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
channelIdfrom the standardchannel-{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
Install
Before You Start
In the Swarmd dashboard:- Create a channel for the website or service.
- Save the returned
clientIdandclientSecret. The secret is shown only when the channel is created. - Subscribe the channel to the agent it should invoke.
- Copy the subscribed agent’s
agentId.
POST /registry/v1/channels and POST /registry/v1/channels/{channelId}/subscriptions — see the API reference.
Configure server-only environment variables:
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: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
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
.env.local. These variables deliberately have no NEXT_PUBLIC_ prefix, so Next.js keeps them on the server:
3. Create the server-side client
4. Add the server route
The browser sends only the user’s message and its currentcontextId. The route creates the conversation on the first turn and reuses it on later turns.
5. Call the route from the browser
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.
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
UsesendEvents when events fit more naturally into an async pipeline or an SSE/WebSocket handler:
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 whypending 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.ConversationState in both cases — one shape, no
polymorphism. Your code reads aggregateState rather than branching on the
HTTP status.
Why a stable contextId rather than a long-lived call
Why a stable contextId rather than a long-lived call
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 showWORKING 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.HITL_HELD, inspect state.latestTask?.metadata?.relay_reason:
HITL_HELDmeans a policy or action needs reviewer approval.HITL_HELD_AGENT_INPUT_REQUIREDmeans the agent requested human input.
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 itscontextId:
contextId if polling must resume after a tab closes or a process restarts:
Custom Messages and Cancellation
Send structured A2A parts instead of plain text:AbortSignal:
Errors
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
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.
