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 and POSTs it to routd at /v1/messages, bearing the service:<daemon> ES256 token it exchanged AUTHD_SERVICE_KEY for. 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, Slack posts events, 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 an authenticated 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):

Delivering exactly once

routd writes an outbound row pending, hands it to the adapter, and marks it sent when the adapter answers. If that answer never arrives the row stays pending and a 30-second sweep hands it over again — bounded by MAX_OUTBOUND_RETRY, then dead-lettered. So an adapter sees the same message more than once by design, and it must not post it twice.

Each send carries an idempotency_key: the outbound row’s stable id. Before calling the platform the adapter claims that key in its own table, and settles the claim with the platform’s message id once the post lands. A repeat of a settled key returns the recorded id and calls nothing.

The hard case is a claim that never settled — the adapter called the platform and never learned the outcome. It cannot tell a lost answer from a lost request, and Slack’s chat.postMessage offers no key of its own, so either guess is wrong: post again and the user sees it twice, skip and the message is gone. The adapter refuses instead. routd exhausts its retries and dead-letters the row, which puts it in front of an operator. Visible beats doubled.

The same reasoning applies one layer down. An adapter retries a platform call that failed with 429 — a refusal, with a Retry-After telling it when — but never retries a create call that failed with a 5xx or a dropped connection, because those may have created the message already.

How they fit

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

The trust contract is symmetric, and both directions are ES256 bearers verified against authd’s JWKS — no shared HMAC secret. Inbound, routd requires a service: subject; outbound, the adapter’s chanlib gate requires the subject to be exactly service:routd. Each token is exchanged from AUTHD_SERVICE_KEY and rotates roughly hourly. 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.
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 chanlib.BotHandler, embedding NoSocial and NoVoiceSender for what the platform does not have. Serve the standard HTTP endpoints on LISTEN_ADDR=:8080.
  2. Drop a template/services/<daemon>.yml — a partial compose file docker includes verbatim — with the service's env block. If the adapter takes inbound web traffic, add a sibling <daemon>.yaml — its config manifest, with a proxyd_routes block. The next arizuko run picks up both.

Spec: specs/5/7-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