The process control port
Every running radian process that hosts a session — the full-screen interface (radian) and
radian exec — opens one Unix socket and serves a JSON-lines protocol on it. A local script,
radian attach, or a hub uses it to send input, cancel a turn, read the session, follow its
committed events, and (with the owner token) answer the pending approval or question, change
the model route and close the process. It is a local IPC surface, never a remote protocol.
Where the socket is#
$RADIAN_HOME/run/<pid>.sock, where$RADIAN_HOMEis$RADIAN_HOMEwhen set, else$HOME/.radian.- The
run/directory is0700and the socket file is0600. - The file is removed when the process exits, including a panic unwind. At startup a
*.sockwhose pid is no longer alive is removed, so akill -9does not leave one behind. - The port is on by default.
[control] enabled = falsein the config file turns it off for that process (seeconfiguration); the startup line then prints no[control]path. When it is on, startup prints[control] /home/me/.radian/run/12345.sock.
The protocol#
One JSON object per line, in both directions. A request:
{"id": 1, "verb": "followup", "text": "hello"}
A response:
{"id": 1, "ok": true}
or
{"id": 1, "ok": false, "error": "…"}
id is echoed back and is whatever JSON value the client sent; a request without a parseable
id gets "id": null. The verb's own result fields sit at the top level of a successful
response. A successful response to a verb with a result is the id, ok, and the result's
fields.
One request is one JSON line of at most 16 MiB (16 777 216 bytes), excluding the newline. A
longer line is discarded through its newline and answered with
the request line is over the 16 MiB limit; the connection stays usable.
Sources#
followup, steer and inject carry an input whose source the port decides, never the
client's word alone:
source |
result |
|---|---|
absent, "scheduler" |
Scheduler (the default) |
"subagent" |
Subagent |
"mcp" |
Mcp |
"user" |
refused — the port never accepts a self-declared User |
"permission_decision" |
refused |
| anything else | refused |
A turn that is already running keeps its own effective source, which only ever lowers: a
steer or inject that joins it can lower it, never raise it. A followup that arrives
mid-turn belongs to a later turn and leaves the running turn alone.
The same rule applies at a turn's start: its source is the least-privileged of the input
that opens it and every steer/inject that turn admits. A queued scheduled input can
lower the turn an owner's followup starts, and it does so before the first model call.
The owner token#
A request may carry an optional string field token. When it equals the trimmed content of
$RADIAN_HOME/hub/token (read on every request, so a rotation takes effect at the next
request), the request is the owner's:
followup,steerandinjectenqueue with sourceUser; thesourcefield is ignored.- The message records
{"via": "hub"}in itsoriginmetadata, so the transcript can tell a phone-sent message from a typed one: the TUI appends a weak· hubto that message's last row (a keyboard message has none). - The five owner verbs below are available.
A missing, unreadable or empty token file means no token matches. A request without token,
or with a wrong one, behaves exactly as without this section: source Scheduler by default, no
via metadata, the source rules above, and the five owner verbs return an error line and
change nothing. The comparison is constant-time.
The verbs#
| verb | fields | result |
|---|---|---|
followup |
text, source?, token?, images?, files? |
enqueues a new turn; message_id |
steer |
text, source?, token?, images?, files? |
redirects the running turn |
inject |
text, source?, token?, images?, files? |
adds context without waking an idle session |
cancel |
— | cancels the running turn (idempotent) |
list_sessions |
— | sessions: one main row (below) |
subscribe |
token? |
{"ok": true}, then event lines (below) |
pending |
token |
head (or null) and length |
answer |
request_id, response, token |
completes one pending entry |
config |
config, token |
queues one turn-boundary change |
models |
token |
the /model list, grouped by provider |
close |
token |
{"ok": true}, then the process ends; the journal stays |
The task verbs (spawn_task, wait_task, task_status) do not exist. An unknown verb
(including "approve") returns an error and changes nothing.
pending#
{"id": 7, "verb": "pending", "token": "…"}
{"id": 7, "ok": true, "head": {"request_id": 3, "kind": "approval", …}, "length": 1}
head is null and length is 0 on an empty queue. length is the queue's valid pending
count. request_id is the integer queue id that answer takes; it grows with every push.
An approval head:
{"request_id": 3,
"kind": "approval",
"call_id": "call_abc",
"tool": "write",
"resource": "out.txt",
"resolved_resource": "/home/me/project/out.txt",
"arguments": {"path": "out.txt", "content": "hi"},
"reason": null,
"approval_id": "p-17"}
resource is the resource as the call wrote it; resolved_resource is the path or command the
permission rules actually matched (a prompt shows this one); arguments is the call's raw JSON;
reason is the rule's reason, or null; approval_id is the permission layer's own request
id.
A question head carries the full structured request:
{"request_id": 4, "kind": "question",
"question": {"questions": [
{"id": "loader", "prompt": "How should it load?", "kind": "single",
"options": [{"id": "builtin", "label": "Built-in", "recommended": true}],
"allow_other": true, "required": true}]}}
answer#
{"id": 8, "verb": "answer", "request_id": 3, "response": "allow_once", "token": "…"}
An approval response is one of:
response |
effect |
|---|---|
"allow_once" |
allow this call once |
"allow_always" |
AllowForSession: a process-local grant for this call's tool and resource, the same as the ACP surface's allow_always |
"reject_once" |
deny; the tool result says “denied by the user” |
{"kind": "reject_once", "reason": "…"} |
deny with that reason; it lands verbatim in the tool result |
The same three may be written as objects: {"kind": "allow_once"} or
{"kind": "allow_always"} (no reason), and the reject_once object above.
A question response carries one answer per question:
{"response": {"answers": [
{"id": "loader", "selected": ["builtin"]},
{"id": "name", "text": "radian"},
{"id": "tiers", "selected": ["safe", "workspace"], "other": "a custom tier",
"note": "…"}]}}
Each answer accepts id, selected (option ids), text (free text), other (the Other
text) and note (at most 500 characters). The answer's request_id is optional; when absent
the port uses the TUI's q-<queue id>, so a port answer and a keyboard answer land the same
tool result. Free text, multi-select and Other are all answerable.
The pending queue is the arbiter: the first answer wins. A successful answer completed that
entry. An error line changes nothing when:
- the id is unknown:
no pending request has that id; - the entry was already answered (by the TUI, ACP, or an earlier port answer) or abandoned:
the request is no longer pending: a late answer changes nothing; - the response is for the other kind (an approval response for a question, or the reverse);
- the response does not parse: an unknown approval word, a non-string
reason, or ananswerslist that violates the answer shape.
A TUI that was showing the completed popup closes it when the queue's head moves, draws one weak
answered from the hub line in the transcript, and applies no second answer. The tool result (the
tool card, or the ask_user record) is the same one a keyboard answer produces.
config#
{"id": 9, "verb": "config", "token": "…",
"config": {"model": {"provider": "anthropic", "model": "claude-sonnet-4"}}}
{"id": 10, "verb": "config", "token": "…", "config": {"thinking": "high"}}
The change goes to the turn-boundary configuration queue and is applied before the next turn,
recording one ConfigChanged in the journal — the same path as /model and /thinking. An
in-flight model request keeps the old route; the next turn uses the new one.
| key | type | accepted values |
|---|---|---|
model |
object | exactly {"provider": string, "model": string}. The route must be the current route or one entry of models; any other provider/model is refused. |
thinking |
string | off, minimal, low, medium, high, xhigh or max. Any other value is refused. |
Both keys may appear in one object; they are applied as one change. An empty object, an unknown
key, a non-object model, a missing or empty provider or model, or an unknown route
returns an error line and queues nothing. The key and header values are never part of the
change.
models#
{"id": 11, "verb": "models", "token": "…"}
{"id": 11, "ok": true, "models": [
{"provider": "anthropic", "models": [
{"id": "claude-sonnet-4", "current": true},
{"id": "claude-opus-4", "current": false}]},
{"provider": "opencode-go", "models": [
{"id": "deepseek-v4.1-flash", "current": false}]}]}
The same list /model shows for this session, grouped by provider and with the current route's
entry marked current: true. Groups follow /model's order: the current provider first, then
providers and models by name. Only logged-in providers (a usable auth.json entry) and the
current route's provider are considered; a provider with no known model contributes no group.
A config model must name one of these entries or the current route.
close#
{"id": 12, "verb": "close", "token": "…"}
{"id": 12, "ok": true}
The answer is written first; the port then stops accepting new connections, lets every open
connection finish the answers it owes (a connection with no complete request yet waits a brief
moment for one the client is still writing), and only then ends the process. A connection whose
close crossed another one therefore still receives its {"ok": true}, and each connection
ends after its answer. The process's end cancels a running turn through the same path as
cancel — an interrupted assistant prefix and an open tool call are recorded the
way the journal's recovery rules expect — and abandons pending approvals and questions the way a
process exit does. The journal stays, so the next prompt through a hub resumes the session. A
subscriber's stream ends as it does on any process exit.
The full-screen interface takes the /quit path: it commits one session closed remotely
notice to the transcript, restores the terminal, and leaves. The notice is journaled, so a
resume of the session shows it, in its journal position. A draft typed when the close arrives
is dropped the way a ctrl-c quit drops it. Like /quit, the close stops the queue first: a
followup still waiting opens no turn; its journal entry stays, so the next --resume claims it
and sends it as the resumed session's first turn.
A host that installs no close hook answers
{"ok": false, "error": "this process cannot be closed remotely"} and changes nothing. This
build installs the hook in the full-screen interface (radian) and in the hub-held
radian exec process a hub starts; every other hosting process refuses the verb. Two close
requests crossing at once both answer ok, and the host
is asked once, so the process exits once. A close that meets the hub-held idle window also
produces one clean exit. A client that stops reading cannot hold the process open: the wait for
the open connections is bounded.
list_sessions#
{"id": 1, "verb": "list_sessions"}
{"id": 1, "ok": true, "sessions": [{
"id": "...", "kind": "main", "holder": "tui",
"workspace": "/home/me/project",
"journal": "/home/me/.radian/sessions/....jsonl",
"model": {"provider": "anthropic", "model": "claude-sonnet-4"},
"state": "blocked",
"pending": 1}]}
One process serves one session, so the list has one main row:
| field | meaning |
|---|---|
id |
the session id |
kind |
always main |
holder |
hub when the process runs in the hub-held mode a hub starts, tui for every other hosting process (a radian TUI, an interactive radian exec, a -p run). The hub reports a session with holder hub as live: hub and every other live one as live: tui. |
workspace |
the canonical workspace the session was opened in |
journal |
the journal file this process has open; two sessions with the same id under different session directories are told apart by it. A process that does not report the field (an older build) is matched by id alone, so a lookup never reads "no holder" from a missing field. |
model |
the model route in force |
state |
idle, working or blocked; a non-empty pending queue is blocked, a running turn (or background jobs) is working, an idle block (a used-up wake budget, a /goal budget stop, a lost job notice, the intent-stop cap or the idle hook-continuation cap) is blocked while no turn runs, and everything else is idle |
pending |
the pending approval/question count |
None of these fields needs the owner token.
subscribe and events#
After the subscribe response, the server writes committed events as lines:
{"event": {"source": "agent", "name": "tool_started", "data": {"index": 0, "call_id": "…", "name": "read"}}}
{"event": {"source": "runtime", "name": "TurnFinished", "data": {"turn_id": "…", "outcome": "completed", …}}}
The agent events are the committed AgentEvents the interface receives, minus the live-only
streaming deltas (model_text_delta, model_reasoning_delta). The runtime events are the
driver's (TurnFailed, TurnFinished, ConfigChanged, …). A queue change adds one pending
event:
{"event": {"source": "control", "name": "pending",
"data": {"head": {"request_id": 3, "kind": "approval", "tool": "write", …},
"length": 1}}}
It is written whenever the pending queue's version changes (a push, an answer, an abort, or an
abandoned wait disappearing). head is null and length is 0 on an empty queue. A
subscriber that sent token with subscribe gets the same head pending returns. A subscriber
without the token gets a redacted head with only request_id, kind ("approval" or
"question") and tool (the tool name for an approval, null for a question): no arguments,
no resource, no question text. No token deltas are pushed; the event stream stays committed
facts only.
One client may issue requests and subscribe on the same connection. The per-client buffer is bounded: a subscriber that falls behind is disconnected, and the session never waits on a client.
Attachments#
followup, steer and inject also accept optional attachments, with or without a token:
| field | type | what it is |
|---|---|---|
images |
array of {"mime": string, "data": string} |
data is standard base64. mime must be image/png, image/jpeg, image/gif or image/webp; the bytes' magic number decides the kind. The limits are the interface composer's: at most 5 MiB and 8000 px per side. |
files |
array of {"name": string, "text": string} |
The text becomes the attached-file part named name, without reading anything from disk. At most 100 KiB per file and 400 KiB in total. |
They become the message's content parts through the same composer the interface uses for a
dropped image and an @ file: the text part, then one image part per image, then one
attached-file part per file. An image's display name is image <n> (1-based). text is
required and may be empty when the request carries only attachments. A bad attachment — an
unknown MIME, invalid base64, an image or file over a limit — rejects the whole request with an
error naming the item, and nothing is enqueued.
Example#
# One socket per hosting process: $RADIAN_HOME/run/<pid>.sock. Pick the hosting process
# you want; `$$` is this shell's pid, not the hosting process's.
pid="$(ls "$RADIAN_HOME/run" | sed 's/\.sock$//' | head -n 1)"
sock="$RADIAN_HOME/run/$pid.sock"
printf '%s\n' '{"id":1,"verb":"subscribe"}' | nc -U "$sock"
printf '%s\n' '{"id":2,"verb":"followup","text":"run the tests"}' | nc -U "$sock"
printf '%s\n' '{"id":3,"verb":"pending","token":"…"}' | nc -U "$sock"