docspluginstransportscreating a transport

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.

defineTransport
export const discord = defineTransport<DiscordConfig>({
  name: 'discord',
  addressPrefix: 'dc',
  configSchema: DiscordConfigSchema,
  admin: { /* form metadata, see below */ },
  create: (ctx, config) => new DiscordTransport(ctx, config),
});
FieldTypeDescription
namestringUnique registry key, and the routes.json key your slice lives under.
addressPrefixstringParticipant-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.
configSchemaZodType<TConfig>Validates your routes.json slice — typically an array of entries.
create(ctx, config) => Transport | nullFactory: resolve addresses, build your instance, return it (or null).
adminTransportAdminDescriptorMetadata 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:

MemberDescription
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).
logPino-compatible child logger scoped to your transport.

Transport

The instance your factory returns implements this interface.

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

PacketHandling
conversationA durable message — render it in your native API.
approval_requestRender an approve/reject affordance carrying the agent address and approval id.
approval_ackSettle the original approval message.
previewAn incremental streaming frame — drives edit-in-place (see below).
delegateNever 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:

  1. First preview frame for a new streamId — post a message and remember its id against that streamId.
  2. Later frames with the same streamId — edit that message in place with the new text.
  3. The conversation packet arrives carrying the same streamId — fold its final text into the same message (one last edit) instead of posting fresh, and forget the streamId.

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.

PropertyTypeDescription
displayLabelstringLabel shown in the dashboard.
fieldsAdminField[]The form inputs (below).
summarize(entry) => stringProjects one entry into the table's details column.
setupInstructions?stringMarkdown shown in a "how to get these credentials" disclosure.

Each AdminField:

PropertyTypeDescription
keystringForm field key.
type'text' | 'password' | 'number'Input type.
labelstringField label.
secret?booleanMask on read; resolve the mask against the on-disk value on write.
optional?booleanField may be left blank.
group?stringVisual grouping in the form.
path?stringDotted path for nested config (e.g. imap.host).

Registering

Three steps wire a new transport — say a Discord bridge — into the server:

  1. Define it in packages/cast/src/transports/discord.ts, exporting a defineTransport() value.
  2. Import it into the server entry and call registerTransport().
  3. Add a discord slice to routes.json with 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.