HTTP chat endpoint — POST /api/chat¶
Why¶
The runtime was Discord-in, Discord-out. POST /webhook/{source} exists but is
GitHub-event specific: it formats an event into a prompt and dispatches it, it does not
do request/reply.
That made Discord the only place a human could hold a conversation with an agent. Rig
Cockpit (rig-cockpit-ios) had no way to reach one, and a Cockpit chat surface — open
the app, pick a tenant agent, talk to it — is the goal this unblocks.
What¶
createDashboard(character, memory, { webhookHandler, chatHandler }) now accepts a chat
handler alongside the existing webhook handler, and serves it at POST /api/chat.
The agent loop is untouched. The handler calls the same agent.process() the Discord
path calls, with the same context shape, so providers, tools, MCP servers and memory all
behave identically.
Request¶
Response¶
sessionId is passed straight through to agent.process()'s context — the same field
stream-consumer.js already uses — so multi-turn continuity needs no new machinery. A
session id is generated when the caller omits one.
Status codes¶
| Code | Meaning |
|---|---|
| 200 | reply produced |
| 400 | body is not JSON, or message is missing/empty/not a string |
| 401 | CHAT_API_TOKEN is set and X-Chat-Token does not match |
| 413 | body over 32 KB |
| 429 | a chat is already in flight |
| 502 | the provider failed |
| 503 | CHAT_API_TOKEN is unset — the route is disabled |
Decisions¶
Fails closed. Every call drives an LLM, so the route is cost-incurring. It stays
disabled and returns 503 until CHAT_API_TOKEN is set, mirroring rig-conductor's
RunPodClient.IsConfigured, which 503s its pod-control routes until the operator
provisions the secret. The alternative — ship open, harden later — leaves a window in
which an unauthenticated caller can spend the agent's quota.
The token comparison is constant-time and tolerates a length mismatch rather than
throwing, and an unset token is never satisfied by an unset header. That last one is the
trap worth naming: a naive configured === presented is true when both are undefined,
which would silently open the route on any pod without the secret.
One chat in flight. The claude-cli provider spawns a subprocess per turn, so
unbounded parallel chats are a one-line way to exhaust the host. Additional callers get
429. The slot is released in a finally, because releasing only on success means the
first provider error wedges the endpoint at 429 for the lifetime of the pod.
Provider errors are not echoed to the caller. They routinely carry env var names, credential paths and prompt fragments. The detail is logged; the response says only that the provider failed. This reply crosses a network boundary to a phone.
No new public surface. The dashboard service stays ClusterIP. Cockpit reaches this
through rig-cockpit-worker, which is already the app's only edge.
Configuration¶
| Variable | Effect |
|---|---|
CHAT_API_TOKEN |
Shared secret. Unset disables the route entirely. |
Callers present it as X-Chat-Token.
Not in this change¶
- Streaming. A turn is a single request/reply. Long turns will feel slow in the app before they feel wrong; streaming is the follow-up if that becomes the complaint.
- Per-tenant authorization. One token, one agent, one pod. Tenant separation today is deployment-level: each tenant agent is its own release with its own token. That is sufficient while each pod serves exactly one character, and it is not sufficient once an agent holds credentials — see the note below.
Follow-up this exposes¶
In this runtime the persona and the tool grant live in the same file. character.json
carries both the personality and the tools block that decides what the agent may reach,
and buildTools() turns those endpoint declarations into callable tools with no policy
layer in between.
That is fine for a read-only agent. It stops being fine when an agent holds credentials, because the file describing how the agent talks is also the file that authorises what it can touch. A separate policy document — one the agent cannot see or edit, where deny wins — is the next structural piece, and it does not exist yet.