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/attachmentsattachments 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?: { mediaId }[], pageContext?, uiCapabilities?, model?, regenerate?: true, transient?: true, sentAt? } — uiCapabilities says which components this client can draw (UI capabilities); transient starts a NEW thread as a scratch conversation, left out of GET /agent/threads until promoted (10b); model must be a catalog model available to the caller (400 otherwise) and applies to this turn only (see Models); regenerate see Regenerating an answer; sentAt (ISO 8601 or epoch ms) is when the person sent it, for a client sending what it buffered offline — stored as StoredMessage.sentAt (a future time is taken as now, anything unreadable ignored) and what messageTimestamps shows the model; mode?: 'auto' | 'queue' | 'interrupt' see Message queue — a send on a thread whose turn is still running answers 202 { queued: true, … } JSON instead of a stream. With independent approvals, a message on an existing thread that is exactly a decision command (sim, cancelar #id, …) answers 200 { threadId, proposalDecision, text } JSON and starts no turn.
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 }. Under the inline runner the model call the run is in is aborted at once, as is a tool that passes on ctx.abortSignal; the stream ends with a cancelled frame, and the step the Stop cut short is not persisted.
4POST /agent/tool-call/approveApprove a pending action tool call. Body: { toolCallId, runId?, remember?, via? }. A call that became an independent proposal is a 400 — decide it on its proposal route.
5POST /agent/tool-call/rejectReject a pending action tool call. Body: { toolCallId, runId?, reason?, via? }.
6GET /agent/threadsList the actor's threads.
6cGET /agent/models?agent=The models the caller may pick: { providers: [{ id, label, models: [{ id, label, description?, badges?, available, unavailableReason?, contextWindow? }] }], default, locked?: { model, reason? } } — see Models. An empty catalog when none is configured.
6dGET /agent/agentsThe registered agents for a picker: { name, description, isDefault?, lockedModel?, personas?, defaultPersona? }[] — lockedModel when the catalog locks that agent to one model for the caller; personas ({ id, label, description? }[]) and defaultPersona when it declares them.
6bGET /agent/tools?agent=The tools the caller can reach through an agent, as { name, kind, presentation? }[] — the same list the model is offered. ?agent=* (ALL_AGENTS) is the union across every agent, each tool once, under the same gates. 404 for an unknown agent. See Presentation.
7GET /agent/threads/personas/catalogThe default agent's persona catalog, { id, label, description? }[]. GET /agent/agents lists every agent's.
8GET /agent/threads/:idThread detail (messages + activeRunId, the run streaming right now or null — the one to re-attach to), or null. Thread summaries carry activeRunId too.
8bPATCH /agent/threads/:id{ title?, model?, defaultAgent?, persona? } — rename (1–200 characters), pin a catalog model on the thread (null unpins; 400 for a model the catalog does not offer), set the agent a send that names none runs as (null clears; 400 for an unregistered agent — a persona alias counts as registered), and/or pin a persona (null clears; 400 persona_not_found for one the agent does not declare). The model and persona are checked for the agent the thread's next turn runs as. 501 for model/defaultAgent/persona on a store without updateThread. Answers { ok: true }.
8cPOST /agent/messages/:id/feedbackRate a message: { value: 'up' | 'down' | null, comment? } → { feedback: { value, comment?, updatedAt } | null } (null clears). Only the thread's owner — 403 otherwise; 404 unknown message; 400 a bad value; 501 on a store without threadOfMessage + setMessageFeedback. The rating comes back as StoredMessage.feedback and is not copied to a fork.
8dGET /agent/threads/:id/queueThe thread's message queue: { items: QueuedMessageView[], paused: QueuePause | null }.
8eDELETE /agent/threads/:id/queueDrop every waiting message (and lift a pause). Answers the (empty) queue.
8fPOST /agent/threads/:id/queue/resumeLift a pause and start the head when nothing is running. Answers the queue, plus runId when the head started.
8gPATCH /agent/queue/:messageId{ message?, attachments?: { mediaId }[] | null, position? } — edit and/or move a waiting message. Answers the queue; 410 when it already started or was removed, 404 unknown, 403 another actor's.
8hDELETE /agent/queue/:messageIdRemove a waiting message. Answers the queue; 410 / 404 / 403 as above.
8iPOST /agent/queue/:messageId/interruptRun a waiting message now: it moves to the head as an interrupt, any pause is lifted, and the running turn is cancelled for it. Answers the queue plus interrupting (the cancelled run) — or runId when nothing was running and it started at once. 410 / 404 / 403 as above.
8jGET /agent/threads/:id/action-proposalsThe thread's independent proposals the caller may see; paginated through ?after= and the X-Action-Proposals-Next header. Without actionApprovalMode: 'independent' there are none: 200 with [].
8kPOST /agent/threads/:id/action-proposals/:proposalId/approve | …/rejectDecide a proposal: { remember? } / { reason? }. Answers the mutation result (applied, unchanged, conflict, not_found, expired).
9DELETE /agent/threads/:idSoft-delete a thread.
10POST /agent/threads/:id/fork-from/:messageIdFork a thread up to a message (regenerate / branch).
10bPOST /agent/threads/:id/promoteKeep a transient thread (one started with POST /agent/chat { transient: true }): it joins GET /agent/threads. { ok: true }; 501 on a store without promoteThread.
10cDELETE /agent/threads/:id/from/:messageIdDrop a message and everything after it — the "edit and resend" primitive. { ok: true }.
10dGET /agent/skills?threadId=The skills this caller can invoke right now, scope-resolved — the same list the model is offered: { name, description, scope, shadows? }[]. An empty list when no skills are configured.
11bGET /agent/quota{ windows: [{ period, usedTokens?, limitTokens?, usedUsd, limitUsd?, resetsAt?, warnAt? }], blocked?, warning?: { period, ratio, reason? } } — see Budget windows. usedTokens is omitted for a USD-only budget; warning names the fullest window past its warnAt (never while blocked). A send while one is exhausted answers 429 { code: 'quota_exceeded', period, message }.
11GET /agent/configServer facts a client would otherwise repeat: { attachments: { enabled, upload, maxBytes, allowedContentTypes, maxPerMessage }, models: { enabled }, quota: { enforced }, identity: { anonymous } } (what the React useAgentConfig reads).
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.

