Bridge protocol
The RPCs the bridge serves on its Unix socket, and their wire format.
The bridge serves Effect RPCs as newline-delimited JSON on a Unix socket. From TypeScript, use @timmo001/effect-ha-bridge, which handles the framing for you. This page is for other languages and for debugging.
The socket is at the socket path, in a directory only you can open (0700), and is itself only accessible by you (0600).
RPCs
| RPC | Payload | Result |
|---|---|---|
GetEntities |
{ "target": Target, "domain"?: string } |
A list of entity updates, one for each entity the target matches |
WatchEntities |
{ "target": Target, "domain"?: string } |
A stream of entity updates: the current state of each matching entity, then every change |
CallAction |
{ "action": string, "data"?: object, "target"?: Target, "return_response"?: boolean } |
The action’s response when return_response is true, otherwise null |
GetConfig |
null |
Home Assistant’s config, such as time_zone, location_name and version |
CameraSnapshot |
{ "target": Target } |
{ "contentType": string, "data": string }, with the image bytes base64-encoded in data |
Search |
{ "query"?: string, "target"?: Target, "kinds"?: ("entity" | "device" | "area")[], "domain"?: string, "deviceClass"?: string, "limit"?: number, "offset"?: number } |
{ "results": SearchMatch[], "total": number, "unavailable": string[] } |
RenderTemplate |
{ "template": string, "variables"?: object, "strict"?: boolean, "timeout"?: number } |
{ "result": Json, "warnings": string[] }, the first render |
WatchTemplate |
{ "template": string, "variables"?: object, "strict"?: boolean, "timeout"?: number } |
A stream of renders, each { "result": Json } or { "error": string, "level": string }, rendering again after reconnects |
WatchEvents |
{ "event_type"?: string } |
A stream of events, each with event_type, data, origin, time_fired and context |
FireEvent |
{ "event_type": string, "event_data"?: object } |
Nothing |
GetHistory |
{ "target": Target, "domain"?: string, "start_time": string, "end_time"?: string, "no_attributes"?: boolean, "all_changes"?: boolean } |
An object keyed by entity ID, each a list of { "state", "attributes"?, "last_changed", "last_updated" } |
GetLogbook |
{ "target"?: Target, "domain"?: string, "start_time": string, "end_time"?: string } |
A list of logbook entries, each with an ISO when |
WatchLogbook |
{ "target"?: Target, "domain"?: string } |
A stream of new logbook entries, carrying on from each reconnect |
WatchTrigger |
{ "trigger": object | object[], "variables"?: object } |
A stream of { "variables": object, "context": object | null }, one each time a trigger fires |
TestCondition |
{ "condition": object, "variables"?: object } |
{ "result": boolean, "template_errors"?: string[] } |
WatchCondition |
{ "condition": object } |
A stream of { "result": boolean } or { "error": string }, each with optional template_errors, sent when the result changes |
GetSystemLog |
{ "search"?: string } |
Core’s logged errors and warnings, newest first, each as system_log/list sends it: name, message, level, source, timestamp, first_occurred, exception and count |
WatchSystemLog |
{ "search"?: string } |
A stream of new errors and warnings, and repeats of earlier ones, read every 5 seconds |
GetLog |
{ "source": LogSource, "lines"?: number, "boot"?: number, "latest"?: boolean, "verbose"?: boolean, "integration"?: string, "search"?: string } |
A list of the log’s latest records, oldest first |
WatchLog |
{ "source": LogSource, "lines"?: number, "verbose"?: boolean, "integration"?: string, "search"?: string } |
A stream of the log’s latest records, then each new one, following reconnects |
GetLogBoots |
null |
The host’s boots, an object of boot IDs keyed by offset, "0" for the current boot |
GetLogApps |
null |
A list of installed apps, each { "slug", "name", "state", "version" } |
SearchLogs |
{ "query": string, "sources"?: ("errors" | LogSource)[], "lines"?: number, "limit"?: number, "offset"?: number } |
{ "results": { "source", "record", "score" }[], "total": number }, best match first |
GetEntityConfig |
{ "domain": "automation" | "script", "entity_id": string } |
{ "domain", "entity_id", "id": string | null, "editable": boolean, "config": object } |
CreateEntityConfig |
{ "domain": "automation" | "script", "id"?: string, "config": object } |
{ "domain", "id" } once Home Assistant has validated and saved it |
UpdateEntityConfig |
{ "domain": "automation" | "script", "entity_id": string, "config": object } |
{ "domain", "id" } once Home Assistant has validated and saved it |
DeleteEntityConfig |
{ "domain": "automation" | "script", "entity_id": string } |
{ "domain", "id" } once it’s removed |
ValidateEntityConfig |
{ "domain": "automation" | "script", "config": object } |
{ "valid": boolean, "errors": { "part", "message" }[], "unknown": { "kind", "id" }[] }, without saving anything |
A target is { "entity_id"?, "device_id"?, "area_id"?, "floor_id"?, "label_id"? }, each a string or a list of strings, as in Home Assistant. Each value is an ID or a name, which the bridge resolves to an ID: an exact ID first, then an entity ID without the domain when there is a domain (or the action’s domain), then a unique case-insensitive name. Home Assistant then expands the target, so an area includes entities on devices in it. domain keeps only entities in that domain.
An entity update is { "state": EntityState, "name": string }. state is the state object Home Assistant sends, with entity_id, state, attributes, last_changed, last_reported, last_updated and context (id, parent_id, user_id); everything past state can be missing. name is the display name described in Bar JSON. WatchEntities expands the target again after Home Assistant reconnects and when a registry changes, sending the state of every entity it newly matches, and sends an entity’s state again when its display name changes.
A search match is { "kind", "id", "name", "score", "matched" } plus domain, deviceClass, device, parentDevice, area and floor when they’re known. total counts every match before offset and limit (default 20) are applied, and unavailable lists any registries the bridge couldn’t load, such as area_registry. Without a query, Search lists every item the target and filters allow, by kind then name, each with a score of 100. A target keeps only the entities, devices and areas Home Assistant says it refers to. Search reads the bridge’s cache, asking Home Assistant only to expand a target, and fails with SearchEmpty ({ "_tag": "SearchEmpty" }) when it has no query, target, domain or deviceClass. Searching describes the matching.
GetEntities, CallAction, GetConfig, CameraSnapshot and Search with a target fail with HomeAssistantError ({ "_tag": "HomeAssistantError", "message": string }) when Home Assistant rejects the request or the bridge isn’t connected to it. Every RPC with a target fails with TargetError ({ "_tag": "TargetError", "message": string }) when a name matches nothing or more than one item, when Home Assistant doesn’t know an ID, or when CameraSnapshot’s target doesn’t match exactly one camera. WatchEntities waits for a connection instead of failing. CallAction takes the same action, data and target keys as actions in Home Assistant automations.
A log source is one of "core", "supervisor", "host", "audio", "cli", "dns", "multicast" and "observer", or { "app": string } with an app’s slug. boot is an offset, 0 for this boot and -1 for the one before. latest and integration only apply to Core’s log; integration reads Core’s live log, which needs Home Assistant 2026.11 or newer, as does WatchLog for Core. A search is fuzzy, as Search is: GetLog, WatchLog and the system log RPCs keep matching records in time order, and SearchLogs ranks them, reading Core’s errors and Core’s log when sources is left out. Logs describes each log.
The log RPCs fail with LogUnavailable ({ "_tag": "LogUnavailable", "reason": "admin" | "supervisor" | "permission" | "unsupported", "message": string }) before asking Home Assistant when the bridge’s token doesn’t belong to an admin, when the log needs a Supervisor that Home Assistant doesn’t have, or when Home Assistant can’t serve the request, such as Core’s live log on an older Core. Inside a Home Assistant app, they fail with permission when the Supervisor refuses the app because its config.yaml lacks the access the log needs. Clients can skip the log on LogUnavailable rather than treat it as a failure. They fail with HomeAssistantError when Home Assistant rejects the request or the bridge isn’t connected to it.
The config RPCs work as Home Assistant’s automation and script editors do. GetEntityConfig returns the config any automation or script was loaded from, wherever it’s defined. id is the editor’s key, an automation’s id or a script’s key in scripts.yaml, and editable says whether it’s in automations.yaml or scripts.yaml, the only files the editor and these RPCs write. CreateEntityConfig adds one, making up an automation’s id or a script’s key from its alias when id is left out. UpdateEntityConfig replaces all of one’s config. Home Assistant validates a config before saving it, then reloads it, so a new entity appears shortly after. They fail with ConfigUnavailable ({ "_tag": "ConfigUnavailable", "reason": "admin" | "not_editable", "message": string }) before asking Home Assistant when the bridge’s token doesn’t belong to an admin, or when an update or delete targets one that isn’t in the editor’s file. They fail with HomeAssistantError when Home Assistant rejects the config, with its reason, or doesn’t know the entity.
ValidateEntityConfig checks a config without saving it, and doesn’t need an admin token. Home Assistant checks its triggers, conditions and actions (a script’s sequence) as the automation editor does, and each part it rejects, or a required part that’s missing, is in errors as { "part": "triggers" | "conditions" | "actions", "message" }. unknown lists what the config names that Home Assistant doesn’t have, each { "kind": "entity" | "action" | "device" | "area" | "floor" | "label", "id" }, much as Spook reports them. It reads the IDs the config gives outright, such as entity_id, area_id and action. It skips templates, steps with enabled: false, event data and variables, and entities in domains an action can create as it runs (group, device_tracker and persistent_notification). A registered entity that’s disabled isn’t unknown. valid is true when both lists are empty.
Messages
Each line is one JSON message. Send a request:
{"_tag":"Request","id":"1","tag":"GetEntities","payload":{"target":{"entity_id":"sun.sun"}},"headers":[]}
A single result comes back as an Exit with the same ID:
{"_tag":"Exit","requestId":"1","exit":{"_tag":"Success","value":[{"state":{"entity_id":"sun.sun","state":"above_horizon","attributes":{}},"name":"Sun"}]}}
A failure has "_tag":"Failure" and a cause list instead of value. An RPC’s own error is a Fail entry:
{"_tag":"Exit","requestId":"2","exit":{"_tag":"Failure","cause":[{"_tag":"Fail","error":{"_tag":"HomeAssistantError","message":"camera snapshot: StatusCode: non 2xx status code (404 GET ...)"}}]}}
A malformed request comes back as a Die entry, such as {"_tag":"Die","defect":"Expected null"} when GetConfig has no payload.
Keep the connection open until the reply arrives. When the client closes its side, the bridge ends that client’s requests that are still running.
Streams send Chunk messages, each with one or more values:
{"_tag":"Chunk","requestId":"1","values":[{"state":{"entity_id":"sun.sun","state":"above_horizon","attributes":{}},"name":"Sun"}]}
After each chunk, the bridge waits for an Ack before sending the next one:
{"_tag":"Ack","requestId":"1"}
To stop a stream, send {"_tag":"Interrupt","requestId":"1"} or close the connection.
Try it
socket="$XDG_RUNTIME_DIR/ha-bridge/ha-bridge.sock"
{
echo '{"_tag":"Request","id":"1","tag":"GetConfig","payload":null,"headers":[]}'
sleep 1
} | socat - "UNIX-CONNECT:$socket"