arizuko

arizukocomponents › channel adapters

channel adapters

What they are

In plain terms, a channel adapter is a translator between one chat app and arizuko. Each app — Telegram, Slack, WhatsApp — has its own way of talking; the adapter turns that into the one format arizuko understands, and turns arizuko’s replies back into something the app can post.

A channel adapter is a small daemon that speaks one upstream platform’s protocol on one side and arizuko’s HTTP contract on the other. Telegram on the left, routd on the right.

Inbound: the adapter receives a message from the platform, signs the request with CHANNEL_SECRET, and POSTs it to routd at /v1/messages. Outbound: routd calls the adapter back at POST /send with the agent’s reply, and the adapter pushes it to the platform.

One adapter per platform. One container per adapter. Each runs on LISTEN_ADDR=:8080 inside its container; Docker networking keeps the ports collision-free.

Why they exist

Every platform has its own protocol — Telegram long-polls, Discord holds a websocket, Mastodon streams, email speaks IMAP. Putting any of that into routd would bind the message loop to one platform’s quirks. The adapter absorbs the quirks so routd only ever sees a signed POST /v1/messages. Add a platform by adding an adapter; routd doesn’t change.

The shared shape

Every adapter binds to the same Go interface in core/types.go:

Every adapter serves the same HTTP endpoints (mounted by chanlib):

How they fit

platform (Slack, Telegram, …)
    |  platform-native protocol
    v
  <channel adapter>   (LISTEN_ADDR=:8080, own container)
    |  POST /v1/messages    (signed by CHANNEL_SECRET)
    v
  routd
    |  POST /send            (callback to adapter)
    v
  <channel adapter>
    |  platform-native protocol
    v
  platform

The trust contract is symmetric: routd verifies inbound HMACs, the adapter verifies outbound HMACs. Neither trusts a request without a valid CHANNEL_SECRET signature. See auth.

The adapters

nameplatformlanguagenotes
slakdSlackGoAssistant pane — suggested prompts and conversation title; reactions map to likes.
teledTelegramGoLong-poll bot API, file uploads, native voice messages.
discdDiscordGoGateway over websocket, ActionRow buttons, channel threads.
mastdMastodonGoActivityPub via API token, replies and boosts.
bskydBlueskyGoAT Protocol, post threading.
reditdRedditGoComments and posts; native dislike via downvote (the one platform with a true downvote).
emaidemailGoIMAP receive, SMTP send, threading by Message-ID.
linkdLinkedInGoMessaging API, connection-scoped DMs.
whapdWhatsAppTypeScriptBaileys client, QR pairing, voice notes.
twitdX / TwitterTypeScriptMentions and DMs.

An adapter without a platform verb returns chanlib.ErrUnsupported rather than failing quietly. The agent reads that response and picks another action instead of dropping the call.

Per-platform quirks stay inside the adapter. Slack threads, Discord ActionRows, WhatsApp QR pairing, IMAP folder watching — none of it leaks into routd. The DB sees a uniform messages row no matter where the message came from.

Output rendering follows the same one-renderer rule. The agent picks a Claude Code output style per channel: slack emits Slack mrkdwn (*bold*, _italic_, <url|text>); other channels use CommonMark. The runner selects the style when it spawns the container (container/runner.go) so the adapter receives text already shaped for the channel.

Language choice follows the upstream SDK. Most platforms have a maintained Go client, so the adapter is Go. WhatsApp and X have their best-maintained clients in TypeScript (Baileys, twitter-api-v2), so those adapters run on Node. The HTTP contract is identical either way.

Adding a new adapter

Two artifacts, no edits to proxyd or compose:

  1. Write the daemon. Implement Channel; implement Socializer for the verbs the platform exposes. Serve the standard HTTP endpoints on LISTEN_ADDR=:8080.
  2. Drop a template/services/<daemon>.toml with the compose env block and a [[proxyd_route]] entry. The next arizuko run picks it up.

Spec: specs/5/35-proxyd-standalone.md. Extension points: EXTENDING.md.

Asymmetric channels

An inbound channel and an outbound channel do not have to be the same daemon. A message can arrive on email and the reply can go out on Slack — the route table decides. See howto/asymmetric-channels.

Go deeper