The interface Bridge Core exposes: JSON over a Unix domain socket, one object per
line. Every surface speaks it — the menu-bar shell, bridgectl, the Companion
Channel, and spoken commands inside a Live Call — and no surface has a private
protocol beside it.
It is never gated by any switch (ADR 0002,
absolute). Every action below answers with the Duty, Voice and Message Switches
all off. The reference implementation gated seven actions behind the Duty
Switch; that behaviour is dropped, not ported, and
tests/test_control_plane_actions.py is the regression test.
This document is the contract the Swift shell (#11) implements against. The
Python vocabulary is gpt_voicecoding.seams.control_plane; the mechanism is
gpt_voicecoding.control_plane.
| What | Where | Why |
|---|---|---|
| The socket | /tmp/gpt-voicecoding-<uid>/control.sock |
Darwin caps an AF_UNIX path at 103 bytes, and the application-support path is already 76 of them before a long home directory is considered. The runtime root is short, per-uid, created 0700 at engine start (/tmp is cleared on reboot, which is correct for a socket), ownership-checked with lstat before adoption, and the socket itself is 0600. |
| The durable state | ~/Library/Application Support/GPT-VoiceCoding/engine/state.json |
It outlives reboots, and nothing but Bridge Core may read it. |
| The configuration | ~/Library/Application Support/GPT-VoiceCoding/engine/config.toml |
The user owns it; the engine only reads it. |
So the socket path is not derivable from the state path. A surface reads it
from configuration, or is told it directly (bridgectl --socket). Both paths are
overridable, and a configured socket path longer than 103 bytes is refused at
start with a named error rather than an OSError from inside asyncio.
- One request per line, UTF-8,
\n-terminated. One reply per request, on the same connection, in order. - A line may not exceed 65536 bytes in either direction. A request that
overruns it is answered with
malformed_requestand that connection is closed — there is no honest way to resync inside a line. - Reply capacity is measured and written with one canonical UTF-8 JSON-line
encoder, including JSON escaping and the terminating newline. The server
performs a final outbound check. If an honest response skeleton cannot fit,
it sends a bounded
refusedreply; it never drops Session rows silently. - A connection may carry any number of requests. Connections are independent: two surfaces can neither wedge nor read each other.
- Malformed input costs one request, never the server.
- The socket is refused by both sides unless it is owned by the current user and
private (
0600). A live engine is never displaced; debris from a dead one is cleared.
{"action": "switch", "payload": {"name": "duty", "on": true}}payload may be omitted when an action takes nothing.
reader is an optional field beside action, naming who the answer is for
when that changes what may be carried. Its only value is "voice": the answer
will cross the Live Call's return leg, where the Call Agent hands it to the
Voice and codex cuts anything over 4,000 UTF-8 bytes, keeping ~2,000 bytes from
each end and leaving a marker that says how much went but never which part. A
history or brief request that carries it is fitted to that line as well as
to this one, and the tighter of the two binds; on every other action it is
carried and ignored. Absent — or JSON null — is exactly the behaviour that
existed before the field, which is what keeps the Companion Channel byte for
byte unchanged. An unrecognised value is malformed_request, naming the values
there are. The engine sets it, in the bridgectl invocation it generates for
the Call Agent (--reader voice); the Companion Channel's / grammar never
does (#302,
ADR 0016 as amended).
{"action": "history", "payload": {"target": {"agent": "codex", "session_id": "abc"}}, "reader": "voice"}{"ok": true, "action": "switch", "protocol": 8, "data": {"name": "duty", "on": true, "previous": false}}{"ok": false, "action": "switch", "protocol": 8, "error": {"code": "unknown_switch", "message": "unknown switch: 'sound'"}}action is null when the line never named a usable one. protocol is the
numeric protocol version, currently 12. A missing field or JSON null means the
reply did not declare a usable version. The Swift shell refuses to interpret any
reply whose version is missing or differs from the version it supports, and shows
that protocol mismatch separately from an engine refusal or an unreachable engine.
error.message is rendered verbatim. It is the refusal's own words, from
Bridge Core. A surface that rephrased one would be a second voice deciding what
the user is told.
| Code | Means |
|---|---|
malformed_request |
Not one JSON object this protocol can represent, or past the byte bound. |
unknown_action |
Well-formed, naming an action this engine does not have. |
invalid_payload |
The action is known; what came with it is not usable. |
unknown_switch |
No switch by that name is registered. |
unknown_session |
No Session by that identity was ever registered here. |
stale_session |
Known session id, unreachable under that identity — a fork, or an end. |
unknown_pending |
Nothing is waiting under that id; it was answered, or its hook ended. |
refused |
Any other Bridge Core refusal. Still carries its own words. |
telegram_credentials, telegram_network, telegram_destination, telegram_api |
Binding refused at the named Telegram layer; no token is included. |
engine_unreachable |
Raised by a surface, never sent by the engine: nothing answered. |
status.engine_version is the running engine's package version. status.call_agent
is absent until the first Call Agent is created after startup, and absent again
after forget_call_agent. When present it contains model, effort,
context_window, context_percent, total, and last. Each usage object has
input, output, reasoning, and cached token counts. Usage and context fields
are null until the live thread/tokenUsage/updated notification arrives;
the meter is percent used, with codex's 12,000-token baseline removed from
capacity. These are the Call Agent's tokens, not the Voice's. bridgectl status
prints these fields alongside the existing diagnostics.
models takes no payload and returns {"models": [{"model": "…", "efforts": ["low", "high"]}]}.
The engine asks its shared, long-lived app-server once at startup; reads never
poll codex. Hidden models are omitted. An empty or unavailable catalog returns
an empty list until restart, which fetches a fresh catalog.
forget_call_agent takes no payload and returns {}. It is refused while a call
is connecting, up, or closing. Between calls it clears the current thread and
its usage. A user's live dial always starts a fresh Call Agent; a system dial
continues the current thread or starts one if none exists. This memory is never
persisted across engine restarts.
bind_telegram uses three requests, without changing configuration or storing
the token on disk:
{"token": "<pasted token>"}validates it and returnsbot_name,username,chat_id: null, andchat_type: null. It discards pre-binding updates.- An empty payload checks for a new
/start; while waiting it returns the same pending document. After Start it returns the chat id as a string and Telegram's actual chat type (private,group, orsupergroup), sends one confirmation, and completes. Another empty request after completion is refused. {"cancel": true}abandons a pending binding. A surface dismissing the flow must cancel it. Supplying another token replaces the pending flow.
The existing Telegram wire constructs a transport bound to the pasted token.
A composed Telegram adapter pauses its poll for the entire flow, including
waiting for its in-flight poll to finish, and resumes on success, refusal, or
cancellation. The null channel is unchanged and needs no Telegram knowledge.
Refusals carry the existing Telegram layer words; tokens are never logged.
CLI equivalents are bridgectl models, bridgectl forget_call_agent, and
bridgectl bind_telegram [<token>|--cancel] (no argument checks for Start).
Prefer the socket payload for tokens: entering a literal token in a shell command
can leave it in shell history even though the engine never logs it.
The desktop shell owns one shared poller (#360, maintainer clarification): with
only the Duty Lamp visible, read both status and the roster brief every two
seconds. With the Control Panel window open, read status every second and
brief every two seconds, including the targeted brief while that screen is
open. With neither surface visible, stop polling. The card needs status as
well as brief because its counts, switches, and call state come from status.
Both roster rows and targeted Session Briefs also carry state_word, rendered
by Core's existing wording function. This additive field completes #359's
desktop contract (#360, maintainer clarification): the shell displays it
unchanged rather than parsing text or keeping a second wording table.
Each Session in status also carries brief_state, the same Core-derived
state code as its brief. The original lifecycle state is unchanged. Desktop
counts use brief_state after excluding ended rows, Child Processes, and
Headless Runs from the main Sessions, so prose questions are not miscounted as
finished and the shell does not duplicate Core's question recognition.
The roster brief optionally includes roster.desktop_reminder, shaped as
{"id": "opaque unique identity", "row": <one roster row>}. Core creates it on
an eligible main Session Stop (decision, permission or finished), preserving
that Stop's row snapshot even if the Session resumes before the next read.
The identity is stable across reads and unique across engine lifetimes. New
reminders overwrite the single in-memory record; ordinary progress, naming,
roster ordering and other outlet switches do not create reminders. Duty off
or removal of the target from the live roster clears it.
The desktop treats the first successful read after opening or reconnecting as a baseline, then displays only a changed identity. Missing optional data means no automatic reminder, never a fallback to timestamp differences. Counts and hover continue using their existing fields. This extends the existing brief read; it adds no action, independent poller, queue or delivery receipt.
Fourteen, and the set is closed. Adding one is a contract change. Protocol 12
adds models, forget_call_agent, and bind_telegram (#359). Protocol 6
retired sessions and added brief, the one verb Session state is fetched
through. Protocol 7 retired progress and added history: the Session Brief
carries the newest message whole and the History page carries that message and
everything before it, five at a time with a cursor, so the exact progress
publication had no question left to answer. A protocol-6 surface must report a mismatch — it would
otherwise send progress to an engine that answers unknown_action, and it has
no way to ask for a second page of anything.
Protocol 8 changes no action. It retires pending_approvals from status: a
pending permission is one of the three Session states, so the roster row in
permission is the whole of what is waiting, and its approval_id is the handle
approve answers with. A protocol-7 surface must report a mismatch rather than
count an absent list as zero — "0 pending approvals" over a dialog that is on
screen is a silent wrong number, which is what the version gate exists to
prevent.
Protocol 9 adds sessions, config and assistant — the three verbs the
Companion Channel's command menu opens screens with (ADR 0021 §6 §7, #264,
#265). The
menu is four entries, /assistant, /sessions, /status, /config, and it
advertises only what the shared parser accepts: the entries and the one
sentence shown beside each live in seams/control_plane.py::MENU, and the
Telegram adapter sets them on the bot at first contact and keeps no list of its
own. A protocol-8 surface would send sessions to an engine that answers it
and be unable to tell that from one that refuses it. The sessions here is
not protocol 6's: that one was a second rendering of the roster, and this one
answers with Briefing's own text and adds only labels.
Protocol 10 adds the optional reader field described under Request
(#302). A
protocol-9 engine accepts the same request and ignores the field, answering a
page fitted to this wire alone — which codex then cuts on the way to the Voice,
silently and in the middle. So a surface has to be able to tell the two apart
before it asks. A request that sends no mark is answered at 10 exactly as it was
at 9, and no gate on the field is added on the Python side: bridgectl ships
inside the engine's own package, so the two move together.
launch and close were the eighth and ninth until protocol 4. They are parked
with the code behind them (#72):
v1.0 is a bridge over the Sessions the user starts, so nothing here brings one
into existence or ends one. Their two error codes, launch_failed and
close_failed, went with them. A surface still sending either action is
answered unknown_action.
Payload: none. Data:
{
"switches": {"duty": false, "voice": false, "message": false, "auto_hangup": true},
"sessions": [ /* see below */ ],
"call_id": null,
"pending_relays": [{"request_id": "…", "target": {…}, "kind": "answer", "text": "…",
"route": "deliver", "queued_at": 0.0,
"outcome": "unknown" /* or null: nothing has been attempted */}]
}A session — every field of one roster row, because a surface that had to ask a second question to render one line would be a second reader of the same Session:
{"target": {"agent": "claude", "session_id": "abc", "pid": 1234},
"label": "gpt-voicecoding · build the control plane",
"name": "workspace-claude-ed",
"workspace": "/Users/…", "first_seen": 1787222000.0,
"lifecycle": "live", "state": "idle",
"waiting_for": {"kind": "question", "caught_up": true, "prompt": "Which base?",
"options": [{"text": "main", "description": "Use the default branch",
"recommended": true}],
"recommendation": "main", "tool_name": null, "detail": null,
"approval_id": null, "awaiting": null},
"progress": {"availability": "readable", "has_history": true,
"omission": "status_summary",
"read_at": "2026-08-26T02:44:39+00:00", "recent": []},
"last_activity": "2026-08-26T02:44:39+00:00",
"child": {"kind": "main", "parent": null},
"headless_run": false,
"reply_window": "open"}target is the address; label is for speech and for matching. A label
never crosses the wire as an address — resolving one to a target is Bridge
Core's router, on the way in from the Companion Channel.
child and headless_run are the two tiers a row can be in that are not a
Session, and a row carries both because both stay on this wire: status lists
every row the engine holds, and neither of these is a row the user may be
offered. child.kind is main or child (ADR 0020's Child Process);
headless_run is true for a run with no controlling terminal — a complete
process nobody can type into, kept as a row and never announced (ADR 0020 as
amended, CONTEXT.md's Headless Run). A surface counting the Sessions a
person can see running counts the rows where child.kind is main and
headless_run is false. The key is additive: a reader that does not know it
reads its absence as false, which is the count it had before.
first_seen is wall-clock seconds, and it is when this engine first saw the
Session — no agent knows it.
waiting_for is what a stopped Session stopped on, as structure rather than as a
rendered sentence. kind is one of none, question, permission, peer,
child, unknown; unknown always comes with caught_up: false, and means
ask again, never nothing is happening. A question option always carries
text and recommended; its description is the Agent's optional explanation
of that choice, or null when the Agent supplied none.
awaiting is set on peer and child alone, and it is the lane's own
reference to the party being awaited: the awaited Session's address for peer,
the child's own name for child, and null for a child that has none. The name
the user reads is Core's and travels on a brief's awaited, because a Session
Name climbs (ADR 0024) and a name resolved at the Stop would be announced stale.
A row's progress always carries the same five fields:
availability:not_read,unreadable, orreadable;has_history: a boolean only when readable, otherwisenull;omission:none,older,status_summary, ornewest_oversize(oversizenames one History page entry and never a whole reading);read_at: the readable observation's timestamp, otherwisenull; andrecent: ordered whole entries, each withroleandtext.
Only readable plus has_history=false and omission=none means nothing has
been said. A roster publication carries no chat body: readable history becomes
has_history=true, recent=[], omission=status_summary. not_read and
unreadable are never inferred from an empty array. The source failure behind
an unreadable discovery is reported as lane degradation; a history read maps
it to a typed refusal.
last_activity is separate from the progress summary on purpose. A Session can have moved
without saying anything a reader would show, and this is the field that says so.
It is null when nothing read one.
It is deliberately wider than that summary: it counts any work in the Session,
its own subagents included, where the progress summary carries only what the user
would be shown. On the Claude lane that means a sidechain record advances it even though
such a record never becomes an entry; on the Codex lane it is the thread's own
updatedAt, which moves for any work at all. One meaning on both lanes — and a
Session four minutes into a subagent is not a Session nobody has heard from. A
subagent is not a Child Process: that is a separate agent process, and it has a
row of its own.
reply_window is derived on the row and rendered here, so no surface re-derives
it and no two surfaces can disagree about one Session.
Payload: none, or {"target": {"agent": "codex", "session_id": "abc", "pid": null}}.
Answered by Briefing (core/briefing.py), which is the only thing in this
engine that puts words to what a Session is doing. An omitted target, or one
written as JSON null, means the whole roster — the same reading route gives
an absent value. A target that is present and unusable is refused rather
than widened: a surface that asked about one Session is never answered with all
of them.
Every reply carries kind, text, and the structure text was rendered from:
Roster rows and counts include every live, addressable Session, whether Focus
or not. Rows are ordered by most recent activity descending on every surface;
unknown activity sorts last and ties retain registry order. focus remains
call-side metadata, never a sorting or counting rule. Each row adds newest
(the first line of the newest assistant message, or null when unavailable)
and last_activity_at (ISO 8601, or null). The target-specific Session Brief
continues to carry the whole newest message. Names start at the known project
alone and climb to <project> · <task> when a task source arrives; an unknown
project is not fabricated from a socket address or pid.
{"kind": "roster",
"text": "sessions: 1 waiting for your decision\n …",
"roster": {"counts": {"decision": 1},
"focus": {"agent": "codex", "session_id": "abc", "pid": null},
"rows": [{"target": {…}, "name": "gpt-voicecoding · port the log",
"agent": "codex", "state": "decision", "focus": true,
"newest": "I rebuilt the index.",
"last_activity_at": "2026-09-02T03:04:05+00:00"}]}}{"kind": "session",
"text": "gpt-voicecoding · port the log — codex:abc — waiting for your decision\n …",
"session": {"target": {…}, "name": "gpt-voicecoding · port the log", "agent": "codex",
"state": "decision",
"newest": {"state": "said", "text": "I rebuilt the index."},
"decision": {"prompt": "Which base?",
"options": [{"text": "main", "description": "the default branch",
"recommended": true}],
"recommendation": "main", "tool": null, "summary": null},
"answerable_here": true,
"last_activity_at": "2026-09-02T03:04:05+00:00"}}text is the engine's own rendering, and the words in it never fork.
bridgectl brief prints exactly this string, and so does the engine log. One
source of the words is the point: two would be two descriptions of one Session.
What may differ per surface is the layout (ADR 0021 §5): the Companion
Channel is handed the same brief as a structured carrier beside this text —
state, agent, name, question, option labels in order, recommendation, the newest
message whole — filled from the same wording tables, and an adapter with a shape
of its own (Telegram: a state light, the question in bold, the original folded)
arranges those words and chooses none. An adapter with no layout prints text.
That is what the realtime adapter already does with the spoken brief, and the
rule it restates is the one above: the vocabulary is core/briefing.py's alone.
The structure travels beside the text here too, for a surface that reads fields
rather than lines.
state is one of five, and they are what the user is told:
| State | When |
|---|---|
decision |
A question is put to the user — a question hook, or a question mark in the turn's final prose. A question mark makes a decision; nothing else does. |
permission |
A permission dialog is open. |
waiting_on |
The turn ended with the ball in another Session's hands or in the Session's own Child Process's. The one state word that names somebody: waiting on <name>, filled with the awaited Session's Session Name, the child's own name, or a background command for a child that has none. |
finished |
No question and nothing awaited — including a hand-over that asks nothing, and a stop nobody could read. Exited Sessions appear nowhere. |
running |
Mid-turn. Nothing is being asked of the user. |
unreadable was a sixth state and is retired (#320). A read that failed is
a fact about the message, not a state of the Session, so it is told where it
belongs: the state says nothing is being asked and newest.state says the
message could not be read. Nothing about a failed read is dressed as a decision
the user has to make.
newest is the newest assistant message whole, under ADR 0016's omission
rules — the engine never condenses, so the one-line conclusion a user hears and
the detail they may ask for are one field. Its state is said, nothing_said,
not_read, unreadable or oversize, and text is present only for said.
awaited is who the waiting_on state word names, already in the words the
user reads — the awaited Session's Session Name, or the awaited child's name.
null on every other state, and on a waiting_on whose party has no name of
its own, which the rendered text beside it words as a background command.
decision is null when nothing is being asked. A question carries prompt,
every option and any recommendation; a permission carries tool and a
one-line summary. Both shapes use the one object, and which one it is follows
from state.
answerable_here says whether the user's reply can reach this Session from
here. A question is answerable while its lane still holds the route; a
permission is answerable while a handle still holds the dialog open. Anything
else is answered at the terminal.
The Roster Brief lists one header row per live Session, the Focus Session
first, and children nowhere: every row is one brief <address> answers, and a
Child Process is seen and never spoken to. counts is the others whenever
there is a Focus Session — that Session is named in full, so counting it again
would be one Session told twice.
The Focus Session is never set here. It moves when the user replies to a
Session — relay or approve — and is cleared when that Session ends. Asking
about a Session is not replying to one.
newest travels twice — as a field and inside text — so one whole message can
overflow the line that has to hold it. When it does, the reply still answers:
newest becomes {"state": "oversize", "text": null} and everything the user
acts on — the header, the state, the whole decision — stays. Text is never cut
(ADR 0016).
brief <address> is a read, read now, through exactly one inspect. Its
refusals are history's minus one: unknown_session, stale_session, and
refused for a Child Process or a lane that could not be read at all. A Session
whose progress could not be read is not a refusal here — it answers with
an unreadable newest beside a state read off the wait, whether that Session
is running or has stopped.
Payload: {"target": {"agent": "codex", "session_id": "abc", "pid": null}, "before": 12}. before is optional, and JSON null reads the same as absent.
Data:
{"entries": [{"ordinal": 14, "role": "assistant", "text": "done"},
{"ordinal": 13, "role": "user", "text": "do the thing"},
{"ordinal": 12, "role": "assistant", "omission": "oversize"}],
"older": true,
"read_at": "2026-08-26T02:44:39+00:00"}One page of what a Session said and was told, newest first, bounded by a
count and not by bytes: [policy] history_page_entries, five by default, both
roles counted. The size is the engine's dial and is never on the request — two
surfaces cannot ask one engine for two different pages of one Session.
ordinal is the entry's place in the Session's visible record, counted from
the oldest entry and starting at 0, assigned by the lane at read time. Both
sources are append-only for the entries this carries — a Claude transcript file,
a Codex thread's turns — so an ordinal names the same entry across reads while
the Session lives. older says whether anything remains before the oldest entry
on this page; the next request passes that entry's ordinal as before, which is
exclusive.
No before is the newest page, and the newest page includes newest. Every
page is complete on its own and the engine remembers no cursor between reads, so
the page a surface gets first is the one a Session Brief's newest is on. A
before above every ordinal is the newest page too. A before past the oldest
entry is an empty page with older: false — an answer, not a refusal, and
the distinction this whole action is drawn around.
The 65,536-byte limit stays a ceiling on the line, never the page. An entry
that would push the encoded Reply past it is published as existing but omitted
— ordinal, role, omission: "oversize", and no text — and keeps its
slot, so the page always advances and one large message never blocks the ones
before it. Text is never cut (ADR 0016).
It is a read: it resolves one exact identity, asks that lane and no other,
and never starts a turn. It is not folded into the roster — inspect keeps
answering the newest tail and folding, and a page is a separate read that
observes nothing. A Session mid-turn is readable here.
It refuses rather than answering emptily, and the four refusals are four different facts:
| Code | When |
|---|---|
unknown_session |
No Session by that identity was ever registered here. |
stale_session |
The identity names a different process now, or the Session has ended. The row is not ended by this action: discovery is the whole-lane reading and is the only thing that ends one. |
refused |
The lane could not be read at all. The message is the lane's own words, and the row stands exactly as the roster last saw it: not being able to look is not a sighting. |
refused |
Nothing could read what it said — a Codex Session the shared daemon does not hold, or one whose first turn has written no record yet. |
The last one is the line this action is drawn around. A Session that was read and has nothing before the cursor answers with an empty page; a Session nobody could read is a refusal. A surface handed the second as the first would render a working Session as one that has said nothing.
A Session the Codex daemon does not hold therefore never gets an invented reading. Its rollout is on disk and reading it would be a second source answering the same question with worse evidence.
Payload: {"name": "duty", "on": true}. on must be a JSON boolean; a string is
refused, because "false" is truthy and the switch it would turn on is the
master. Data: {"name": …, "on": …, "previous": …}.
Four names are registered — duty, voice, message, auto_hangup — plus
whatever Feature Switches configuration declares. Any other name is
unknown_switch. Every position is persisted, and a surface reads them back from
status under the same keys.
auto_hangup is the Auto Hang-up Switch, and it is the odd one: it starts on,
where the other three start off, and it hangs from nothing. The Silence Ceiling
is the call's own limit rather than an act toward the user, so it ends a silent
call with Duty off and on calls the user opened; only this switch stops it. How
long that silence runs is configuration, not a switch — [policy] silence_end_seconds, and speech_settle_seconds for how long a pause stays a
pause before it counts. The Call Keeper asks SwitchAdjudicator.may_auto_hangup(),
the same way it asks may_touch_call() before it dials.
Growing the set is additive on the wire and not a protocol change: the grammar
above is unchanged, and a surface renders status's switches over its own known
order, so a key it has no row for is ignored rather than guessed at.
Turning an outlet on is read at the flip, on the roster as it stands: each live main Session still waiting on a question or permission is pushed to the Companion Channel, and the Call Keeper is woken once. It never replays a historical Stop Notice.
A wake reaches the call only through the Keeper, which paces it: after any end of
a call — hung up, dropped, or a dial that failed — [policy] cool_down_seconds
passes before it dials again, and a wake inside that window marks one dial owed
and pays it from a fresh reading when the window closes. The user's own
bridgectl live is not subject to it.
Payload: none. Data: {"state": "up" | "connecting" | "down", "call_id": "…" | null}.
One action: it ends the call the system owns, or starts one if none is up. Every surface calls this one — a surface holding its own call state is how two toggles once opened two calls. It is bound only by the one-call-at-a-time invariant, never by a switch.
Payload: {"target": {…}, "text": "carry on", "route": "deliver" | "supplement"}.
Data:
{"request_id": "…", "target": {…}, "state": "pending" | "retained" | "delivered" | "reported_failed",
"route": "deliver",
"receipt": {"outcome": "delivered" | "failed" | "held" | "unknown", "reason": "…"} | null,
"reason": "delivered" | "awaiting_reply_window" | "duplicate_risk" | "held_far_side"
| "session_ended" | "question_unanswerable"}The receipt is a grade and a reason, never a sentence. Three facts and no
prose: state is where the words are, receipt is what the last attempt proved
— the delivery seam's own value, with the adapter's evidence in reason — and
the top-level reason is one code from the closed RelayReason set
(core/relays.py), which says why the Relay stands where it does:
| code | what it says |
|---|---|
delivered |
the attempt proved the words reached the model |
awaiting_reply_window |
they wait, and may go again when the Session next takes a turn |
duplicate_risk |
an attempt proved nothing either way, so they are kept and never re-sent on this system's authority (P9) |
held_far_side |
the far side parked them in front of a person |
session_ended |
terminal: the Session ended while they waited |
question_unanswerable |
terminal, before the wire: that question is no longer answerable from here (#68) |
receipt is null when nothing was attempted — never a grade of
unknown, which is a positive observation about an attempt that was made. The
two are the difference between "it may already have arrived" and "it never left
this process", which is the whole of the duplicate-safety rule.
Surfaces print the three codes and compose no sentence: state=<state> grade=<grade|none> reason=<code> is the one format (core/relays.py,
receipt_line), and bridgectl relay and the Companion Channel's inbound relay
path both answer with exactly it. The words the user hears are the Voice's,
re-rendered from these facts.
state is Lifecycle (core/lifecycle.py), which is where the words stand
across every attempt they will get — deliberately not the per-attempt Delivery
grade, because reading one attempt's grade as the item's fate is the reference
implementation's worst delivery bug. Its literals are the four states this verb
can answer with: queued is retained, delivered is delivered, and
terminal is reported_failed. There is no held state: held is a grade
the far side returned, and it reaches a surface as receipt.outcome = "held"
with reason = "held_far_side", still retained because the words are still
waiting.
Queued is not delivered, and state says which it was.
Route follows the user's explicit intent and is never inferred from how busy a Session is — the same "busy" carries both "add this now" and "this can wait".
There is no action for system-authored words: a surface asking for one would be a surface claiming to be the system.
Payload: {"approval_id": "a1", "verdict": "allow" | "deny" | "ask"}. Data: the
verdict that was carried, and relay's receipt for carrying it — an Approval
Relay is a Relay, so its receipt is the same state, grade and reason.
{"approval_id": "a1", "verdict": "allow", "request_id": "…", "target": {…},
"state": "delivered", "route": "deliver", "reason": "delivered",
"receipt": {"outcome": "delivered", "reason": "…"}}The handle is the approval_id on a live roster row that is waiting on a
permission. Two refusals, and they mean different things to the user:
unknown_pending— no live row carries that handle. The dialog was answered, or its hook ended and it went back to the screen; that is where it is answered now. The engine keeps no clock over it and no ledger of it, so a handle that is not on the roster is not waiting anywhere.refused— the row is a Child Process. It is seen and never spoken to, and that includes never answered.
Payload: none. Data:
{"seams": [{"seam": "companion_channel", "outcome": "pass" | "fail" | "manual",
"configured": "…", "loaded": "…", "detail": ""}]}Seam names: call, companion_channel, and agent.<kind> per configured agent.
The engine reports what it actually loaded, never what a configuration file
says it should have loaded. configured is what the file named; loaded is what
the adapter says about itself when asked — every seam here is pluggable and
every one of them has a verify verb, so all of them are asked, and a Call adapter
whose far side is down reports that rather than the engine reciting the
configuration back and calling it an observation.
Three outcomes: pass, fail, and manual — nothing configured anywhere, which
is handed to the operator rather than passed or failed. What is compared is
presence, not spelling: configuration names a factory and an adapter names its
implementation, and demanding those match character for character would fail on
every correctly wired machine. So fail means one of three things — the adapter
itself reported a failure, configuration names an adapter and the engine loaded
nothing (or the null one), or something is loaded that nothing configured.
Payload: none. Data: {"text": "…", "options": ["…", …]}.
A screen (ADR 0021 §6): text is the screen for a surface that draws
nothing — bridgectl prints it, and a Companion Channel with no buttons sends
it — and options are the labels, in order, for a surface that draws choices.
A label is picked by position: a surface that hands one back sends the
numeral, and the label's words are never read. On the Companion Channel every
screen is an Anchor, so a numeral replying to it picks, and a button is that
numeral (core/anchors.py, core/menu.py).
sessions is the roster: text is exactly what brief with no target
renders, and options carry one label per live Session in the roster's own
order — the Session Name, with the address after it where two rows share a
name. With no live Session the text ends with the nothing-running hint and
options is empty: a screen with nothing to pick is not an Anchor. On the
channel, picking a Session opens its greeting (brief / history / send message); send message opens the Say to <name>: prompt, an Anchor whose
replies are Answer Relays to that Session.
config offers switch, verify and live. On the channel, switch opens
the switch screen — one label per switch carrying its state, a press meaning
flip, read against the board as it stands when the press arrives — and
verify and live run at once and answer in text. status stays plain text
and is not a screen.
Payload: none. Data is a screen, the same shape sessions and config answer
with: {"text": "...", "options": []}.
Opens an Assistant Conversation (ADR 0021 §7): the engine starts a fresh coding
thread with the delegated instructions and [delegate] model, and answers with
the fixed opening line. No turn is run — the line is the engine's own words,
so a conversation costs nothing until it is replied to. There are never options:
a conversation is answered in words.
The conversation is continued only by replying to one of its messages on the
Companion Channel, so a caller reached through this socket opens a thread it has
no way to continue: nothing sent here is an Anchor. bridgectl assistant is a
way to prove the seam answers, not a way to hold a conversation.
The engine refuses, refused, in its own words when it has no assistant behind
it or the coding thread could not be started. How many conversations stay
answerable at once is [policy] assistant_conversations, three by default; the
oldest is dropped whole when one past that is opened. A reply to a dropped one
is a reply to an Anchor the engine no longer holds, so it is words that replied
to nothing and goes to the newest Anchor, whichever that now is. The
start-again hint is for the other case: a thread the coding model itself no
longer has.
bridgectl and the Companion Channel's / grammar are one command set, parsed
by one parser, so neither can grow a command the other lacks. On the Companion
Channel relay and approve are folded away — a reply is the relay and a
numeral is the approval (ADR 0021 §6) — but they stay typeable, as does every
verb below.
status
switch <name> on|off
brief [<agent>:<session id>[:<pid>]]
history <agent>:<session id>[:<pid>] [--before <ordinal>]
live
relay <agent>:<session id>[:<pid>] [--supplement] <words>
approve <approval id> allow|deny|ask
verify
sessions
config
assistant
<agent>:<session id>[:<pid>] is how a SessionTarget is written on one line. A
Claude target without a pid is refused: --resume forks a second process under
the same session id. The pid is read by converting it, not by spelling it, so
anything that is not a whole number above zero — a superscript digit, a run of
digits past CPython's int-conversion limit — comes back as the refusal not a process id rather than a traceback.
bridgectl exits 0 when the engine answered, 1 when it refused, and 2
when there was no engine to ask. Collapsing the last two would tell a user their
switch does not exist when nothing is running.
--task consumes every remaining word. Project names containing spaces are one
quoted argument; callers never quote an absolute workspace or compose a Session
Label. Omitting --agent selects the configured global default. The old
positional agent/workspace/label form is not accepted beside this one.
One TOML file, read once, by the composition root and nothing else.
[adapters.settings.session_launcher] and [launch] are no longer read. A
configuration carrying [adapters.settings.session_launcher] is refused rather
than silently ignored.
[engine]
socket_path = "/tmp/gpt-voicecoding-501/control.sock" # optional
state_path = "~/Library/Application Support/GPT-VoiceCoding/engine/state.json" # optional
[adapters]
call = "gpt_voicecoding.adapters.call.realtime:realtime_call"
companion_channel = "gpt_voicecoding.adapters.companion_channel.telegram:telegram_channel"
[adapters.agents]
claude = "gpt_voicecoding.adapters.agent.claude:build"
codex = "gpt_voicecoding.adapters.agent.codex:codex_agent"
[adapters.settings.call] # optional; every key belongs to that adapter
workspace = "~/code" # where the bridge's own threads run; default is ~
voice = "cove" # optional; the default Voice
realtime_model = "gpt-live-1-codex" # optional; realtime model, not the Call Agent model
[policy] # optional; these are the locked defaults
silence_end_seconds = 60
cool_down_seconds = 30
speech_settle_seconds = 5
anchor_rows_per_session = 100 # Anchors the Companion Channel keeps per Session (ADR 0021 §2)
assistant_conversations = 3 # Assistant Conversations it keeps at once (ADR 0021 §7)
[log] # required: three numbers with no default
max_bytes = 8388608
retained_files = 3
stripped_environment_prefixes = ["Malloc"]
[delegate]
model = "the-model-you-chose" # required: the cost lever has no default
effort = "high" # optional; shared by Call Agent and Delegated Turn
cli = "/Applications/GPT-VoiceCoding.app/Contents/Resources/engine/bin/bridgectl"Each adapter reference is module:attribute, resolved by the composition root —
the only thing in the system that imports an adapter. A factory is called as
factory(sink=<event sink>) and returns the adapter.
[adapters.settings.<seam>] is that seam's own table, and the composition root
forwards it without reading a key: only the adapter knows what its own keys
mean, and a root that parsed them would be the hub growing adapter-shaped
knowledge (ADR 0001). A seam given no table is called with the sink alone. Every
adapter that takes one refuses to start on a key it does not recognise, because a
misspelled setting that silently falls back to a default is the
configuration-shaped version of the silent fallback this project bans.
The shipped Call and Codex Agent adapters share one codex app-server. The
Codex Agent adapter spawns, owns and reaps it; the Call adapter's realtime route
rides it and starts none of its own. The composition root introduces them, which
is why naming the shipped Call adapter without also naming a Codex Agent adapter
in [adapters.agents] refuses to assemble rather than starting an engine whose
voice surface could never come up.
The shipped Call adapter also needs the voice extra —
pip install 'gpt-voicecoding[voice]' — and its factory says so at assembly
time rather than at the moment somebody tries to speak.
An adapter with a connection, a reader task or a child of its own may implement
the optional Connectable shape (seams/connection.py): async connect() and
async aclose(), both idempotent. The composition root opens every one of them
before it serves the socket — a surface that reaches a serving engine reaches
one whose seams are actually filled, and an adapter that cannot open stops the
start rather than being answered for. On shutdown they are closed in reverse
order, and every one gets its turn even if an earlier one raises. An adapter with
nothing to open implements neither verb; the contract is optional because not
every seam has a connection to hold.
This file is executed with the privileges of the user who wrote it, and is exactly as trusted as the engine itself. That is the deliberate cost of naming adapters by reference: a compiled-in table of allowed names could not name a deployment's own private wiring, and keeping that wiring private is a charter decision. The file lives in the user's own application-support directory for the same reason.
[delegate] cli is where the control-plane CLI really is. Bridge Core names it
in the instructions it generates for the voice thread and for a Delegated Turn,
so it has to be true: an instruction naming a binary that is not there is exactly
the invented detail those instructions forbid. Left out — the ordinary case — the
engine uses the console script installed beside its own interpreter, and uses it
only after finding that it exists and can be run. A bundle moves that binary, so
a bundle states this key. If neither is really there, the engine refuses to
start rather than describing a CLI nobody can run.
In the bundle that key is not optional, and it points into Contents/Resources
rather than Contents/MacOS: pip installs a console script beside the
interpreter that installed it, and the bundle's interpreter is under
Contents/Resources/engine/. There is no second copy in Contents/MacOS/ — a
duplicate binary that shadowed the real one would be two things to sign and two
things to be wrong. The bundled bridgectl keeps pip's console-script body,
but the pipeline replaces its absolute shebang with a shell/Python preamble
that resolves the script's real path and execs the interpreter sitting beside
it. The same rewrite covers every Python console script in engine/bin/; see
docs/app-bundle.md.
An unconfigured Call or Companion Channel seam refuses to start, with a named error. An engine that silently loaded nothing behind a seam looks exactly like a healthy one until it is needed — the outage ADR 0003 exists to prevent. Running without a Companion Channel is legitimate, but the null implementation ships with that adapter (#10) and is not built yet, so today it is also a refusal that says so.
[log]'s three numbers are required, with no default in code: they are what
ADR 0004's outage measured, and a compiled-in fallback would quietly reinstate a
value the measurement proved matters. The log's path is a location rather than
a decision, so it defaults beside the state file. max_bytes binds every
generation, so the disk one log can occupy is max_bytes × (retained_files + 1).
python -m gpt_voicecoding.engine --config ~/Library/Application\ Support/GPT-VoiceCoding/engine/config.tomlThe engine stays in the foreground and never daemonises: the menu-bar shell
spawns it as a direct child and expects it to remain one (ADR 0005). SIGINT and
SIGTERM both stop it in order — loops cancelled, socket removed, so the next
start is not left claiming its own debris. It exits 2 when it could not start,
naming what was missing on stderr. A configuration refusal before log takeover
goes there in full. After takeover, only the final
the engine cannot start: … sentence is mirrored to the inherited stderr; the
full diagnostic stays in engine.log, which the engine owns (ADR 0004).