An OpenCode plugin that sends agent status to Home Assistant via webhooks -- and optionally lets you respond to permission requests from HA.
Requires OpenCode 2.2 or later. For OpenCode 1, install the opencode-v1 dist-tag
(npm install opencode-homeassistant@opencode-v1).
- Notifies Home Assistant when the OpenCode agent becomes busy, idle, waiting, or encounters an error
- Sends the hostname alongside the state, so you can identify which machine triggered the automation
- Tracks session duration --
idle,waiting, anderrorpayloads includedurationMs(time since the lastbusyevent) - Flushes pending webhooks when OpenCode exits, including a final
idleupdate for active sessions - Automatically cleans up sessions stuck in
busystate after 10 minutes of no activity, checked every minute - Per-state webhook routing -- send different states to different webhook IDs
- Multiple webhook targets -- send the same state to several Home Assistant instances
- Includes question choices in
waitingpayloads -- HA can render actionable notifications with the available options - Remote permission response -- approve or deny agent permissions from HA (via entity polling)
- JSON payload, compatible with Home Assistant's webhook trigger out of the box
| State | OpenCode event | Description |
|---|---|---|
busy |
session.execution.started, repeated on session.step.started |
Agent starts or continues processing |
idle |
session.execution.succeeded, session.execution.interrupted |
Agent finishes and is waiting for input |
waiting |
permission.asked |
Agent requests user permission |
waiting |
form.created |
Agent asks the user a question |
error |
session.execution.failed |
Session encounters an error |
The plugin sends a POST request with Content-Type: application/json:
{
"state": "idle",
"hostname": "my-macbook",
"project": "my-app",
"sessionId": "01JFF...",
"durationMs": 12345
}| Field | Description |
|---|---|
state |
One of busy, idle, waiting, error |
hostname |
Machine hostname (os.hostname()) |
project |
Directory name of the current project |
sessionId |
OpenCode session ID (useful for correlating events) |
durationMs |
Milliseconds since the session became busy (omitted from busy payloads) |
waiting |
Details about what the agent is waiting for (only on waiting payloads) |
| Field | Description |
|---|---|
reason |
Either permission (agent needs approval) or question (agent asks a question) |
id |
Permission ID or question request ID, used for remote replies |
type |
Permission action, e.g. shell, edit, webfetch (only for permission) |
title |
Human-readable description of the request |
pattern |
The command or path pattern being requested (only for permission) |
questions |
Array of question details with options (only for question) |
In a Home Assistant automation, access these values via trigger.json.*, e.g. trigger.json.state.
{
"state": "waiting",
"hostname": "my-macbook",
"project": "my-app",
"sessionId": "ses_...",
"durationMs": 4200,
"waiting": {
"reason": "permission",
"id": "per_cd77d4766001...",
"type": "shell",
"title": "shell: mkdir test-folder",
"pattern": ["mkdir test-folder"]
}
}{
"state": "waiting",
"hostname": "my-macbook",
"project": "my-app",
"sessionId": "ses_...",
"durationMs": 5000,
"waiting": {
"reason": "question",
"id": "frm_014e8b5fe001...",
"title": "Choose framework",
"questions": [
{
"header": "Choose framework",
"question": "Which framework would you like to use?",
"options": [
{ "label": "React", "value": "React", "description": "Component-based UI library" },
{ "label": "Vue", "value": "Vue", "description": "Progressive framework" }
]
}
]
}
}Each question in the questions array has:
| Field | Type | Description |
|---|---|---|
header |
string |
Short label |
question |
string |
Full question text |
options |
array |
Available choices (label, value, and description) |
multiple |
boolean? |
Whether multiple selections are allowed |
opencode plugin add opencode-homeassistantThis adds the plugin to your opencode.json:
{
"$schema": "https://opencode.ai/config.json",
"plugins": ["opencode-homeassistant"]
}The package has a server entrypoint, which sends webhooks and polls Home Assistant, and a CLI entrypoint, which OpenCode loads automatically. The CLI entrypoint only delivers question answers from Home Assistant, because OpenCode 2 lets only CLI plugins reply to questions.
Create ~/.config/opencode/opencode-homeassistant.json:
{
"webhookUrl": "https://your-home-assistant/api/webhook/your_webhook_id"
}The config file path can be overridden with the OPENCODE_HA_CONFIG_PATH environment variable. See webhook trigger documentation at Home Assistant.
You can also pass any of these settings as plugin options. Options take precedence over the config file:
{
"plugins": [
{
"package": "opencode-homeassistant",
"options": { "webhookUrl": "https://ha.local/api/webhook/opencode_status" }
}
]
}If no webhook URLs are configured or the config file is missing, the plugin is disabled silently. The plugin reads its configuration when it loads, so restart OpenCode after changing it.
The plugin writes nothing by default. To troubleshoot remote replies, enable the debug log:
{
"debug": true,
"debugLogPath": "~/.local/state/opencode/homeassistant.log"
}debugLogPath defaults to ~/.local/state/opencode/homeassistant.log. At startup the plugin
records its resolved version and remote reply settings:
started version=X.Y.Z, entity=input_text.opencode_permission_response, permissionTimeout=120s, remoteReplies=enabled
Each poll then records when it starts, which response it matched, and why it gave up.
Use webhookUrls to send different states to different webhook IDs. A default key acts as a fallback for any state without its own entry:
{
"webhookUrls": {
"busy": "https://ha.local/api/webhook/opencode_busy",
"error": "https://ha.local/api/webhook/opencode_error",
"default": "https://ha.local/api/webhook/opencode_general"
}
}With this config, busy and error events go to their own webhooks while idle and waiting fall back to the default webhook.
Each entry in webhookUrls can be a single URL or an array, allowing you to notify multiple Home Assistant instances or trigger several automations at once:
{
"webhookUrls": {
"default": [
"https://ha-home.local/api/webhook/opencode_status",
"https://ha-office.local/api/webhook/opencode_status"
],
"error": "https://ha-home.local/api/webhook/opencode_errors"
}
}The plugin resolves webhook URLs in this order:
webhookUrls[state]-- exact match for the current statewebhookUrls.default-- fallback for unmatched stateswebhookUrl-- legacy single-URL config (used whenwebhookUrlsis absent)
The plugin can relay permission responses from Home Assistant back to OpenCode. When a permission.asked event fires, the plugin sends the webhook and starts polling an HA entity for a response. If the user answers the permission locally in the TUI, the poll aborts immediately.
To enable this, add the following to your config:
{
"webhookUrl": "https://ha.local/api/webhook/opencode_status",
"haApiUrl": "https://ha.local/api",
"haToken": "${HA_LONG_LIVED_TOKEN}",
"permissionResponseEntity": "input_text.opencode_permission_response",
"permissionTimeout": 120
}| Field | Default | Description |
|---|---|---|
haApiUrl |
(required) | Home Assistant REST API base URL |
haToken |
(required) | Long-lived access token (supports ${ENV_VAR} expansion) |
permissionResponseEntity |
input_text.opencode_permission_response |
Entity the plugin polls for the user's response |
permissionTimeout |
120 |
Seconds to wait for a response before giving up |
Important
${ENV_VAR} expansion reads the opencode process environment, not your interactive shell. Exporting the variable in .zshrc is not enough when opencode is launched from a GUI app, a launch agent, or any non-login context, and opencode does not read .env files. If the variable is missing, remote replies are silently skipped: the notification still arrives, but tapping it does nothing.
A reliable option is a mise [env] entry, which does reach opencode:
# ~/.config/mise/config.local.toml (machine-local, untracked)
[env]
HA_LONG_LIVED_TOKEN = "eyJhbGciOi..."To check what opencode actually sees, enable the debug log and answer a prompt. The plugin names the exact missing variable:
not polling because haToken references HA_LONG_LIVED_TOKEN,
which is not set in the opencode process environment
How it works:
- Plugin sends the
waitingwebhook (withwaiting.idset to the permission ID or question request ID) - Plugin starts polling
permissionResponseEntityevery 2 seconds - HA automation shows an actionable notification; when the user taps an action, the automation sets the entity state (see formats below)
- Plugin reads the response, clears the entity, and replies to OpenCode. Question answers go through the plugin's CLI entrypoint, so they need a connected OpenCode terminal interface.
- If the user answers locally in the TUI instead, the plugin receives a
permission.replied,form.replied, orform.cancelledevent and stops polling immediately
Each pending request gets its own webhook and its own poll. When an agent asks for several permissions at once, you receive one notification per request and can answer them in any order. The session resumes only after every request has been answered, so make sure your automation includes the request ID in the response rather than assuming a single outstanding request.
Response formats
The entity value is disambiguated by splitting on :. A first segment of question marks a question response (3 segments); anything else is a permission response (2 segments).
| Kind | Format | Example |
|---|---|---|
| Permission | <permissionId>:<response> |
per_abc123:allow |
| Question | question:<requestId>:<optionIndex> |
question:frm_xyz789:0 |
Valid permission responses: allow, always, deny.
<optionIndex> is a 0-based index into questions[0].options -- the same array sent in the waiting payload. An index is used rather than the label because labels may contain :, carry emoji, or exceed the input_text 255-character limit. If the index is out of range or unparsable, the plugin sends no reply rather than a malformed answer.
Notes and limits:
- Only
questions[0]is answered. Multi-question requests are not supported. - Exactly one option is sent, since iOS notification actions are single-tap. For
multiple: truequestions, the answer contains only that option. custom: true("Type your own answer") is not representable, as HA builds buttons fromoptionsonly.- HA should clear the entity shortly after setting it (5 seconds is a good default) to stop stale values latching. The 2-second poll interval sits comfortably inside that window -- do not raise it much beyond 2s without widening the reset delay.
Change the color of a smart bulb or LED strip based on agent state -- red for errors, yellow for waiting on input, green for busy, off when idle:
automation:
- alias: OpenCode busy light
triggers:
- trigger: webhook
webhook_id: your_webhook_id
allowed_methods:
- POST
local_only: false
actions:
- choose:
- conditions: "{{ trigger.json.state == 'busy' }}"
sequence:
- action: light.turn_on
target:
entity_id: light.desk_led
data:
color_name: green
brightness: 128
- conditions: "{{ trigger.json.state == 'waiting' }}"
sequence:
- action: light.turn_on
target:
entity_id: light.desk_led
data:
color_name: yellow
brightness: 200
- conditions: "{{ trigger.json.state == 'error' }}"
sequence:
- action: light.turn_on
target:
entity_id: light.desk_led
data:
color_name: red
brightness: 255
- conditions: "{{ trigger.json.state == 'idle' }}"
sequence:
- action: light.turn_off
target:
entity_id: light.desk_ledA trigger-based template sensor gives you a rich entity with attributes and derived state logic. This version tracks multiple concurrent sessions -- one session going idle won't overwrite the state of another still-busy session:
template:
- trigger:
- trigger: webhook
webhook_id: your_webhook_id
allowed_methods:
- POST
local_only: true
- trigger: event
event_type: timer.finished
event_data:
entity_id: timer.opencode_agent_state
sensor:
- name: OpenCode Agent Status
unique_id: opencode_agent_status
device_class: enum
state: >
{%- if trigger.platform == 'event' -%}
idle
{%- else -%}
{%- set prev = this.attributes.get('sessions', {}) -%}
{%- set sid = trigger.json.sessionId | default('unknown') -%}
{%- set raw = trigger.json.state -%}
{%- set was_busy_long = raw == 'idle'
and prev.get(sid, {}).get('state') == 'busy'
and (trigger.json.durationMs | default(0) | int) >= 10000 -%}
{%- set effective = 'completed' if was_busy_long else raw -%}
{%- set ns = namespace(states=[]) -%}
{%- for k, v in prev.items() if k != sid and v.state != 'idle' -%}
{%- set ns.states = ns.states + [v.state] -%}
{%- endfor -%}
{%- if effective != 'idle' -%}
{%- set ns.states = ns.states + [effective] -%}
{%- endif -%}
{%- if ns.states | length == 0 -%}
{{ effective }}
{%- else -%}
{%- set prio = {'error': 0, 'waiting': 1, 'busy': 2, 'completed': 3} -%}
{%- set ns2 = namespace(best='idle', best_prio=99) -%}
{%- for s in ns.states -%}
{%- if prio.get(s, 50) < ns2.best_prio -%}
{%- set ns2.best = s -%}
{%- set ns2.best_prio = prio.get(s, 50) -%}
{%- endif -%}
{%- endfor -%}
{{ ns2.best }}
{%- endif -%}
{%- endif -%}
icon: >
{%- set icons = {
'busy': 'mdi:robot-outline',
'waiting': 'mdi:robot-confused-outline',
'error': 'mdi:robot-dead-outline',
'completed': 'mdi:robot-happy-outline',
} -%}
{{ icons.get(this.state, 'mdi:robot-off-outline') }}
attributes:
sessions: >
{%- if trigger.platform == 'event' -%}
{}
{%- else -%}
{%- set prev = this.attributes.get('sessions', {}) -%}
{%- set sid = trigger.json.sessionId | default('unknown') -%}
{%- set raw = trigger.json.state -%}
{%- set ns = namespace(sessions={}) -%}
{%- for k, v in prev.items() if k != sid and v.state != 'idle' -%}
{%- set ns.sessions = dict(ns.sessions, **{k: v}) -%}
{%- endfor -%}
{%- set was_busy_long = raw == 'idle'
and prev.get(sid, {}).get('state') == 'busy'
and (trigger.json.durationMs | default(0) | int) >= 10000 -%}
{%- set effective = 'completed' if was_busy_long else raw -%}
{%- if effective != 'idle' -%}
{%- set entry = {
'state': effective,
'hostname': trigger.json.hostname | default(''),
'project': trigger.json.project | default(''),
} -%}
{%- set ns.sessions = dict(ns.sessions, **{sid: entry}) -%}
{%- endif -%}
{{ ns.sessions | to_json }}
{%- endif -%}
hostname: >
{%- if trigger.platform == 'webhook' -%}
{{ trigger.json.hostname }}
{%- else -%}
{{ this.attributes.get('hostname', '') }}
{%- endif -%}
project: >
{%- if trigger.platform == 'webhook' -%}
{{ trigger.json.project | default('') }}
{%- else -%}
{{ this.attributes.get('project', '') }}
{%- endif -%}
session_id: >
{%- if trigger.platform == 'webhook' -%}
{{ trigger.json.sessionId | default('') }}
{%- else -%}
{{ this.attributes.get('session_id', '') }}
{%- endif -%}
duration_ms: >
{%- if trigger.platform == 'webhook' -%}
{{ trigger.json.durationMs | default(none) }}
{%- else -%}
{{ this.attributes.get('duration_ms', none) }}
{%- endif -%}
waiting_reason: >
{%- if trigger.platform == 'webhook' and trigger.json.waiting is defined -%}
{{ trigger.json.waiting.reason | default(none) }}
{%- elif trigger.platform == 'webhook' -%}
{{ none }}
{%- else -%}
{{ this.attributes.get('waiting_reason', none) }}
{%- endif -%}
waiting_title: >
{%- if trigger.platform == 'webhook' and trigger.json.waiting is defined -%}
{{ trigger.json.waiting.title | default(none) }}
{%- elif trigger.platform == 'webhook' -%}
{{ none }}
{%- else -%}
{{ this.attributes.get('waiting_title', none) }}
{%- endif -%}
permission_id: >
{%- if trigger.platform == 'webhook' and trigger.json.waiting is defined -%}
{{ trigger.json.waiting.id | default(none) }}
{%- elif trigger.platform == 'webhook' -%}
{{ none }}
{%- else -%}
{{ this.attributes.get('permission_id', none) }}
{%- endif -%}
question_options: >
{%- if trigger.platform == 'webhook' and trigger.json.waiting is defined
and trigger.json.waiting.questions is defined
and trigger.json.waiting.questions | length > 0
and trigger.json.waiting.questions[0].options is defined -%}
{{ trigger.json.waiting.questions[0].options | to_json }}
{%- elif trigger.platform == 'webhook' -%}
[]
{%- else -%}
{{ this.attributes.get('question_options', []) | to_json }}
{%- endif -%}
timer:
opencode_agent_state:
name: OpenCode agent state auto-revert
duration: '00:00:05'
automation:
- alias: OpenCode agent state timer control
mode: restart
triggers:
- trigger: state
entity_id: sensor.opencode_agent_status
actions:
- if:
- condition: state
entity_id: sensor.opencode_agent_status
state: [error, completed]
then:
- action: timer.start
target:
entity_id: timer.opencode_agent_state
else:
- action: timer.cancel
target:
entity_id: timer.opencode_agent_stateThe aggregate state reflects the highest-priority active session (error > waiting > busy > completed > idle). The sessions attribute stores a dict of active sessions keyed by sessionId, each with state, hostname, and project.
The completed state is synthesized when the agent goes from busy → idle after at least 10 seconds -- a signal that a real task finished. The timer ensures transient states (error, completed) auto-revert to idle after 5 seconds. The waiting_* and permission_id attributes are cleared when a non-waiting webhook arrives, preventing stale values.
Get a push notification with action buttons when the agent has been busy for a while (>30s) and then needs your input. This uses a separate webhook so it works independently of the sensor and correctly handles parallel sessions.
Plugin config -- route waiting payloads to both the sensor and the notification webhook:
{
"webhookUrls": {
"default": "https://ha.local/api/webhook/opencode_agent_status",
"waiting": [
"https://ha.local/api/webhook/opencode_agent_status",
"https://ha.local/api/webhook/opencode_agent_notify"
]
}
}Since webhookUrls[state] overrides default entirely, the waiting entry must include the sensor webhook too.
HA automation -- triggers on the notification webhook, reads all data from the payload:
input_text:
opencode_permission_response:
name: OpenCode Permission Response
initial: ''
max: 255
automation:
- alias: Notify when OpenCode needs input after long-running prompt
mode: parallel
max: 10
triggers:
- trigger: webhook
webhook_id: opencode_agent_notify
allowed_methods:
- POST
local_only: true
conditions:
- condition: template
value_template: >
{{ trigger.json.state == 'waiting'
and (trigger.json.durationMs | default(0) | int) >= 30000 }}
actions:
- variables:
waiting: '{{ trigger.json.waiting | default({}) }}'
waiting_reason: "{{ waiting.reason | default('') }}"
is_permission: "{{ waiting_reason == 'permission' }}"
is_question: "{{ waiting_reason == 'question' }}"
permission_id: "{{ waiting.id | default('') }}"
question_options: >
{%- if waiting.questions is defined
and waiting.questions | length > 0
and waiting.questions[0].options is defined -%}
{{ waiting.questions[0].options }}
{%- else -%}
[]
{%- endif -%}
action_approve: "{{ 'APPROVE_' ~ context.id }}"
action_deny: "{{ 'DENY_' ~ context.id }}"
action_always: "{{ 'ALWAYS_' ~ context.id }}"
- variables:
notification_message: >
{{ trigger.json.project | default('unknown') }}
on {{ trigger.json.hostname | default('unknown') }}
{%- if waiting.title not in [none, ''] %}
— {{ waiting.title }}
{%- endif %}
question_actions: >
{%- set opts = question_options if question_options is list else [] -%}
{%- set ns = namespace(actions=[]) -%}
{%- for opt in opts[:10] -%}
{%- set ns.actions = ns.actions + [{"action": "OPT_" ~ loop.index0 ~ "_" ~ context.id, "title": opt.label}] -%}
{%- endfor -%}
{{ ns.actions }}
- choose:
- alias: Permission notification
conditions: '{{ is_permission }}'
sequence:
- action: notify.mobile_app_your_phone
data:
title: opencode is waiting for input
message: '{{ notification_message }}'
data:
push:
interruption-level: time-sensitive
actions:
- action: '{{ action_approve }}'
title: Approve
- action: '{{ action_deny }}'
title: Deny
destructive: true
- action: '{{ action_always }}'
title: Always Allow
- wait_for_trigger:
- trigger: event
event_type: mobile_app_notification_action
event_data:
action: '{{ action_approve }}'
- trigger: event
event_type: mobile_app_notification_action
event_data:
action: '{{ action_deny }}'
- trigger: event
event_type: mobile_app_notification_action
event_data:
action: '{{ action_always }}'
timeout: '00:02:00'
continue_on_timeout: true
- choose:
- alias: Approve
conditions: '{{ wait.trigger is defined and wait.trigger.event.data.action == action_approve }}'
sequence:
- action: input_text.set_value
target:
entity_id: input_text.opencode_permission_response
data:
value: '{{ permission_id }}:allow'
- alias: Always Allow
conditions: '{{ wait.trigger is defined and wait.trigger.event.data.action == action_always }}'
sequence:
- action: input_text.set_value
target:
entity_id: input_text.opencode_permission_response
data:
value: '{{ permission_id }}:always'
- alias: Deny
conditions: '{{ wait.trigger is defined and wait.trigger.event.data.action == action_deny }}'
sequence:
- action: input_text.set_value
target:
entity_id: input_text.opencode_permission_response
data:
value: '{{ permission_id }}:deny'
- alias: Question notification with options
conditions: '{{ is_question and question_actions | length > 0 }}'
sequence:
- action: notify.mobile_app_your_phone
data:
title: opencode is waiting for input
message: '{{ notification_message }}'
data:
push:
interruption-level: time-sensitive
actions: '{{ question_actions }}'
default:
- action: notify.mobile_app_your_phone
data:
title: opencode is waiting for input
message: '{{ notification_message }}'
data:
push:
interruption-level: time-sensitiveKey design choices:
- Dedicated webhook (
opencode_agent_notify) -- the automation triggers directly from the webhook payload, not from sensor state changes. This avoids race conditions when multiple sessions are active and ensuresdurationMsis accurate per-session. mode: parallel-- multiple concurrent sessions can each have their own notification in-flight.durationMs >= 30000-- uses the plugin's per-session busy timer instead of HA'slast_changed, which can be inaccurate when sessions overlap.- Permission reply -- tapping Approve/Deny writes to
input_text.opencode_permission_responseusing the format<permissionId>:<response>, which the plugin polls for. - Question actions -- dynamically builds notification buttons from the question's options (up to 10, the iOS limit). Tapping one writes
question:<requestId>:<optionIndex>to the same entity; the plugin resolves the index back to the option label and replies to OpenCode.
Log how long each agent interaction took. The durationMs field is included in idle, waiting, and error payloads, measuring the time since the session last became busy:
automation:
- alias: OpenCode session duration
triggers:
- trigger: webhook
webhook_id: your_webhook_id
allowed_methods:
- POST
local_only: false
conditions:
- condition: template
value_template: '{{ trigger.json.durationMs is defined }}'
actions:
- action: input_number.set_value
target:
entity_id: input_number.opencode_last_duration_seconds
data:
value: '{{ (trigger.json.durationMs / 1000) | round(1) }}'