Refusals answer { message, code? }

Every refused request answers its HTTP status with a JSON body { message, code? } — message in words a person can read, code a machine-readable reason where there is one (unauthorized, model_not_allowed, thread_required, quota_exceeded, …). @adonis-agora/agent/client throws it as AgentChatHttpError (status, code, body, and the server's message as its own); @adonis-agora/agent/react reads the same body.

Every route resolves the actor first

Routes that need identity — chat, threads list, quota — resolve the actor first. With no actorResolver configured each browser is its own anonymous actor (an HttpOnly agent_anon cookie set on its first request); with one, a resolver that throws makes the route reply 401 and never run. 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, written in the chat stream protocol compatible with Aviary: one AgentStreamEvent JSON object per data: frame. Agora's local React transport, useAgentChat, transcript model and @adonis-agora/agent/client read it unchanged.

The legacy delta envelope is gone

Before 0.48 the default was a 'legacy' envelope — data: {"delta":"…"} per text chunk plus named event: component / event: elicitation / event: approval frames — selected by a streamProtocol option. Both the envelope and the option are removed: every server speaks the protocol below. A hand-written reader of {"delta"} reads {"kind":"text","text":…} instead (and ui / elicitation / approval-requested events in place of the named frames), or moves to one of the clients above.

event: meta
data: {"runId":"<uuid>","threadId":"<uuid>"}

data: {"kind":"step-start"}

data: {"kind":"reasoning","text":"The user wants…"}

data: {"kind":"tool-input-available","id":"c1","name":"weather","input":{"city":"Lisbon"},"toolKind":"read"}

data: {"kind":"tool-output","id":"c1","output":{"tempC":21}}

data: {"kind":"step-finish","usage":{"inputTokens":812,"outputTokens":40},"costUsd":0.0004}

data: {"kind":"step-start"}

data: {"kind":"text","text":"It is 21°C in Lisbon."}

data: {"kind":"step-finish","usage":{"inputTokens":870,"outputTokens":9},"costUsd":0.0001}

data: {"kind":"title","title":"Weather in Lisbon"}

event: done
data: {}

The vocabulary remains compatible with Aviary's stream protocol (AgentStreamEvent, exported from this package too). What this package writes:

kindWhen
step-start / step-finisharound each model call and the tools it asked for; step-finish carries usage and costUsd (null when unpriced, never a made-up 0)
text, reasoningas the model streams them (the AI SDK adapter streams reasoning deltas)
tool-input-start / -delta / -availableas the model streams a call; a provider that streams none gets its calls announced by the loop once the model call returns
tool-output / tool-output-error / tool-output-deniedonce the step's tools have settled; a person's "no" is tool-output-denied, never an error
elicitationa question set the run is parked on (plus runId, the run to answer against)
approval-requestedan action call parked on a person: approver: 'requester' (plus runId, toolName, input)
uia component a tool pushed with ctx.emitUi, present or the older ctx.emitComponent — { id, component, props, version?, fallbackText?, componentVersions?, toolCallId? }; id defaults to <toolCallId>:ui:<n>, a repeat replaces it; persisted on the message once the step's tools settle. A component the send's uiCapabilities leave out arrives as text instead (One output, many surfaces)
approval-requested with targetan independent proposal awaiting a decision: target: { kind: 'proposal', proposalId } — decide it on the proposal routes
titlethe thread was titled from its first message
cancelledthe run was stopped — the last frame before done, so a truncated answer reads as one

Every event frame carries an SSE id: — its 1-based position in the run (id: 7 above data: {"kind":"text",…}). The number belongs to the frame, not the connection: the same frame has the same id on the POST that started the run and on every later GET …/stream. GET /agent/chat/:runId/stream?after=<n> (or the standard Last-Event-ID header; after wins) sends only the frames numbered above n, still preceded by meta — which is how the Agora React client resumes a dropped stream without re-rendering what it has. meta, done and error carry no id. When nothing is buffered under the run any more (it ended while the client was away, its buffer passed the sink's replay window, or it went with a restarted process), the route answers 404, read as "nothing to resume"; a sink reports that through the optional TokenStreamSink.has (the in-process, in-memory and Redis sinks implement it).

A failed run ends with event: error + data: {"code","message"} instead of done; the codes are quota_exceeded, output_rejected, structured_output_invalid and run_failed.

Frames are written from inside the loop's existing checkpoints, so a durable replay never streams one twice and no checkpoint moved.

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. When the client closes the connection first, the route (native, AG-UI and A2UI alike) ends its subscription to the run; the run itself keeps going.

Persisted history

GET /agent/threads/:id returns the messages a reload rebuilds the transcript from. Besides content, toolCalls/toolResults and attachments, an assistant message carries what its step streamed, so a reloaded thread shows what the live one did:

FieldWhat it holds
reasoning?: stringthe model's thinking for the step, as its reasoning frames streamed it
reasoningMs?: numberhow long it thought — the sum of each burst of consecutive reasoning frames; the same number step-finish carries live
ui?: { id, component, props, version?, fallbackText?, componentVersions?, toolCallId? }[]components the turn pushed (ui frames, the model's and its tools'), first-seen order, last props per id
actionProposalOutcome?on the message an independent proposal admitted: its decision, execution status, result or error

They are read off the frames inside the model call's own checkpoint, so a durable replay persists the same values rather than re-measuring a turn that is not happening. A provider may also report them itself on ModelTurnResult (reasoning, reasoningMs, ui); its values win. @adonis-agora/agent/react's storedThreadToUiMessages replays them as reasoning and data-ui parts.

Models

A turn's model is the send's model, else the thread's pinned model, else the provider's default. The simple way to offer a picker is aiSdkModels — several models behind one provider, which carries its own catalog:

config/agent.ts
import { aiSdkModels } from '@adonis-agora/agent/ai-sdk'
import { openai } from '@ai-sdk/openai'

export default defineConfig({
  model: () =>
    aiSdkModels(
      {
        'openai/gpt-4o-mini': { model: openai('gpt-4o-mini'), label: 'GPT-4o mini', badges: ['fast'] },
        'openai/gpt-4o': { model: openai('gpt-4o'), label: 'GPT-4o' },
      },
      { default: 'openai/gpt-4o-mini', providerLabels: { openai: 'OpenAI' } },
    ),
})

With no models in the config, GET /agent/models serves that catalog and a turn runs on the picked id. aiSdkModel(model) serves one model and refuses a pick naming another (the turn fails) rather than silently answering on a model nobody chose.

A send's model applies to that turn only — the server never stores it on the thread, whether the send creates the thread or continues one. Pinning is its own request, PATCH /agent/threads/:id { model } (null unpins), and only a pin outlives the turn.

Locking an agent to one model. A catalog that answers locked: { model, reason? } for an agent runs every turn of that agent on locked.model, whatever the send or the thread's pin names, and refuses a send naming another model with 400 (code: 'model_not_allowed'). GET /agent/agents reports it as lockedModel. List the other models as unavailable as well, so a client that predates the field cannot pick them.

To decide per caller (plan, budget, provider health), set models to a ModelCatalog — list({ actor, agent }) returns the same view, with available: false and an unavailableReason for what the caller cannot use right now — or to a literal view for a fixed list. The server refuses (400) any model the catalog does not list as available, on a send and when pinning — a client only ever sends ids the catalog listed, but the server does not trust that. The picked id reaches the provider as ModelTurnArgs.model. The pick rides the run's input, so a durable replay runs on the same model, and it labels the turn's usage when the provider reports no model id.

Regenerating an answer

POST /agent/chat { threadId, regenerate: true } answers the thread's last user message again — the retry button under the last answer. The server requires threadId (400, code: 'thread_required', without one) and the caller's own thread; stores no user message (message is ignored — the stored one is answered); drops the answer(s) after that user message, so the model does not see the answer it is replacing; and streams the new answer as a normal run under a new runId. model, agent and pageContext apply as on any send. The truncation is its own durable step (regenerate:truncate), so a replay never truncates twice and a normal turn journals exactly what it always did.

Message queue

A person can keep typing while a turn is running. A message sent then is queued on the thread, server-side — it survives a reload, a closed tab, another device — and runs as soon as the turn ahead of it settles, exactly as if it had been sent then. It is not part of the transcript until it runs: the turn it starts appends it as its user message. The queue contract remains compatible with Aviary; Agora's local React client exposes it through chat.queue.

POST /agent/chat takes mode:

modeThread idleThread busy (a turn is running)
'auto' (default)starts the turn, streams itqueues it at the tail → 202
'queue'queues it, and starts it at once → 202 with runIdqueues it at the tail → 202
'interrupt' (or interrupt: true)starts the turn, streams itqueues it at the head, cancels the running turn → 202 with interrupting

The 202 body is { queued: true, threadId, messageId, position, queue: { items, paused }, runId?, interrupting? } — position is 0-based (0 runs next), runId is present only when the message started straight away, and a queued message's run id is its own messageId, so a client can name the run before it starts. Everything a send carries (agent, model, attachments, pageContext) is resolved and checked when it is queued (400 / 403 / 429 then, not later) and stored with the message; the actor is the one who queued it. The turn it starts records when it was queued (or the send's own sentAt) as the message's sentAt, so messageTimestamps shows the model when it was sent, not when its turn began. regenerate is refused with 409 { code: 'run_active' } while a turn runs.

When the turn holding the thread settles, the server decides what follows before writing that turn's terminal frame, and announces it as the stream's last event — {"kind":"queue","queue":{…},"started":{"messageId","runId"}} (attach to the new run with GET /agent/chat/:runId/stream), or a queue.paused saying why nothing starts:

The turn…The queue
completedthe head starts
failedpauses (run_failed, message = the failure)
was cancelled (Stop)pauses (cancelled)
was cancelled by an interruptthe interrupt's message (the head) starts

The head's actor is checked against the quota as it starts; an exhausted budget pauses the queue (quota_exceeded) instead. A paused queue keeps every message and starts nothing until POST /agent/threads/:id/queue/resume. Every change to the queue while a turn holds the thread (queued, edited, moved, removed) is also written into that turn's stream as a queue event — a whole snapshot, never a delta. GET /agent/threads/:id carries queue too.

One turn per thread. The thread's active run (activeRunId) is the admission lock, and every write to it is a compare-and-set (claimActiveStream / releaseActiveStream on the store), so a settling run on a worker and a send on a web pod cannot both start a turn. A holder that is no longer running (a process that crashed mid-turn) is replaced by the next send or resume rather than queued behind for ever. With the durable runner the handoff is journaled (queue:completed / queue:failed local steps behind ctx.patched('agent:chat-queue'), then ctx.startChild under the message's id), so a replay never starts a message twice and a run in flight when you upgrade replays the history it has.

