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
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 |
Shell approval is needed | command, tool_id, segments, reason |
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.
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.
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.
