Skip to main content

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 stable contextId. 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.
Two response codes on the send, one shape. 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.
201 Created
Cache the contextId client-side. Every follow-up message in this conversation reuses the same value.

Step 2 — Send a Message

Two possible responses — same JSON body shape, different HTTP status:
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:
Response is a ConversationState — same shape as the send response.
Stop polling when 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 one contextId 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:
Every hop persists its own RelayTask with its own state. ConversationState.tasks[] gives you the full breakdown:
The relay rolls those per-task states up into a single aggregateState using this precedence:
So if any task is HITL_HELD, the aggregate is HITL_HELD. If any is still WORKING, it’s WORKING. Otherwise if any FAILED, it’s FAILED. Only if every task is COMPLETED does the aggregate go terminal. Most frontends only need to render 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.
That’s the whole integration — three endpoints, one shape, one status-code branch.

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:
Two flavours you’ll see: On resolution the aggregate flips to one of two distinct terminal values:
  • ApprovedaggregateState === 'COMPLETED'. latestTask.status.message.parts[0].text (or latestTask.artifacts[0].parts[0].text) carries the agent’s reply.
  • RejectedaggregateState === 'REJECTED'. latestTask.metadata.relay_reason === 'HITL_REJECTED' and latestTask.status.state === 'canceled'; body is empty.
Single field to distinguish. Don’t infer rejection from empty-reply heuristics — that misfires on the resume race and on the “agent legitimately produced no text” edge case. Trust the aggregate. For HITL flows, extend your polling deadline — analyst approvals can take hours. A common pattern: 5-min deadline by default, sliding 30-min deadline while the aggregate is 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 same contextId — the agent retains full history.
There’s no need to mint a new conversation per turn. One 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 the contextId.
  • Send messages with POST /conversations/{contextId}/messages. Inspect the HTTP status: 200 = done, 202 = poll.
  • Poll GET /conversations/{contextId}/state until aggregateState is COMPLETED, REJECTED, FAILED, or CANCELED.
  • The response body is a ConversationState in every case — same shape, no polymorphism.
  • One contextId groups many RelayTask rows; the relay rolls them up into an aggregateState for you.
  • TypeScript applications can use @swarmd/channel-client to wrap all of this.

Next Steps