Streaming & HTTP
The eleven core /agent routes, the five optional surfaces, the SSE envelope, human-in-the-loop approve/reject, and re-attaching to a live run.
The provider mounts eleven core routes under config.path (default agent) at boot. They cover the full chat lifecycle — start, stream, cancel, HITL, threads, and quota. No controllers to write; the provider resolves the actor, calls the framework-agnostic AgentService, and pipes the SSE.
Five optional surfaces mount only when their configuration is present:
| Surface | Mounts when |
|---|---|
POST /agent/attachments | attachmentStaging is set — see Attachments. |
GET /agent/approvals/mine | a governance read-model resolves. Not behind governanceAuthorize; always scoped to the caller. |
/agent/governance/* (11 routes) | a read-model resolves and a governanceAuthorize gate is configured. Without the gate they are not mounted at all and answer 404 — see Governance read-model. |
GET|POST /agent/governance/pricing | the above, plus a pricingStore. |
POST|GET|DELETE /mcp | the MCP provider is registered in adonisrc.ts. A separate mount point, with its own identity path. |
The routes
| # | Method & path | Purpose |
|---|---|---|
| 1 | POST /agent/chat | Start a run. Resolves the actor, creates or continues a thread, and streams the reply as SSE. Body: { message, threadId?, agent?, persona?, attachments?, pageContext? }. |
| 2 | GET /agent/chat/:runId/stream | Re-attach to a run's live token stream (reconnect / resume). |
| 3 | POST /agent/chat/:runId/cancel | Abort a run. Returns { aborted: true }. |
| 4 | POST /agent/tool-call/approve | Approve a pending action tool call. Body: { runId, toolCallId }. |
| 5 | POST /agent/tool-call/reject | Reject a pending action tool call. Body: { runId, toolCallId, reason? }. |
| 6 | GET /agent/threads | List the actor's threads. |
| 7 | GET /agent/threads/personas/catalog | The persona { id, label } catalog for a UI picker. |
| 8 | GET /agent/threads/:id | Thread detail (messages + activeStreamId), or null. |
| 9 | DELETE /agent/threads/:id | Soft-delete a thread. |
| 10 | POST /agent/threads/:id/fork-from/:messageId | Fork a thread up to a message (regenerate / branch). |
| 11 | GET /agent/quota/today | The actor's token spend today: { usedTokens }. |
| 12 | GET /agent/approvals/mine optional | The calling actor's own pending HITL approvals, oldest first. Query: limit? (default 50, clamped to 200). Mounts with governanceQueries but is not behind governanceAuthorize — it is always scoped to actor.id, so a non-admin surface (e.g. a coordinator's chat) can poll its own suspended tool calls even when the cross-actor read-model is ADMIN-only. |
Actor-scoped routes fail closed
Routes that need identity — chat, threads list, quota — resolve the actor first. If the resolver throws (or none is configured), the route replies 401 and never runs. See Authorization.
Owner-scoped routes (IDOR protection)
Routes addressed by a runId or threadId — stream, cancel, approve, reject, threads/:id (get/delete/fork), and continuing an existing thread on chat — additionally enforce object-level ownership: a caller may act only on the runs and threads it owns. A non-owner that guesses an id gets 404 (unknown) or 403 (not owner), unless it is governance-privileged. See Object-level ownership.
The persona catalog route is registered before threads/:id so it isn't captured by the :id param.
The SSE envelope
POST /agent/chat and GET /agent/chat/:runId/stream reply with Content-Type: text/event-stream and this exact frame sequence:
event: meta
data: {"runId":"<uuid>","threadId":"<uuid>"}
data: {"delta":"The "}
data: {"delta":"weather "}
data: {"delta":"in Lisbon is 21°C."}
event: done
data: {}- A single
metaframe first, carrying therunIdandthreadId. - One
dataframe per streamed chunk, each{"delta": "<text>"}. - A terminating
doneframe when the run completes.
Component frames (Generative UI)
A tool can also emit a typed UI component into the stream, which appears as a named event: component frame (data: {"name":...,"data":...}) interleaved with the text deltas. The text envelope above is unchanged — text-only clients ignore the extra event. See Generative UI.
The response also sets X-Agent-Run-Id and X-Agent-Thread-Id headers (plus Cache-Control: no-cache, no-transform and X-Accel-Buffering: no so proxies don't buffer the stream). The stream ends only on run completion — the token sink stays open across a HITL suspend, so an approved-then-resumed run keeps one continuous stream.
Human-in-the-loop
When the model calls an action tool, the run records it pending_approval and pauses. The client learns the pending toolCallId from the thread detail (GET /agent/threads/:id → the tool call's status), then decides:
# approve
curl http://localhost:3333/agent/tool-call/approve \
-H 'content-type: application/json' \
-d '{"runId":"<runId>","toolCallId":"<toolCallId>"}'
# or reject, optionally with a reason fed back to the model
curl http://localhost:3333/agent/tool-call/reject \
-H 'content-type: application/json' \
-d '{"runId":"<runId>","toolCallId":"<toolCallId>","reason":"not this account"}'On approve, the loop runs the tool and continues, recording who approved it. On reject, it records the rejection and feeds a rejection result back to the model, which continues the turn. A decision is always scoped to one (runId, toolCallId) pair, so one run can never approve another's call.
Re-attaching to a run
Streams survive a dropped connection. A late subscriber to GET /agent/chat/:runId/stream replays the run's buffered chunks first, then follows live until done. Combined with the thread's activeStreamId, a reloading client can find the in-flight run and rejoin its stream.
Single-replica by default
Re-attach works because the default token sink buffers per run in process memory. That makes it single-replica: a run started on one instance can only be re-attached on that same instance. Switch to the Redis sink so any pod can serve any run's stream, and pair it with the durable runner for cross-instance resume.
Consuming the stream
The package ships the client. @adonis-agora/agent/client posts a turn, decodes the envelope above into typed frames, and re-attaches automatically when the connection drops; @adonis-agora/agent/react wraps it in a useAgentChat hook:
import { useAgentChat } from '@adonis-agora/agent/react'
function Chat() {
const { messages, status, error, send, cancel } = useAgentChat({ basePath: '/agent' })
// messages[].parts is ChatPart[] — text and component parts in emission order
}Both are covered in full — options, resume semantics, component frames, and the HITL calls the client deliberately leaves to you — in Browser client & React.
For a non-JavaScript consumer, or a bespoke reader, the envelope above is the whole contract: read text/event-stream, dispatch on the event: name, and treat an unnamed event as a text delta.
Lucid
Persist threads, messages, tool calls, and token usage to Postgres, MySQL, or SQLite through @adonisjs/lucid — the six agent tables, the published migration, and the in-memory twin for tests.
Browser client & React
The framework-agnostic SSE client and the useAgentChat React hook shipped in the package — streaming chat, automatic resume across a dropped connection, component frames, and the HITL calls the client deliberately leaves to you.