Creating a transport
A transport wraps an external messaging system — Telegram, Slack, a custom bridge — and translates between its native message shape and Cast's packet model.
defineTransport
A transport is a value returned by defineTransport(). The factory returns your runtime instance, or null if no routes resolved to an agent.
export const discord = defineTransport<DiscordConfig>({
name: 'discord',
addressPrefix: 'dc',
configSchema: DiscordConfigSchema,
admin: { /* form metadata, see below */ },
create: (ctx, config) => new DiscordTransport(ctx, config),
});| Field | Type | Description |
|---|---|---|
| name | string | Unique registry key, and the routes.json key your slice lives under. |
| addressPrefix | string | Participant-address namespace you own (Telegram tg, Slack slack). Often differs from name, and can't collide with another transport or one of the reserved system prefixes: u, a, ext, cast, local, cli, web, admin, console. |
| configSchema | ZodType<TConfig> | Validates your routes.json slice — typically an array of entries. |
| create | (ctx, config) => Transport | null | Factory: resolve addresses, build your instance, return it (or null). |
| admin | TransportAdminDescriptor | Metadata that renders your dashboard form, registry-driven. |
Config schema & routes.json
configSchema validates the array under your transport's key in routes.json. Each entry carries an address (the agent it binds to) plus whatever credentials and options your transport needs — there is no separate secrets schema, credentials are fields in the same entry. In create(), canonicalize each address through ctx.resolveAddress(), skip entries that don't resolve, and return null if none do.
TransportContext
ctx — the first argument to your create(ctx, config) factory, above — gives your transport a small, fixed set of server capabilities to call:
| Member | Description |
|---|---|
| ingestInbound(from, to, text, senderName, routing?, attachments?) | Forward an inbound message into the gateway — runs identity, system commands, dispatch. |
| ingestApprovalResponse(from, to, response) | Forward an approve/reject for an outstanding prompt. |
| resolveAddress(address) | Canonicalize a route's address label to a bus address. |
| listSystemCommands() | The server's / commands (Telegram publishes these as its menu). |
| log | Pino-compatible child logger scoped to your transport. |
Transport
The instance your factory returns implements this interface.
| Member | Description |
|---|---|
| connect() · disconnect() · isConnected() | Establish and tear down the inbound channel. |
| send(pkt, ctx) | Deliver an outbound packet to a participant. |
| sendEvent(evt) | Render an ephemeral event (typing, lifecycle); ignore unsupported ones. |
| ownsParticipant(address) | Whether an address belongs to you — the gateway uses it to route outbound packets. |
| deferredAck? | Set when send() succeeding doesn't imply durable receipt (cache-like clients); the gateway then waits for the transport to mark delivery. |
Inbound: native → packet
When a native message arrives, extract the sender and target, download any attachments (gate on the max attachment size), and call ctx.ingestInbound(...). Bursty sources benefit from a short debounce that merges rapid messages into one turn — Telegram coalesces at one second, Slack at 800ms.
Outbound: packet → native
send() is called once per outbound packet. Render each deliverable type into your native API.
| Packet | Handling |
|---|---|
| conversation | A durable message — render it in your native API. |
| approval_request | Render an approve/reject affordance carrying the agent address and approval id. |
| approval_ack | Settle the original approval message. |
| preview | An incremental streaming frame — drives edit-in-place (see below). |
| delegate | Never reaches a transport — bus-internal. |
Streaming output rides the same send() path — no extra method. The agent's response arrives as a run of preview packets, and you assemble a live, edit-in-place message by correlating them with the final durable conversation packet on a shared streamId:
- First
previewframe for a newstreamId— post a message and remember its id against thatstreamId. - Later frames with the same
streamId— edit that message in place with the new text. - The
conversationpacket arrives carrying the samestreamId— fold its final text into the same message (one last edit) instead of posting fresh, and forget thestreamId.
If the route sets streaming: false, drop preview frames at the gate; the conversation packet then posts a single sealed message on its own. Since previews are never persisted, dropping them is always safe.
Events & approvals
sendEvent(evt) delivers ephemeral signals — typing, lifecycle notices. Render the variants your medium supports and drop the rest; a common pattern gates lifecycle messages on whether the user was recently active, so a cold channel doesn't get "waking up…" noise.
Approvals are a round trip: an approval_request arrives via send(), the user taps approve/reject, you call ctx.ingestApprovalResponse(...), and an approval_ack comes back so you can settle the original message.
TransportAdminDescriptor
The admin page is registry-driven: declare this descriptor and your form renders with no edits to the admin router or web UI.
| Property | Type | Description |
|---|---|---|
| displayLabel | string | Label shown in the dashboard. |
| fields | AdminField[] | The form inputs (below). |
| summarize | (entry) => string | Projects one entry into the table's details column. |
| setupInstructions? | string | Markdown shown in a "how to get these credentials" disclosure. |
Each AdminField:
| Property | Type | Description |
|---|---|---|
| key | string | Form field key. |
| type | 'text' | 'password' | 'number' | Input type. |
| label | string | Field label. |
| secret? | boolean | Mask on read; resolve the mask against the on-disk value on write. |
| optional? | boolean | Field may be left blank. |
| group? | string | Visual grouping in the form. |
| path? | string | Dotted path for nested config (e.g. imap.host). |
Registering
Three steps wire a new transport — say a Discord bridge — into the server:
- Define it in
packages/cast/src/transports/discord.ts, exporting adefineTransport()value. - Import it into the server entry and call
registerTransport(). - Add a
discordslice toroutes.jsonwith an entry per agent.
// packages/cast/src/index.ts
import { registerTransport } from './transports/registry.js';
import { discord } from './transports/discord.js';
registerTransport(discord);
// routes.json
{
"discord": [
{
"address": "assistant",
"botToken": "..."
}
]
}Registration is fail-fast — a duplicate name or a reserved or colliding addressPrefix throws before the server starts. Once registered, your transport loads from routes.json automatically and participates in hot-reload like the bundled ones. See Transports for the operator-facing config model.