The queue needs a store that implements ChatQueueStore — the Lucid store (table agent_queued_message, column agent_thread.queue_pause; both created by createAgentTables) and the in-memory store do. A custom store that does not keeps the old behaviour (a send on a busy thread starts a second, concurrent turn) and the queue routes answer 501; CHAT_QUEUE_STORE_CONTRACT in @adonis-agora/agent/testing checks an implementation of your own.

In-process callers: chat() refuses, send() queues

AgentService.chat() stays start-or-refuse: on a thread whose turn is still running it throws a ChatQueueError (status: 409, code: 'run_active') instead of queueing. AgentService.send() is the form that queues (what POST /agent/chat calls). Before 0.50 a second chat() on a busy thread started a concurrent turn.

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. runId may be omitted — a tool call id is unique, so the route reads the run off the call's own row (AgentStore.getToolCallRunId) and answers 404 for a call it does not know. That is the body @adonis-agora/agent/react sends; the same holds for tool-call/answer and tool-call/skip.

Both decision routes take two optional fields: via names the surface the decision came through ('web' when omitted, 1–64 characters) and remember: true (approve only) approves later calls of the same tool in the same thread without asking again. A malformed one is a 400.

Approval policy

By default the person chatting approves every action call, and a request never lapses. approvalPolicy in config/agent.ts changes who and for how long:

