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

# Channel Client

> Add a Swarmd channel to a TypeScript website with automatic OAuth, typed lifecycle events, HITL handling, and conversation continuity.

# TypeScript Channel Client

`@swarmd/channel-client` is the recommended way to connect a website, bot, or backend service to an agent through a Swarmd channel. It wraps the Conversation REST API and handles:

* OAuth2 client-credentials exchange, token caching, and refresh
* deriving `channelId` from the standard `channel-{channelId}` client ID
* conversation creation and follow-up message continuity
* the send fast path and background polling
* typed lifecycle events for `working`, `hitl-held`, and terminal outcomes
* cancellation with `AbortSignal`
* text and artifact reply extraction
* structured authentication, API, and polling errors

<Warning>
  `clientSecret` is a server credential. Use this library in a Node.js server, API route, server action, worker, or other trusted backend. The client refuses to initialize when it detects a browser. Never put the secret in a `NEXT_PUBLIC_*`, `VITE_*`, or other client-exposed environment variable.
</Warning>

## Install

```bash theme={null}
npm install @swarmd/channel-client
```

Node.js 18 or later is supported. The package has no runtime dependencies and uses the built-in Fetch API.

## Before You Start

In the Swarmd dashboard:

1. Create a channel for the website or service.
2. Save the returned `clientId` and `clientSecret`. The secret is shown only when the channel is created.
3. Subscribe the channel to the agent it should invoke.
4. Copy the subscribed agent's `agentId`.

