Agora

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:

SurfaceMounts when
POST /agent/attachmentsattachmentStaging is set — see Attachments.
GET /agent/approvals/minea 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/pricingthe above, plus a pricingStore.
POST|GET|DELETE /mcpthe MCP provider is registered in adonisrc.ts. A separate mount point, with its own identity path.

The routes

#Method & pathPurpose
1POST /agent/chatStart a run. Resolves the actor, creates or continues a thread, and streams the reply as SSE. Body: { message, threadId?, agent?, persona?, attachments?, pageContext? }.
2GET /agent/chat/:runId/streamRe-attach to a run's live token stream (reconnect / resume).
3POST /agent/chat/:runId/cancelAbort a run. Returns { aborted: true }.
4POST /agent/tool-call/approveApprove a pending action tool call. Body: { runId, toolCallId }.
5POST /agent/tool-call/rejectReject a pending action tool call. Body: { runId, toolCallId, reason? }.
6GET /agent/threadsList the actor's threads.
7GET /agent/threads/personas/catalogThe persona { id, label } catalog for a UI picker.
8GET /agent/threads/:idThread detail (messages + activeStreamId), or null.
9DELETE /agent/threads/:idSoft-delete a thread.
10POST /agent/threads/:id/fork-from/:messageIdFork a thread up to a message (regenerate / branch).
11GET /agent/quota/todayThe actor's token spend today: { usedTokens }.
12GET /agent/approvals/mine optionalThe 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 meta frame first, carrying the runId and threadId.
  • One data frame per streamed chunk, each {"delta": "<text>"}.
  • A terminating done frame 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.

On this page