arizuko › components › 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:
Channel—Name,Connect,Send,SendFile,SendVoice,Owns,Typing,Disconnect. The minimum every adapter implements.Post,Like,Dislike,Forward,Quote,Repost,Edit,Delete,Pin,Unpin— the social half ofBotHandler. Implemented where the platform has the verb; an adapter without them embedschanlib.NoSocialand each returnschanlib.ErrUnsupported.HistoryFetcher— optional. Pull older messages from the platform API when the agent asks for context.
Every adapter serves the same HTTP endpoints (mounted by chanlib):
GET /health— 200 when connected to the platform, 503 when the link is down (QR unscanned, stream dropped, token expired).POST /send,/send-file,/send-voice,/typing—routdhands the adapter an outbound row to deliver.POST /post,/like,/dislike,/forward,/quote,/repost,/edit,/delete,/pin,/unpin— the social verbs, one route each.GET /v1/history— mounted only when the adapter implementsHistoryFetcher.
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
| name | platform | language | notes |
|---|---|---|---|
| slakd | Slack | Go | Assistant pane — suggested prompts and conversation title; reactions map to likes. |
| teled | Telegram | Go | Long-poll bot API, file uploads, native voice messages. |
| discd | Discord | Go | Gateway over websocket, ActionRow buttons, channel threads. |
| bskyd | Bluesky | Go | AT Protocol, post threading. |
| reditd | Go | Comments and posts; native dislike via downvote (the one platform with a true downvote). | |
| emaid | Go | IMAP receive, SMTP send, threading by Message-ID. | |
| linkd | Go | Messaging API, connection-scoped DMs. | |
| whapd | TypeScript | Baileys client, QR pairing, voice notes. | |
| twitd | X / Twitter | TypeScript | Mentions 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:
- Write the daemon. Implement
chanlib.BotHandler, embeddingNoSocialandNoVoiceSenderfor what the platform does not have. Serve the standard HTTP endpoints onLISTEN_ADDR=:8080. - Drop a
template/services/<daemon>.yml— a partial compose file dockerincludes verbatim — with the service's env block. If the adapter takes inbound web traffic, add a sibling<daemon>.yaml— its config manifest, with aproxyd_routesblock. The nextarizuko runpicks 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
chanlib/handler.go—BotHandler, the fourteen verbs and their request types.chanlib/andchanreg/— shared HTTP plumbing every adapter mounts.- concepts/routing — routd, the other end of every adapter’s HTTP calls, and how an inbound message picks its group and reply channel.