Remote control plane (/remote)
Spettro can expose a small local HTTP/SSE control plane so that an external process — a script, an editor extension, another TUI — can drive a running session: submit prompts, stream live progress (tool calls, comments, agent output, banners, approval/ask-user prompts) and request an interrupt.
The control plane is purely opt-in. It only starts when you run /remote
inside the TUI. By default it binds to 127.0.0.1; use /remote local to
bind to 0.0.0.0 so other devices on your LAN can reach it. It is gated by a
per-session bearer token printed to the chat when it starts.
Starting and stopping
| Command | Behavior |
|---|---|
/remote |
Bind on 127.0.0.1:7878. If that port is busy, scan upward (7879, 7880, …) for ten attempts before letting the OS pick a free port. |
/remote :PORT |
Try the requested port first. If it is busy, fall back to an OS-assigned free port and warn you in the banner. |
/remote local |
Bind on 0.0.0.0:7878 (LAN-accessible). If that port is busy, scan upward (7879, 7880, …) for ten attempts before letting the OS pick a free port. |
/remote local :PORT |
Try the requested port first on 0.0.0.0. If it is busy, fall back to an OS-assigned free port and warn you in the banner. |
/remote stop (or off, shutdown) |
Stop the server, close all live SSE connections, and free the port. |
/remote status |
Print the current URL and bearer token without restarting anything. |
When the server is bound, the TUI prints a system message that contains:
- the URL (
http://127.0.0.1:<port>or the LAN IP printed for/remote local) - the bearer token (a fresh random hex string per
/remoteinvocation) - a quick reference for every endpoint
The token is shown only once in the chat. Copy it before starting your
client. If you lose it, run /remote status to print it again, or
/remote stop followed by /remote to mint a new one.
Authentication
Every endpoint requires the bearer token. Either:
- send it as
Authorization: Bearer <token> - or pass it as a
?token=<token>query string (handy for curl experiments)
Anything else returns 401 unauthorized.
By default the server only listens on the loopback interface, so it cannot be
reached from another machine. When you opt into /remote local, it binds to
0.0.0.0 and becomes reachable from other devices on the same network, so
ensure your LAN is trusted and keep the bearer token private. The token
still prevents same-machine cross-origin attacks (browsers cannot set the
Authorization header without an explicit CORS preflight, and the server
never enables CORS).
Endpoints
POST /messages
Submit a prompt or slash command exactly as if the user typed it. If you used
/remote local, replace 127.0.0.1 in the examples below with the LAN IP
shown in the banner.
POST /messages
Authorization: Bearer <token>
Content-Type: application/json
{
"message": "explain the LLM runtime architecture"
}
Response:
{
"accepted": true,
"queued": false,
"note": "running"
}
Behavior:
- Plain text → routed through
handlePromptand starts an agent run with the current mode/model. - Text starting with
/→ executed as a slash command viahandleCommand. - If an agent is already running, the message is queued and the response
carries
"queued": true. Slash commands are never queued — the endpoint returns409 conflictwith anerrorfield instead. - Empty messages return
400 bad request.
GET /events
Subscribe to a live event stream over Server-Sent Events
(text/event-stream). Newly connected clients receive a short replay
buffer (last ~64 events) before live events resume.
GET /events
Authorization: Bearer <token>
Accept: text/event-stream
A client that only follows the stream and never answers questions (a log
tailer, a CI job) should connect with GET /events?observe=1, so a headless
run does not wait on it for an ask_user answer (see
Ask-user forms).
Each event is delivered with the SSE structure:
event: <kind>
id: <monotonic-seq>
data: {"seq":42,"kind":"...","at":"2024-04-27T10:34:12.123Z","data":{...}}
Heartbeats (: ping) are emitted every 15 seconds so intermediaries do not
close idle connections.
GET /status
Return a JSON snapshot of the runtime:
{
"thinking": true,
"mode": "coding",
"active_agent": "coding",
"session_id": "session-abc",
"messages_count": 12,
"tokens_used": 8421,
"started_at": "2024-04-27T10:30:00Z"
}
The snapshot is updated whenever the TUI publishes a state event (start of
a run, end of a run, mode change, etc.).
POST /interrupt
Cancel the current agent run and unblock anything queued behind it. No body is required.
{ "ok": true }
This is equivalent to pressing Esc while a run is in flight inside the
TUI; pending shell-approval and ask-user prompts are dismissed.
GET /
Returns a static index of the available endpoints. Useful for debugging.
Event reference
All events share the envelope:
{
"seq": 17,
"kind": "...",
"at": "RFC3339 timestamp",
"data": { ... }
}
| Kind | Emitted when | Payload |
|---|---|---|
remote_started |
/remote invoked |
port, requested, fell_back, default_port, started_at |
remote_stopped |
/remote stop invoked |
address |
state |
Mode change, agent start/stop, etc. | thinking, mode, active_agent, session_id, messages_count, tokens_used, reason |
user_message |
Local or remote user prompt accepted | content, mentioned_files |
system_message |
Internal info/error message added to the chat | content |
assistant_message |
Agent run completed successfully | content, thinking, meta, tools_count, tokens_used |
assistant_error |
Agent run failed | error |
plan |
Plan agent produced a draft | plan, tools_count, tokens_used |
plan_error |
Plan agent failed | error |
comment |
Agent published a progress comment via the comment tool |
message |
tool |
Any tool started/finished | name, status (running/success/error), agent, args/args_raw, output |
banner |
UI banner shown (info/warn/error/success) | text, level |
approval_request |
A command, file change or network access needs approval | command, tool_id, segments, reason, diff (file changes only), command_bytes, command_truncated, command_hidden_chars, command_visible (only when hidden characters are present), the same four diff_* fields, and approval_id in headless runs — see Approval requests |
ask_user |
The agent invoked ask-user |
version, count, active, questions[], plus the v1 fields question, options, context, default, allow_free_response describing the question numbered active — see Ask-user forms |
commit / commit_error |
Auto-commit agent finished | message / error |
search / search_error |
Repo searcher finished | result / error |
remote_command |
Remote client sent a slash command | command |
remote_prompt |
Remote client sent a plain prompt | prompt |
remote_interrupt |
/interrupt was received |
thinking (was a run active?) |
The kind field is also reflected as the event: SSE name for clients
that filter by event name.
Approval requests
An approval_request carries what is being approved in full: the whole
command (a heredoc of any length, not its first line) and, for a
file-write/file-edit, the whole unified diff. A client that lets the
user decide must show all of it, or say plainly that it does not; a
command cut short can look harmless when its end is not.
Each of the two text fields is bounded at 4 MiB, far above anything a
person reads (the diff of the largest file Spettro diffs at all fits).
Past that the text ends with a line
[truncated: N of M bytes not shown; this is not the whole text], the
matching command_truncated/diff_truncated is true, and
command_bytes/diff_bytes give the full size. A client should check the
flag rather than guess from the text.
command and diff are the exact bytes. They can hold characters that do
not show when drawn: a carriage return that sends the cursor back over the
start of the line, an escape sequence, a bidi override that reorders it, a
no-break space that looks like the space between two shell words, or
zero-width characters and variation selectors that can carry a whole
payload inside what reads as "". When one is present,
command_hidden_chars/diff_hidden_chars is true and
command_visible/diff_visible holds the same text with each such
character written out the way the TUI shows it (^M, ^[, \u202e,
\U000e0100, a tab as ⇥, a look-alike letter posing as Latin as
\u0456). A client that shows the text to a person should show the
_visible field whenever there is one. segments (what allow-always
remembers) comes with segments_visible, the same list written out, when
one of them holds such a character.
In a headless run (spettro --headless), where a client answers approvals, each
event also carries an approval_id ("a-7"), and the answer names it:
POST /approval
{"approval_id": "a-7", "decision": "allow-once"}
decision is allow-once, allow-always or deny; with deny, an
optional instead tells the agent what to do instead. Sub-agents run in
parallel, so two approvals can be pending at once, often of the same tool
(two bash calls), and every network approval has an empty tool_id. An
allow must carry the approval_id of the request it approves; without one
it is refused with 409. A tool_id alone cannot say which request the
client showed: the one on its screen may have been withdrawn (its time ran
out, the run was cancelled) and another call of the same tool asked since,
so "allow" on a stale card would approve a command nobody saw. An answer
carrying only tool_id (the form older clients send) is still accepted for
deny, while exactly one approval of that tool is pending, and refused with
409 when there are several; an unknown approval_id gets 404, and a
second answer to the same approval 409.
Ask-user forms
The model asks up to four related questions as one form. The ask_user event
carries the whole form and is versioned so a client can tell the two shapes
apart:
{
"version": 2,
"question_id": "q-3",
"count": 2,
"active": 0,
"questions": [
{
"header": "Database",
"question": "Which database?",
"options": [
{ "label": "Postgres", "description": "already provisioned" },
{ "label": "SQLite", "is_recommended": true }
],
"multi_select": false,
"allow_free_response": true
},
{
"header": "Checks",
"question": "Which checks run before commits?",
"options": [{ "label": "go vet" }, { "label": "gofmt" }],
"multi_select": true,
"allow_free_response": false
}
],
"question": "Which database?",
"options": ["Postgres — already provisioned", "SQLite"],
"context": "both are already provisioned",
"default": "SQLite",
"allow_free_response": true
}
The five fields below the blank line are version 1's, kept so a client written
before forms existed keeps working. They describe the question at index
active — in the TUI that is the question the user is on, republished as they
move through the form; in headless mode it is always the first one.
Who can answer. POST /ask-user resolves a question only when the run is
driven headlessly (spettro --headless), where the remote client is the user. In
TUI mode the question belongs to the person at the terminal: the event is
published so a remote client can follow along, and POST /ask-user answers
nothing (404) — a Telegram reply is the way in from outside. Everything below
describes the headless case.
A form-aware client sends every answer at once, keyed by header:
{ "question_id": "q-3", "answers": { "Database": "SQLite", "Checks": "go vet, gofmt" } }
A client that only understands the flat shape sends what it always sent:
{ "question_id": "q-3", "answer": "SQLite" }
That is read as the answer to the first question; the rest are reported to
the model as unanswered rather than defaulted. An answer naming an option (by
its label, or by the label — description string the flat options array
shows) selects it; anything else is delivered as the user's own words, verbatim.
A multi-select question takes several option names separated by commas.
Answering is one-shot per question_id: a second POST gets 409, and a
question whose run was cancelled or interrupted gets 404 because the pending
answer was already resolved.
When nobody answers. A headless run never waits on a question nobody can
answer: with no client connected to /events — observers connected with
?observe=1 do not count — the tool returns at once, telling the agent that
no user is available and to proceed on its best judgment. The exception is a
client that dropped off within the last 30 seconds: the question waits for it
to reconnect (a phone backgrounding the app, a proxy recycling the stream)
before giving up. With a client connected it waits up to 5 minutes (override
with SPETTRO_ASK_USER_TIMEOUT_SEC, in seconds; 0 waits indefinitely), then
the agent is told the same thing and the question expires: every question gets
a fresh question_id, so a late answer gets 404 rather than answering a
later question. Sub-agents and goal-mode runs never ask at all.
Quick examples
curl — submit a prompt
TOKEN=... # paste from /remote output
PORT=7878
curl -sS -X POST "http://127.0.0.1:$PORT/messages" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"message":"summarize the open PRs"}'
curl — follow the event stream
curl -N "http://127.0.0.1:$PORT/events" \
-H "Authorization: Bearer $TOKEN"
Python — print every event as it arrives
import json, requests
TOKEN = "..."
PORT = 7878
with requests.get(
f"http://127.0.0.1:{PORT}/events",
headers={"Authorization": f"Bearer {TOKEN}"},
stream=True,
) as r:
for raw in r.iter_lines(decode_unicode=True):
if raw and raw.startswith("data: "):
ev = json.loads(raw[6:])
print(ev["kind"], ev.get("data"))
Node — interrupt the current run
await fetch(`http://127.0.0.1:${PORT}/interrupt`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}` },
});
Failure modes
| Symptom | Likely cause |
|---|---|
401 unauthorized |
Missing or wrong bearer token. |
400 bad request |
Empty message field or non-JSON body. |
405 method not allowed |
Wrong HTTP verb for that endpoint (every endpoint advertises Allow:). |
409 conflict from POST /messages |
Slash command sent while an agent is running. |
503 service unavailable from POST /messages or POST /interrupt |
Server has been stopped (/remote stop). |
| Event stream just hangs | Check that your client disables proxy buffering and follows SSE keep-alives. |
Lifecycle and security notes
- The remote server is owned by the running TUI process and dies with it. There is no daemon mode and no persistent socket file.
- Tokens are 16 random bytes (32 hex chars), regenerated on every
/remoteinvocation. They are never written to disk. - Submissions go through the same routing as keyboard input — the active
permission policy, hooks, approval prompts, and budgets all apply.
restrictedandask-firstpolicies still pop their dialogs locally inside the TUI; the remote client observes the request via theapproval_request/ask_userevents but cannot answer them. Headless runs have no TUI to answer in, so therePOST /ask-useris the answer path — see Ask-user forms. - Interrupts coalesce: rapid bursts deliver at most one
remoteInterruptMsgto the program loop until it is consumed. - Slow SSE subscribers are skipped rather than backpressuring the TUI.
