Skip to content
titah

Reference

HTTP API

Every route `titah serve` exposes, the event model, and the one thing a client rendering tool status must get right.

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

RoutePurpose
GET /The web client (HTML) for browsers, the /health payload (JSON) for everything else
GET /healthStatus, version, pid — always JSON
GET /event?session=<id>SSE stream of all events (optional per-session filter)
POST /sessionCreate 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/:idOne session
GET /session/:id/childrenThe child sessions its sub-agents ran, oldest first
DELETE /session/:idDelete a session
GET /session/:id/status{"running": bool} — is this session working right now
POST /session/:id/discardDelete it only if it has no messages
GET /session/:id/messageMessage history
POST /session/:id/messageSend a prompt
POST /session/:id/abortCancel the running turn
GET /session/:id/permissionPending permission requests
POST /session/:id/permission/:permIDAnswer ({"decision": "once"|"always"|"reject"})
GET /session/:id/questionPending questions from the question tool
POST /session/:id/question/:qIDAnswer ({"answer": "..."}; an empty string means “no answer”)
POST /session/:id/undoRevert 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:

  1. the server emits permission.request
  2. the client shows a dialog
  3. the user answers
  4. a POST /session/:id/permission/:permID comes back
  5. 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.