titah serve exposes a server that holds the entire core. The TUI is just
one of its clients — and curl is another, which is what makes the core testable
without a TUI at all.
titah serve --port 4096
Routes
| Route | Purpose |
|---|---|
GET / | The web client (HTML) for browsers, the /health payload (JSON) for everything else |
GET /health | Status, version, pid — always JSON |
GET /event?session=<id> | SSE stream of all events (optional per-session filter) |
POST /session | Create a session ({"directory": "...", "title": "..."}) |
GET /session[?directory=…] | List sessions; without the filter, every top-level session on the machine — child sessions are excluded |
GET /session/:id | One session |
GET /session/:id/children | The child sessions its sub-agents ran, oldest first |
DELETE /session/:id | Delete a session |
GET /session/:id/status | {"running": bool} — is this session working right now |
POST /session/:id/discard | Delete it only if it has no messages |
GET /session/:id/message | Message history |
POST /session/:id/message | Send a prompt |
POST /session/:id/abort | Cancel the running turn |
GET /session/:id/permission | Pending permission requests |
POST /session/:id/permission/:permID | Answer ({"decision": "once"|"always"|"reject"}) |
GET /session/:id/question | Pending questions from the question tool |
POST /session/:id/question/:qID | Answer ({"answer": "..."}; an empty string means “no answer”) |
POST /session/:id/undo | Revert the last turn’s changes |
POST /session/:id/message
{
"text": "…", // required
"auto": false, // approve anything not denied by config
"model": "provider/id", // override for this turn
"agent": "build-auto", // override for this turn
"effort": "high" // low | medium | high
}
409 if the session is already processing a turn. model, agent and effort
are validated here rather than passed through — an unrecognised value would
otherwise reach the lookup table and produce a confusing failure deep in the
loop.
Why / answers two different things
/ and /health are asked the same question — “what is here?” — and the honest
answer differs by who is asking. Content negotiation on Accept splits them: a
browser gets the web client, everything else gets JSON. /health stays JSON
unconditionally, so a health checker never has to guess.
GET /session/:id/children
A sub-agent runs in a child session, and this is the only route that lists
them. It is not filtered by running: the ones already finished are the ones
people open most, to read what was actually done on their behalf.
Children are deliberately kept out of GET /session. Picking one there would
swap the active session, and the next prompt would land inside the sub-agent’s
own conversation. The TUI uses this route for the transcript page behind Ctrl+X
S — see Sub-agents.
Why discard is not DELETE
The TUI has to create a session when it starts, not when you send the first
prompt, because the event stream needs an id up front. discard is what cleans
that up on exit and on /new — and the emptiness check runs on the server,
because the caller is throwing away a session it merely believes is unused,
and one miscount must never cost a real conversation.
Streaming an answer
Send Accept: text/event-stream on POST .../message to receive the answer as a
stream:
SID=$(curl -s -X POST localhost:4096/session -d "{\"directory\":\"$PWD\"}" | jq -r .id)
curl -N -X POST "localhost:4096/session/$SID/message" \
-H 'accept: text/event-stream' \
-d '{"text":"How many .ts files are in src/? Use glob."}'
Without that header the request returns the completed message instead of streaming it.
The event model
Events follow a hybrid model:
text.delta— one event per token of assistant text.message.updated— a whole-message snapshot, emitted for every tool state change.
!!! warning “Snapshots resend every part”
A client that renders tool status must track what it has already shown.
message.updated carries the complete message each time, so naively
appending produces every tool block over and over.
Streaming per-token text while snapshotting tool state is a deliberate compromise: token deltas are what make an answer feel alive, and tool state has too many interacting fields for a delta to be worth debugging.
Permissions over HTTP
Because the topology is client/server, permission travels across processes:
- the server emits
permission.request - the client shows a dialog
- the user answers
- a
POST /session/:id/permission/:permIDcomes back - the server continues
With no client connected, anything that would need a dialog is auto-denied and
the reason is logged. Blocking forever would freeze the agent in CI;
auto-allowing would make headless mode a silent security hole. For automation,
send {"auto": true} with the prompt, or configure permission.allowlist. See
Permissions & undo.
Attaching a TUI to a remote server
titah attach http://host:4096
This works with no extra code precisely because the local case is the same code
path — titah spawns a server on a random port and attaches to it.
!!! danger “There is no authentication”
titah serve has no auth layer in v1. Bind it to localhost, or put it behind
something that does authenticate. A server on a public interface hands
anyone a shell in your working directory, subject only to the permission
config.
Sessions and directories
A session is created against a directory, and that directory is where every
tool call is confined. GET /session lists sessions; the CLI’s folder-scoped
view (titah sessions list) is a filter over the same data — see
Storage & maintenance.