Skip to main content

TypeScript Channel Client

@swarmd.ai/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
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.

Install

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.
Prefer to script it? The same four steps are POST /registry/v1/channels and POST /registry/v1/channels/{channelId}/subscriptions — see the API reference. Configure server-only environment variables:
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:
Start a conversation and wait for the first reply:
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

The example uses this structure:

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:

3. Create the server-side client

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.

5. Call the route from the browser

Run the project:
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.
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.

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

What the SDK is doing for you

Worth understanding once, because it explains why pending and waitForTerminal exist at all. A conversation send is not a request that blocks until the agent answers. The relay runs the chain on a background thread and hands your socket back early: The window is swarmd.agent-relay.early-return-timeout-ms, 100 seconds by default — chosen to match Cloudflare’s origin cutoff, since a browser’s call cannot outlive it anyway. Typical cross-tenant chains (a couple of LLM turns plus MCP calls) land in the 40–60 second range, so most sends come back 200 and never touch the polling path.
Build for 202 regardless. It is the same body shape, and a slow sink is the case you do not control. sendAndWait handles both for you; the split methods below let you show WORKING in the UI while it happens.
The response body is a ConversationState in both cases — one shape, no polymorphism. Your code reads aggregateState rather than branching on the HTTP status.
The older JSON-RPC path held one HTTP call open for an entire run. That breaks on everything real: CDN idle cutoffs, mobile network churn, a browser suspending a background tab.The conversation model replaces it with one stable contextId and a state URL you can poll from anywhere, any number of times. A dropped socket costs you nothing — the work continues server-side and the next poll picks it up. That is also why SwarmdPollingTimeoutError carries contextId: the work is durable, so you can resume polling later rather than losing the turn.

Split Send and Poll Flow

Use the low-level methods when your frontend needs to show WORKING or HITL_HELD immediately.
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

CANCELED is in the SDK’s terminal set for safety, but the relay’s rollup does not currently emit it — a cancelled task is folded into the aggregate as settled. In practice a turn ends on COMPLETED, REJECTED, or FAILED.
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 the Swarmd dashboard, or through the approvals API with a user token — GET /relay/v1/approvals to list pending requests, GET /relay/v1/approvals/{id} to read one, and POST /relay/v1/approvals/{id}/resolve with { "action": "APPROVED" | "REJECTED", "message": "..." } to settle it. A channel token cannot resolve an approval; the endpoints require HITL_REQUESTS:READ / HITL_REQUESTS:WRITE. The channel client keeps polling either way 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:
The conversation remains durable on the relay. Store the contextId if polling must resume after a tab closes or a process restarts:
For long histories:

Custom Messages and Cancellation

Send structured A2A parts instead of plain text:
Cancel local waiting with an AbortSignal:
This stops the local request or poll loop; it does not cancel server-side agent work.

Errors

Every error extends SwarmdError. SwarmdConfigurationError is thrown up front for bad construction — a missing clientId or clientSecret, a non-standard clientId with no channelId alongside it, no Fetch implementation, or a browser environment — so it will not appear inside a send. SwarmdApiError carries status, method, path, and the parsed body; SwarmdAuthenticationError extends it and is what a 401 becomes after the retry below fails.
A policy block does not surface as an exception. The relay absorbs it into the conversation state, so the call resolves with aggregateState: 'FAILED' and state.latestTask?.metadata?.relay_reason === 'POLICY_BLOCKED'. Check the terminal state, not just the catch.

Reading relay_reason

On a FAILED (or REJECTED) terminal state, latestTask.metadata.relay_reason says why: Surface a generic message to end users and log the reason — most of these are operator problems, not something the person typing can fix. 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

Only override URLs or scope for a Swarmd environment whose operator supplied different values, and always override baseUrl and tokenUrl together — a base URL and token URL from different environments mints tokens the gateway rejects on audience. fetch exists so you can inject an instrumented or proxied implementation; dangerouslyAllowBrowser lifts the browser guard and should stay false outside a controlled runtime that merely exposes browser globals. Polling defaults, applied by waitForTerminal, sendAndWait, sendEvents, and watchConversation: Raise maxPollIntervalMs above pollIntervalMs to get exponential backoff — the interval grows by 1.5x per poll up to that ceiling. Raise timeoutMs for HITL flows, where an approval can take hours.

Method Reference