See [Your First Agent](/tutorials/your-first-agent#option-a-create-a-channel-recommended-for-apps) for the API-based setup.

Configure server-only environment variables:

```bash theme={null}
SWARMD_CHANNEL_CLIENT_ID=channel-d4ac7a03-e833-434f-badc-c2833e0b10af
SWARMD_CHANNEL_CLIENT_SECRET=replace-me
SWARMD_AGENT_ID=4a411d26-dec0-4553-a164-96ccecf0ecb9
```

The client derives `channelId` from `clientId`. If your OAuth client does not use the standard `channel-{channelId}` form, also pass the channel UUID as `channelId`.

## Quick Start

Create one reusable client on the server:

```typescript theme={null}
// lib/swarmd.ts — server-only module
import { SwarmdChannelClient } from '@swarmd/channel-client';

export const swarmd = new SwarmdChannelClient({
  clientId: process.env.SWARMD_CHANNEL_CLIENT_ID!,
  clientSecret: process.env.SWARMD_CHANNEL_CLIENT_SECRET!,
});
```

Start a conversation and wait for the first reply:

```typescript theme={null}
import { extractReply } from '@swarmd/channel-client';
import { swarmd } from './lib/swarmd';

const state = await swarmd.startAndSend(
  process.env.SWARMD_AGENT_ID!,
  'Shortlist the top three candidates for JR-1007.',
);

console.log(state.contextId);       // persist for follow-up turns
console.log(state.aggregateState); // COMPLETED, REJECTED, FAILED, or CANCELED
console.log(extractReply(state));   // agent prose, including artifact output
```

`startAndSend` creates a conversation, sends the message, and polls if the relay has not completed during its early-return window.

## Complete Next.js Example

This example starts with a standard TypeScript Next.js application and ends with a browser chat connected to a Swarmd channel.

### 1. Create the project

```bash theme={null}
npx create-next-app@latest swarmd-chat --typescript --app
cd swarmd-chat
npm install @swarmd/channel-client
```

The example uses this structure:

```text theme={null}
swarmd-chat/
├── app/
│   ├── api/chat/route.ts
│   └── page.tsx
├── lib/
│   └── swarmd.ts
└── .env.local
```

### 2. Connect the channel

Create a channel in Swarmd, subscribe it to the agent your website should invoke, and copy:

* the channel `clientId`
* the channel `clientSecret`
* the subscribed agent's `agentId`

Add them to `.env.local`. These variables deliberately have no `NEXT_PUBLIC_` prefix, so Next.js keeps them on the server:

```bash theme={null}
SWARMD_CHANNEL_CLIENT_ID=channel-d4ac7a03-e833-434f-badc-c2833e0b10af
SWARMD_CHANNEL_CLIENT_SECRET=replace-me
SWARMD_AGENT_ID=4a411d26-dec0-4553-a164-96ccecf0ecb9
```

### 3. Create the server-side client

```typescript theme={null}
// lib/swarmd.ts
import 'server-only';
import { SwarmdChannelClient } from '@swarmd/channel-client';

function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`${name} is required`);
  return value;
}

export const swarmd = new SwarmdChannelClient({
  clientId: required('SWARMD_CHANNEL_CLIENT_ID'),
  clientSecret: required('SWARMD_CHANNEL_CLIENT_SECRET'),
});

export const swarmdAgentId = required('SWARMD_AGENT_ID');
```

Exporting one client lets every request share the OAuth token cache.

### 4. Add the server route

The browser sends only the user's message and its current `contextId`. The route creates the conversation on the first turn and reuses it on later turns.

```typescript theme={null}
// app/api/chat/route.ts
import { extractReply, SwarmdApiError } from '@swarmd/channel-client';
import { NextResponse } from 'next/server';
import { swarmd, swarmdAgentId } from '@/lib/swarmd';

export const runtime = 'nodejs';

export async function POST(request: Request) {
  const body = await request.json() as {
    message?: string;
    contextId?: string;
  };
  const message = body.message?.trim();

  if (!message) {
    return NextResponse.json(
      { error: 'message is required' },
      { status: 400 },
    );
  }

  try {
    const contextId = body.contextId ?? (
      await swarmd.createConversation(swarmdAgentId, {
        signal: request.signal,
      })
    ).contextId;

    const state = await swarmd.sendAndWait(contextId, message, {
      signal: request.signal,
      timeoutMs: 5 * 60_000,
    });

    return NextResponse.json({
      contextId,
      aggregateState: state.aggregateState,
      reply: extractReply(state),
      relayReason: state.latestTask?.metadata?.relay_reason,
    });
  } catch (error) {
    if (error instanceof SwarmdApiError) {
      return NextResponse.json(
        {
          error: 'Swarmd rejected the request',
          status: error.status,
          details: error.body,
        },
        { status: error.status },
      );
    }
    throw error;
  }
}
```

### 5. Call the route from the browser

```tsx theme={null}
// app/page.tsx
'use client';

import { FormEvent, useState } from 'react';

interface ChatReply {
  contextId: string;
  aggregateState: string;
  reply?: string;
  relayReason?: string;
}

export default function Home() {
  const [contextId, setContextId] = useState<string>();
  const [message, setMessage] = useState('');
  const [reply, setReply] = useState('');
  const [status, setStatus] = useState('Ready');

  async function send(event: FormEvent) {
    event.preventDefault();
    setStatus('Agent is working');

    const response = await fetch('/api/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message, contextId }),
    });
    const result = await response.json() as ChatReply & { error?: string };

    if (!response.ok) {
      setStatus(result.error ?? 'Request failed');
      return;
    }

    setContextId(result.contextId);
    setReply(result.reply ?? 'No text reply');
    setStatus(result.aggregateState);
    setMessage('');
  }

  return (
    <main>
      <form onSubmit={send}>
        <input
          value={message}
          onChange={event => setMessage(event.target.value)}
          placeholder="Ask the agent..."
        />
        <button disabled={!message.trim()}>Send</button>
      </form>

      <p>Status: {status}</p>
      {reply && <p>{reply}</p>}
    </main>
  );
}
```

Run the project:

```bash theme={null}
npm run dev
```

The first message creates a durable conversation. The response returns its `contextId`, and the component includes that ID in subsequent messages so the agent retains the conversation history.

<Info>
  This compact example keeps the `/api/chat` request open while the SDK polls. That is suitable for ordinary flows. If an approval may take minutes or hours, return the initial `sendMessage` state to the browser and expose a separate state endpoint, as shown in the split send-and-poll flow below.
</Info>

## Handle Lifecycle Events

For most integrations, `onEvent` is the idiomatic way to react to progress. Events are a discriminated union, so TypeScript narrows the available fields inside each `case`.

```typescript theme={null}
import type { ConversationEvent } from '@swarmd/channel-client';

function handleEvent(event: ConversationEvent) {
  switch (event.type) {
    case 'working':
      console.log('The agent is working');
      break;

    case 'hitl-held':
      console.log('Waiting for human review:', event.reason);
      break;

    case 'completed':
      console.log('Agent reply:', event.reply);
      break;

    case 'rejected':
      console.log('The reviewer rejected the action:', event.reason);
      break;

    case 'failed':
      console.error('The agent flow failed:', event.reason);
      break;

    case 'canceled':
      console.log('The conversation was canceled');
      break;

    default: {
      // Makes this switch fail type-checking if a future SDK version adds
      // an event and the application has not handled it.
      const exhaustive: never = event;
      return exhaustive;
    }
  }
}

const finalState = await swarmd.sendAndWait(contextId, message, {
  onEvent: handleEvent,
});
```

The callback fires for the initial send response and whenever the lifecycle event changes. Repeated polls in the same state are de-duplicated. Applications do not need to interpret HTTP `200` versus `202`, inspect task status, or decide when polling should stop. Use the lower-level `onState` callback if you need every state snapshot instead.

| Event       | Useful fields        | Meaning                                        |
| ----------- | -------------------- | ---------------------------------------------- |
| `working`   | `state`, `contextId` | The conversation is starting or executing      |
| `hitl-held` | `reason`, `state`    | A reviewer or caller must provide input        |
| `completed` | `reply`, `state`     | The flow completed and the reply was extracted |
| `rejected`  | `reason`, `state`    | A reviewer declined the held action            |
| `failed`    | `reason`, `state`    | A policy or upstream error ended the flow      |
| `canceled`  | `state`              | The caller canceled the flow                   |

Every event includes the original `ConversationState` as `event.state`, so uncommon metadata and per-task progress remain available without weakening the common API.

### Async event stream

Use `sendEvents` when events fit more naturally into an async pipeline or an SSE/WebSocket handler:

```typescript theme={null}
for await (const event of swarmd.sendEvents(contextId, message, {
  pollIntervalMs: 2_000,
  timeoutMs: 30 * 60_000,
})) {
  switch (event.type) {
    case 'working':
    case 'hitl-held':
      await publishProgressToBrowser(event);
      break;
    case 'completed':
      await publishReplyToBrowser(event.reply);
      break;
    case 'rejected':
    case 'failed':
    case 'canceled':
      await publishTerminalOutcomeToBrowser(event);
      break;
  }
}
```

The iterable ends automatically after a terminal event. `watchConversation(contextId)` provides the same event stream without sending a new message, which is useful when resuming a persisted conversation.

<Info>
  The package deliberately does not expose a React hook that talks directly to Swarmd: doing so would place the channel secret in the browser bundle. A React application should consume its own API route, SSE stream, or WebSocket and map these server-side lifecycle events into component state.
</Info>

## Split Send and Poll Flow

Use the low-level methods when your frontend needs to show `WORKING` or `HITL_HELD` immediately.

```typescript theme={null}
const created = await swarmd.createConversation(agentId);

const { state, pending } = await swarmd.sendMessage(
  created.contextId,
  'Apply the shortlist decision.',
);

if (!pending) {
  // state is already terminal
  return state;
}

const final = await swarmd.waitForTerminal(created.contextId, {
  pollIntervalMs: 2_000,
  maxPollIntervalMs: 10_000,
  timeoutMs: 30 * 60_000,
  onState(state) {
    if (state.aggregateState === 'HITL_HELD') {
      // Notify your UI, log progress, or persist resumable state.
      console.log(state.latestTask?.metadata?.relay_reason);
    }
  },
});
```

`sendMessage` uses `aggregateState`, not only the HTTP status, to decide whether work is pending. `waitForTerminal` stops on `COMPLETED`, `REJECTED`, `FAILED`, or `CANCELED`.

## Handling UI States

| `aggregateState` | Terminal | Suggested UI                                  |
| ---------------- | -------- | --------------------------------------------- |
| `UNKNOWN`        | No       | Starting                                      |
| `WORKING`        | No       | Agent is working                              |
| `HITL_HELD`      | No       | Waiting for human review                      |
| `COMPLETED`      | Yes      | Render `extractReply(state)`                  |
| `REJECTED`       | Yes      | Review was declined                           |
| `FAILED`         | Yes      | Request failed; inspect `latestTask.metadata` |
| `CANCELED`       | Yes      | Request was canceled                          |

For `HITL_HELD`, inspect `state.latestTask?.metadata?.relay_reason`:

* `HITL_HELD` means a policy or action needs reviewer approval.
* `HITL_HELD_AGENT_INPUT_REQUIRED` means the agent requested human input.

Approval happens in Swarmd or through the approval API. The channel client continues polling and observes the result. Approval normally leads to `COMPLETED`; rejection leads to `REJECTED`.

## Conversation Continuity

Create a conversation once per chat session and reuse its `contextId`:

```typescript theme={null}
const conversation = await swarmd.createConversation(agentId);

await swarmd.sendAndWait(conversation.contextId, 'Show open roles.');
await swarmd.sendAndWait(conversation.contextId, 'Only roles in London.');
```

The conversation remains durable on the relay. Store the `contextId` if polling must resume after a tab closes or a process restarts:

```typescript theme={null}
const state = await swarmd.getConversationState(savedContextId);
const final = state.aggregateState === 'WORKING'
  ? await swarmd.waitForTerminal(savedContextId)
  : state;
```

For long histories:

```typescript theme={null}
const page = await swarmd.listMessages(contextId, { page: 0, size: 50 });
```

## Custom Messages and Cancellation

Send structured A2A parts instead of plain text:

```typescript theme={null}
await swarmd.sendMessage(contextId, '', {
  parts: [
    { kind: 'text', text: 'Review this application' },
    { kind: 'data', data: { applicationId: 'APP-001' } },
  ],
});
```

Cancel local waiting with an `AbortSignal`:

```typescript theme={null}
const controller = new AbortController();

const result = swarmd.waitForTerminal(contextId, {
  signal: controller.signal,
});

controller.abort(new Error('User left the page'));
await result;
```

This stops the local request or poll loop; it does not cancel server-side agent work.

## Errors

```typescript theme={null}
import {
  SwarmdApiError,
  SwarmdAuthenticationError,
  SwarmdPollingTimeoutError,
} from '@swarmd/channel-client';

try {
  await swarmd.sendAndWait(contextId, message);
} catch (error) {
  if (error instanceof SwarmdAuthenticationError) {
    // Invalid client ID/secret or token endpoint configuration.
  } else if (error instanceof SwarmdPollingTimeoutError) {
    // Work is durable. Save error.contextId and resume polling later.
  } else if (error instanceof SwarmdApiError) {
    console.error(error.status, error.body);
    // A 400/403 body can include relay_reason: POLICY_BLOCKED.
  }
}
```

The client caches OAuth tokens until 30 seconds before expiry. If an API request returns `401`, it clears the cached token, obtains a fresh token, and retries that request once.

## Configuration

```typescript theme={null}
const swarmd = new SwarmdChannelClient({
  clientId: 'channel-d4ac7a03-e833-434f-badc-c2833e0b10af',
  clientSecret: '...',

  // Optional:
  channelId: 'd4ac7a03-e833-434f-badc-c2833e0b10af',
  baseUrl: 'https://api.swarmd.ai',
  tokenUrl: 'https://auth.swarmd.ai/realms/swarmd/protocol/openid-connect/token',
  scope: 'mcp:call',
});
```

Only override URLs or scope for a Swarmd environment whose operator supplied different values.

## Method Reference

| Method                                   | Purpose                                       |
| ---------------------------------------- | --------------------------------------------- |
| `createConversation(agentId)`            | Start a durable conversation                  |
| `sendMessage(contextId, text, options?)` | Send one turn; return `{ state, pending }`    |
| `getConversationState(contextId)`        | Read the current state once                   |
| `waitForTerminal(contextId, options?)`   | Poll until a terminal aggregate               |
| `sendAndWait(contextId, text, options?)` | Send a turn and poll when needed              |
| `startAndSend(agentId, text, options?)`  | Create, send, and poll in one call            |
| `sendEvents(contextId, text, options?)`  | Send and stream typed lifecycle events        |
| `watchConversation(contextId, options?)` | Stream events for existing work               |
| `listMessages(contextId, options?)`      | Read paginated history                        |
| `clearToken()`                           | Force a fresh OAuth token on the next request |

## Legacy JSON-RPC Integrations

The Hotels demo originally implemented `message/send` and `tasks/get` directly over the channel JSON-RPC endpoint. It now consumes this SDK through the repository-local dependency `file:../../sdks/typescript/packages/channel-client`, so its deployed container continuously validates the same Conversation REST implementation customers receive. The Prismforce integration established the newer model: one stable `contextId`, aggregate state, and a state poll endpoint.

`@swarmd/channel-client` follows that model. Existing JSON-RPC integrations can remain in place, but new website integrations should use this library. See [Frontend Integration (JSON-RPC — legacy)](/tutorials/hitl-frontend-integration) when maintaining an older integration.
