Frontend Integration — Conversation API
The conversation REST API is the recommended way to integrate a frontend application with Swarmd. It’s designed to survive real-world HTTP conditions — CDN cutoffs, mobile network churn, browser tab suspension — that a long-lived synchronous call cannot. Every conversation is identified by a stablecontextId. You start it once, send messages against it, and poll its state URL until the aggregate goes terminal. The relay handles the orchestration in the background on a virtual thread; your frontend never has to hold the socket open for the whole run.
For TypeScript applications, use
@swarmd/channel-client. It handles the create/send/poll cycle, OAuth token refresh, reply extraction, and typed lifecycle events for working, HITL, and terminal outcomes. The raw HTTP contract below is useful when building another client or debugging an integration.Access Types
The conversation API works for both channel-based access and user-based access. Only the URL prefix and the auth token differ.
Examples in this guide use the channel prefix; substitute the human prefix and its Bearer token for user flows.
The Three Endpoints
The API is deliberately small — three operations cover the whole lifecycle.
There is also
GET /conversations/{contextId}/messages for paginated history if you need it — most frontends don’t; the messages[] array on ConversationState covers UI redraws.
The Send-and-Poll Flow
Every user turn follows the same three-step pattern.
The response body is a
ConversationState in both cases — same JSON shape, no polymorphism. Your code inspects aggregateState (or reads the HTTP status as a fast path) and decides whether to render or poll.
Step 1 — Create the Conversation
Once per user session (or once per widget mount), mint a conversation bound to the agent you want to talk to.contextId client-side. Every follow-up message in this conversation reuses the same value.
Step 2 — Send a Message
- Fast — 200 OK
- Slow — 202 Accepted
The chain finished within the early-return window (30 s by default). The body is a terminal
ConversationState. Render the reply and you’re done.The 202 signal is what makes this integration robust. In the old JSON-RPC path (
a2a/0.3.0), a slow orchestration could hold an HTTP call until a CDN, proxy, or mobile network cut it off. With 202 + polling, the initial POST returns after the configured early-return window regardless of how long the chain takes.Step 3 — Poll for the Final Reply
While the aggregate is non-terminal, poll:ConversationState — same shape as the send response.
aggregateState is in { COMPLETED, REJECTED, FAILED, CANCELED }. Otherwise wait ~2 s and try again.
The aggregateState field
Precedence (highest wins):
HITL_HELD > WORKING > REJECTED > FAILED > COMPLETED.
The COMPLETED branch is the “definitively done, reply present” contract. Clients that see aggregateState=COMPLETED can trust latestTask holds a usable reply and render it without further branching.
One Conversation, Many Tasks
Under onecontextId there can be many RelayTask rows — one for every delegation hop.
Say your user asks the reservations agent to move a booking that’s owned by a partner tenant. Behind the scenes:
RelayTask with its own state. ConversationState.tasks[] gives you the full breakdown:
aggregateState using this precedence:
latestTask.status.message (the user-visible reply) and the aggregate — but the per-task breakdown is available if you want to show a progress list (“guest lookup ✓ · Hilton availability ✓ · quote pending…”).
The Full Client Loop (Raw TypeScript)
The first-party package already implements this loop. The code below shows the underlying HTTP pattern for custom clients.HITL: What Changes for Your UI
When a policy holds a task for human review,aggregateState goes to HITL_HELD. It’s non-terminal, so your poll loop keeps ticking. The response carries an explicit relay reason so you can render the right UI:
On resolution the aggregate flips to one of two distinct terminal values:
- Approved →
aggregateState === 'COMPLETED'.latestTask.status.message.parts[0].text(orlatestTask.artifacts[0].parts[0].text) carries the agent’s reply. - Rejected →
aggregateState === 'REJECTED'.latestTask.metadata.relay_reason === 'HITL_REJECTED'andlatestTask.status.state === 'canceled'; body is empty.
HITL_HELD.
Errors and Retries
The
contextId is the durable handle for a conversation. If your user closes the tab or your app crashes mid-poll, you can pick up exactly where you left off by re-polling the same GET /state URL.
Conversation Continuity
Follow-up user turns reuse the samecontextId — the agent retains full history.
contextId per user session (or per widget mount) is the typical pattern.
Comparison with the Legacy JSON-RPC Path
If you’re on the older JSON-RPC path (POST /relay/v1/…/agents/{agentId}/a2a/0.3.0 with message/send + tasks/get), here’s what carries over:
The legacy path still works and is documented at HITL Frontend Integration. New integrations should prefer
@swarmd/channel-client, which targets the Conversation REST API and survives CDN cutoffs cleanly.
Summary
- Mint a conversation once with
POST /conversations. Keep thecontextId. - Send messages with
POST /conversations/{contextId}/messages. Inspect the HTTP status:200= done,202= poll. - Poll
GET /conversations/{contextId}/stateuntilaggregateStateisCOMPLETED,REJECTED,FAILED, orCANCELED. - The response body is a
ConversationStatein every case — same shape, no polymorphism. - One
contextIdgroups manyRelayTaskrows; the relay rolls them up into anaggregateStatefor you. - TypeScript applications can use
@swarmd/channel-clientto wrap all of this.
Next Steps
- TypeScript Channel Client — use the packaged OAuth, polling, lifecycle-event, and reply-handling implementation.
- Your First Agent — provision a channel or user and mint an access token.
- Human-in-the-Loop — background on how HITL policies feed the states above.
- Monitoring and Audit — inspect the per-task audit trail behind
ConversationState.tasks[].