config/agent.ts
export default defineConfig({
  // …
  approvalPolicy: {
    ttlMs: 15 * 60_000, // every request lapses after 15 minutes
    tools: {
      refund: { approver: 'FINANCE' }, // a role decides refunds
      lookupInvoice: { required: false }, // an action that runs without asking
    },
  },
})

That shorthand covers most deployments. For a decision per actor, thread or agent, pass a whole ApprovalPolicy — requirementFor(tool, actor, thread) returns { required, approver, ttlMs? }, and an optional canDecide(actor, { toolCallId, approver, requesterRef }) replaces the default "holds that role" rule (plug an authz gate in there):

import { REQUESTER_APPROVER, type ApprovalPolicy } from '@adonis-agora/agent'

const approvalPolicy: ApprovalPolicy = {
  requirementFor: (tool, actor) =>
    tool.spec?.ability === 'billing.refund'
      ? { required: true, approver: 'FINANCE', ttlMs: 3_600_000 }
      : { required: true, approver: REQUESTER_APPROVER },
  canDecide: (actor, decision) => actor.roles?.includes(decision.approver) === true,
}

What changes on the wire and the routes:

  • Who. approval-requested carries the approver. For the requester, the approve/reject routes keep the ownership check they always had. For any other approver the caller must pass canDecide, else 403 — including the person chatting.
  • Until when. With a ttlMs the frame carries expiresAt, and the run waits at most that long: the inline runner arms a timer, the durable runner hands the time to its signal wait's own timeout (a deadline the runtime journals, so it survives a restart). Nobody deciding in time settles the call expired: approval-settled { status: 'expired' }, then tool-output-denied with reason approval expired, and the model is told the approval lapsed — not that someone said no. A decision on a lapsed request answers 410.
  • How it settled. approval-settled names decidedBy, decidedVia and remember (or reason on a rejection). A remembered approval streams approval-settled { decidedVia: 'remembered' } without asking.
  • History. GET /agent/threads/:id returns each message's approvals: [{ toolCallId, approver, expiresAt?, status, remember?, decidedBy?, decidedVia?, reason? }].

The requirement is decided inside the call's own persist:toolcall checkpoint, so a parked durable run keeps the answer it was given even if the policy changes while it waits. The Lucid store adds four nullable agent_tool_call columns for it (approver, expires_at, remember, decided_via); a custom AgentStore opts in with rememberedApprovals and toolCallApproval (both optional).

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 activeRunId, 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, for one hour after the run's last write — a run parked on a person for longer than that is still resumed, but a client attaching afterwards replays only what it wrote since. 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 — or, without Redis, the Lucid 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 both consumers. In React, @adonis-agora/agent/react is the whole layer — the provider, useAgentChat, the transcript model, the queue, approvals, attachments:

import { AgentProvider, useAgentChat } from '@adonis-agora/agent/react'

function Chat() {
  const { transcript, composer, queue } = useAgentChat() // under <AgentProvider>
}

See React. Outside React, @adonis-agora/agent/client posts a turn, decodes the envelope above into typed frames, and re-attaches automatically when the connection drops — see Browser client.

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 (meta, error, done), and read an unnamed event as an AgentStreamEvent dispatched on its kind — tolerating kinds you do not know. The NestJS library's React package works against this server as-is.

On this page

Partner

Your product as the official integration for Agent, right here.

Become a partner →