> ## Documentation Index
> Fetch the complete documentation index at: https://docs.swarmd.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Frontend Integration — Conversation API

> Wire your frontend to Swarmd using the conversation REST API: send a message, poll a single stable URL for state, no long-lived HTTP holds.

# 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.

<Info>
  For TypeScript applications, use [`@swarmd/channel-client`](/sdks/typescript/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.
</Info>

***

## Access Types

The conversation API works for both channel-based access and user-based access. Only the URL prefix and the auth token differ.

|                | Channel Access                                           | User Access                     |
| -------------- | -------------------------------------------------------- | ------------------------------- |
| **Use case**   | Apps, bots, services, embedded chat                      | Dashboard, admin tools          |
| **Auth**       | OAuth2 client credentials (`channel-{channelId}` client) | User login (Bearer token)       |
| **URL prefix** | `/relay/v1/channels/{channelId}/conversations`           | `/relay/v1/human/conversations` |

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.

| Verb   | Path                                  | Purpose                                             |
| ------ | ------------------------------------- | --------------------------------------------------- |
| `POST` | `/conversations`                      | Create a conversation; returns `{ contextId, ... }` |
| `POST` | `/conversations/{contextId}/messages` | Send a user message; returns `ConversationState`    |
| `GET`  | `/conversations/{contextId}/state`    | Poll the current aggregate + latest reply           |

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.

```
POST /conversations                                → contextId
POST /conversations/{contextId}/messages           → 200 (terminal)   ← done
                                                   → 202 (working)    ← poll
GET  /conversations/{contextId}/state    (repeat)  → aggregate flips terminal
```

**Two response codes on the send, one shape.**

| Status         | Meaning                                                                                                   | Client action                                                      |
| -------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `200 OK`       | Middleware chain reached a terminal aggregate within the configured early-return window (30 s by default) | Render the reply; done for this turn                               |
| `202 Accepted` | Chain still running; response body carries `aggregateState=WORKING`                                       | Start polling `GET /state` every \~2 s until aggregate is terminal |

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.

```http theme={null}
POST /relay/v1/channels/{channelId}/conversations
Authorization: Bearer <channel-access-token>
Content-Type: application/json

{
  "agentId": "4a411d26-dec0-4553-a164-96ccecf0ecb9"
}
```

**201 Created**

```json theme={null}
{
  "id": "b8a7...",
  "contextId": "17aa30cf-9a10-4c25-8e8d-2b31ff31a4c1",
  "source": { "kind": "CHANNEL", "id": "..." },
  "sink":   { "kind": "AGENT",   "id": "4a411d26-..." },
  "createdAt": "2026-07-03T10:10:33Z"
}
```

Cache the `contextId` client-side. Every follow-up message in this conversation reuses the same value.

***

## Step 2 — Send a Message

```http theme={null}
POST /relay/v1/channels/{channelId}/conversations/{contextId}/messages
Authorization: Bearer <channel-access-token>
Content-Type: application/json

{
  "message": {
    "messageId": "msg-a1b2c3d4",
    "role": "user",
    "kind": "message",
    "contextId": "17aa30cf-9a10-4c25-8e8d-2b31ff31a4c1",
    "parts": [
      { "kind": "text", "text": "Shift RES-000108 from 12 to 19 August. Rate difference?" }
    ]
  }
}
```

Two possible responses — same JSON body shape, different HTTP status:

<Tabs>
  <Tab title="Fast — 200 OK">
    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.

    ```json theme={null}
    {
      "id": "b8a7...",
      "contextId": "17aa30cf-...",
      "aggregateState": "COMPLETED",
      "parentState": "COMPLETED",
      "messageCount": 2,
      "messages": [
        { "messageId": "msg-a1b2c3d4", "role": "user",  "parts": [{ "kind": "text", "text": "..." }] },
        { "messageId": "msg-e5f6g7h8", "role": "agent", "parts": [{ "kind": "text", "text": "Rate difference is $102/night ..." }] }
      ],
      "tasks": [
        { "taskId": "d49a82c0-...", "sinkAgentId": "4a411d26-...", "state": "COMPLETED", "createdAt": "..." }
      ],
      "latestTask": {
        "id": "d49a82c0-...",
        "status": {
          "state": "completed",
          "message": {
            "messageId": "msg-e5f6g7h8",
            "parts": [{ "kind": "text", "text": "Rate difference is $102/night ..." }]
          }
        }
      }
    }
    ```
  </Tab>

  <Tab title="Slow — 202 Accepted">
    The chain overshot the early-return window. The relay handed the socket back so your frontend isn't stuck holding it (avoids CDN 524s / mobile-network cutoffs / browser tab suspension). The middleware chain is still running on a background virtual thread. Body's `aggregateState` is `WORKING`.

    ```json theme={null}
    {
      "id": "b8a7...",
      "contextId": "17aa30cf-...",
      "aggregateState": "WORKING",
      "parentState": "CREATED",
      "messageCount": 1,
      "messages": [
        { "messageId": "msg-a1b2c3d4", "role": "user", "parts": [{ "kind": "text", "text": "..." }] }
      ],
      "tasks": [
        { "taskId": "d49a82c0-...", "sinkAgentId": "4a411d26-...", "state": "CREATED", "createdAt": "..." }
      ],
      "latestTask": {
        "id": "d49a82c0-...",
        "status": { "state": "working" }
      }
    }
    ```

    Start polling `GET /state` until aggregate flips terminal.
  </Tab>
</Tabs>

<Info>
  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.
</Info>

***

## Step 3 — Poll for the Final Reply

While the aggregate is non-terminal, poll:

```http theme={null}
GET /relay/v1/channels/{channelId}/conversations/{contextId}/state
Authorization: Bearer <channel-access-token>
```

Response is a `ConversationState` — same shape as the send response.

```json theme={null}
{
  "contextId": "17aa30cf-...",
  "aggregateState": "COMPLETED",
  "parentState": "COMPLETED",
  "messageCount": 2,
  "messages": [ /* full conversation history */ ],
  "tasks":    [ /* per-hop task summaries — see below */ ],
  "latestTask": { /* the final agent reply */ }
}
```

Stop polling when `aggregateState` is in `{ COMPLETED, REJECTED, FAILED, CANCELED }`. Otherwise wait \~2 s and try again.

### The `aggregateState` field

| Value       | Terminal? | What it means                                                                                                                                                                                                        |
| ----------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `UNKNOWN`   | No        | Conversation just created, no tasks yet — treat as WORKING                                                                                                                                                           |
| `WORKING`   | No        | Chain is executing (including the HITL-approved-but-not-yet-committed window) — keep polling                                                                                                                         |
| `HITL_HELD` | No        | A human reviewer needs to approve/reject — keep polling; show a "held for review" bubble                                                                                                                             |
| `COMPLETED` | Yes       | Every task settled successfully. The reply is guaranteed to be present on `latestTask.status.message.parts[0].text` or `latestTask.artifacts[0].parts` — there is no COMPLETED-with-empty-reply race.                |
| `REJECTED`  | Yes       | An analyst declined a HITL hold. `latestTask.metadata.relay_reason === 'HITL_REJECTED'` and `latestTask.status.state === 'canceled'`. Render this distinctly from FAILED — it's a human decision, not a chain error. |
| `FAILED`    | Yes       | Something upstream errored — surface a generic error to the user. `latestTask.metadata.relay_reason` carries the specific failure reason (e.g. `POLICY_BLOCKED`, `UPSTREAM_ERROR`).                                  |
| `CANCELED`  | Yes       | The conversation was explicitly cancelled by the caller.                                                                                                                                                             |

**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:

```
reservation_hub  → guest_directory        (task #1)
reservation_hub  → hilton_reservations    (task #2)
hilton_reservations → availability_mcp    (task #3)
hilton_reservations → bookings_mcp        (task #4)
reservation_hub  → user (final reply)
```

Every hop persists its own `RelayTask` with its own state. `ConversationState.tasks[]` gives you the full breakdown:

```json theme={null}
"tasks": [
  { "taskId": "d49a82c0-...", "sinkAgentId": "4a411d26-...", "state": "COMPLETED", "createdAt": "..." },
  { "taskId": "45e57e95-...", "sinkAgentId": "be5c4923-...", "state": "COMPLETED", "createdAt": "..." },
  { "taskId": "d08328ff-...", "sinkAgentId": "be5c4923-...", "state": "COMPLETED", "createdAt": "..." }
]
```

The relay rolls those per-task states up into a single **`aggregateState`** using this precedence:

```
HITL_HELD > WORKING > REJECTED > FAILED > COMPLETED
```

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.

```typescript theme={null}
interface ConversationState {
  contextId: string;
  aggregateState: 'UNKNOWN' | 'WORKING' | 'HITL_HELD' | 'COMPLETED' | 'REJECTED' | 'FAILED' | 'CANCELED';
  latestTask?: {
    status?: { state?: string; message?: { parts?: Array<{ kind?: string; text?: string }> } };
    metadata?: { relay_reason?: string };
  };
  // ... messages[], tasks[], etc.
}

const BASE   = 'https://api.swarmd.ai';
const POLL_INTERVAL_MS = 2_000;
const POLL_DEADLINE_MS = 5 * 60 * 1_000;   // 5 min for non-HITL; extend for HITL bubbles

async function createConversation(channelId: string, agentId: string, token: string) {
  const r = await fetch(`${BASE}/relay/v1/channels/${channelId}/conversations`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ agentId }),
  });
  if (!r.ok) throw new Error(`createConversation failed: ${r.status}`);
  return (await r.json()) as { contextId: string };
}

async function sendMessage(
  channelId: string,
  contextId: string,
  text: string,
  token: string,
): Promise<{ state: ConversationState; pending: boolean }> {
  const r = await fetch(
    `${BASE}/relay/v1/channels/${channelId}/conversations/${encodeURIComponent(contextId)}/messages`,
    {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({
        message: {
          messageId: crypto.randomUUID(),
          role: 'user',
          kind: 'message',
          contextId,
          parts: [{ kind: 'text', text }],
        },
      }),
    },
  );
  // 200 → terminal, 202 → still running (poll). Both bodies are ConversationState.
  const state = (await r.json()) as ConversationState;
  return { state, pending: r.status === 202 };
}

async function pollUntilTerminal(
  channelId: string,
  contextId: string,
  token: string,
): Promise<ConversationState> {
  const deadline = Date.now() + POLL_DEADLINE_MS;
  while (Date.now() < deadline) {
    const r = await fetch(
      `${BASE}/relay/v1/channels/${channelId}/conversations/${encodeURIComponent(contextId)}/state`,
      { headers: { Authorization: `Bearer ${token}` } },
    );
    if (!r.ok) throw new Error(`state poll failed: ${r.status}`);
    const state = (await r.json()) as ConversationState;
    const agg = state.aggregateState;
    if (agg === 'COMPLETED' || agg === 'REJECTED' || agg === 'FAILED' || agg === 'CANCELED') return state;
    await new Promise(res => setTimeout(res, POLL_INTERVAL_MS));
  }
  throw new Error('polling deadline exceeded');
}

// Top-level: mint a conversation once per session; call this per user turn.
export async function sendUserMessage(
  channelId: string, contextId: string, text: string, token: string,
): Promise<string> {
  const { state, pending } = await sendMessage(channelId, contextId, text, token);
  const finalState = pending ? await pollUntilTerminal(channelId, contextId, token) : state;
  return finalState.latestTask?.status?.message?.parts?.[0]?.text ?? '[no reply]';
}
```

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:

```json theme={null}
{
  "aggregateState": "HITL_HELD",
  "latestTask": {
    "status": { "state": "working" },
    "metadata": {
      "relay_reason": "HITL_HELD",
      "policy_name": "high_value_refund",
      "policy_version": "v3",
      "policy_level": "HITL"
    }
  }
}
```

Two flavours you'll see:

| `relay_reason`                   | UI state                                                                        |
| -------------------------------- | ------------------------------------------------------------------------------- |
| `HITL_HELD`                      | Reviewer approval required — show "Awaiting human approval" bubble              |
| `HITL_HELD_AGENT_INPUT_REQUIRED` | Agent asking the caller for input — show "Agent needs your confirmation" bubble |

On resolution the aggregate flips to one of two distinct terminal values:

* **Approved** → `aggregateState === 'COMPLETED'`. `latestTask.status.message.parts[0].text` (or `latestTask.artifacts[0].parts[0].text`) carries the agent's reply.
* **Rejected** → `aggregateState === '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

| Situation                                       | Response                                  | Recommended handling                                                                                                       |
| ----------------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| Bad auth token                                  | `401 Unauthorized`                        | Refresh the token, retry once                                                                                              |
| Wrong tenant / wrong channel for this contextId | `404 Not Found`                           | Do not retry — auth misconfiguration                                                                                       |
| Policy blocked the request at ingress           | `400` or `403` with `POLICY_BLOCKED` body | Surface the policy message to the user; do not retry                                                                       |
| Poll returns 502 / 503                          | Transport hiccup                          | Retry with backoff; abort after \~3 consecutive failures                                                                   |
| Timed out after `POLL_DEADLINE_MS`              | Client-side deadline                      | Show "still processing" to the user; you can resume polling later on the same contextId — the state is durable server-side |

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.

```http theme={null}
POST /relay/v1/channels/{channelId}/conversations/{contextId}/messages

{
  "message": {
    "messageId": "msg-b2c3d4e5",
    "role": "user",
    "kind": "message",
    "contextId": "17aa30cf-...",
    "parts": [{ "kind": "text", "text": "What about the following week?" }]
  }
}
```

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:

| Concept             | JSON-RPC (legacy)                                                         | Conversation REST (this guide)                        |
| ------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------- |
| Session handle      | `contextId` on the result envelope                                        | `contextId` from `POST /conversations`                |
| Send                | `POST … a2a/0.3.0` with `method: "message/send"`                          | `POST /conversations/{ctx}/messages`                  |
| Poll                | `POST … a2a/0.3.0` with `method: "tasks/get"`                             | `GET /conversations/{ctx}/state`                      |
| Task identity       | Single `result.id` per turn                                               | Multi-task per contextId; aggregate + per-task states |
| Long-running signal | `result.status.state == "working"` + `metadata.relay_reason == "TIMEOUT"` | HTTP 202 + `aggregateState == "WORKING"`              |

The legacy path still works and is documented at [HITL Frontend Integration](/tutorials/hitl-frontend-integration). New integrations should prefer [`@swarmd/channel-client`](/sdks/typescript/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`](/sdks/typescript/channel-client) to wrap all of this.

## Next Steps

* [TypeScript Channel Client](/sdks/typescript/channel-client) — use the packaged OAuth, polling, lifecycle-event, and reply-handling implementation.
* [Your First Agent](/tutorials/your-first-agent) — provision a channel or user and mint an access token.
* [Human-in-the-Loop](/tutorials/human-in-the-loop) — background on how HITL policies feed the states above.
* [Monitoring and Audit](/tutorials/monitoring-and-audit) — inspect the per-task audit trail behind `ConversationState.tasks[]`.
