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, 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:
Channel—Name,Connect,Send,SendFile,SendVoice,Owns,Typing,Disconnect. The minimum every adapter implements.Socializer—Post,Like,Dislike,Forward,Quote,Repost,Edit,Delete,Pin,Unpin. Implemented where the platform has the verb; sentinel error otherwise.Suggester/Namer— suggested-prompt buttons and the conversation title. Implemented today by slakd; others can opt in.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— theSocializerverbs, one route each.GET /v1/history— mounted only when the adapter implementsHistoryFetcher.
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
| 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. |
| mastd | Mastodon | Go | ActivityPub via API token, replies and boosts. |
| 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
Channel; implementSocializerfor the verbs the platform exposes. Serve the standard HTTP endpoints onLISTEN_ADDR=:8080. - Drop a
template/services/<daemon>.tomlwith the compose env block and a[[proxyd_route]]entry. The nextarizuko runpicks 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
core/types.go—Channel,Socializer,Suggester,Namer,HistoryFetcherinterfaces.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.