arizuko › components › channel adapters
channel adapters
A channel adapter is a small daemon that translates one chat platform to the HTTP contract of routd, and six adapters ship with arizuko.
An adapter translates between one chat platform and routd
A channel adapter translates between one chat app and arizuko. Each app, such as Telegram, Slack or WhatsApp, talks in its own way. The adapter turns that into the one format that arizuko understands, and turns the replies of arizuko into posts that the app accepts.
A channel adapter is a small daemon. It speaks the protocol of one platform on one side, such as Telegram, and the HTTP contract of arizuko on the other side, toward routd.
For inbound messages, the adapter receives a message from the platform and posts it to routd at /v1/messages. It sends the service:<daemon> ES256 token that it got in exchange for AUTHD_SERVICE_KEY. For outbound messages, routd calls the adapter at POST /send with the reply of the agent, and the adapter posts it to the platform.
Each platform has one adapter, and each adapter has one container. Each listens on LISTEN_ADDR=:8080 inside its container, and Docker networking keeps the ports apart.
Adapters keep platform details out of routd
Every platform has its own protocol. Telegram uses long polling, Discord holds a websocket, Slack posts events, and email uses IMAP. Any of that inside routd would tie the message loop to the details of one platform. The adapter takes those details, so routd only ever sees an authenticated POST /v1/messages. To add a platform, add an adapter, and routd does not change.
Every adapter implements one interface
Every Go adapter implements one interface, chanlib.BotHandler in chanlib/handler.go:
- The messaging half
Send,SendFile,SendVoiceandTyping. An adapter without files or voice embedschanlib.NoFileSenderorchanlib.NoVoiceSender.- The social half
Post,Like,Dislike,Forward,Quote,Repost,Edit,Delete,PinandUnpin. An adapter implements a verb where the platform has it. An adapter without them embedschanlib.NoSocial, and each verb returnschanlib.ErrUnsupported.HistoryProvider- Optional. It reads older messages from the platform API when the agent asks for context.
Every Go adapter serves the same HTTP endpoints, which chanlib mounts:
GET /health- 503 when the link to the platform is down, for example an unscanned QR code, a dropped stream or an expired token. Otherwise 200, with the status
stalewhen the channel has been quiet for longer than its threshold. A quiet channel is normal, so it never returns 503. POST /send,/send-file,/send-voice,/typingroutdgives 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- Returns 501 when the adapter has no
HistoryProvider.
An adapter never posts the same message twice
routd writes an outbound row as pending, gives it to the adapter, and marks it sent when the adapter answers. If no answer arrives, the row stays pending, and a sweep every 30 seconds gives it to the adapter again. MAX_OUTBOUND_RETRY, 10 by default, limits the attempts, and then the row goes to the dead letters. So an adapter can see the same message more than once by design, and it must not post it twice.
Each send carries an idempotency_key: the stable id of the outbound row. Before the adapter calls the platform, it claims that key in its own table. After the post lands, it settles the claim with the message id from the platform. 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 result. It cannot tell a lost answer from a lost request, and chat.postMessage of Slack has no key of its own. So both guesses are wrong: another post shows the message twice, and a skip loses it. The adapter refuses instead. routd uses up its retries and moves the row to the dead letters, where an operator sees it. A visible failure is better than a double post.
The same rule applies one layer down. An adapter retries a platform call that failed with 429, a refusal with a Retry-After that says when to try again. It never retries a create call that failed with a 5xx or a dropped connection, because that call may have created the message already.
One adapter and routd exchange four kinds of call
The figure shows the whole exchange between one adapter and routd, from top to bottom. The adapter registers once, answers health checks, pushes inbound messages and receives outbound calls. The left column is what the adapter sends or holds. The right column is what routd checks.
adapter, chanlib.Run routd
│ every call carries an ES256 service bearer:
│ service:<daemon> to routd, service:routd to the adapter
│ │
│ POST /v1/channels/register │
├─────────────────────────────────────►│ origin pin: the first (IP, sub)
│ name, url, jid_prefixes, caps │ owns the name; a later claim
│◄─────────────────────────────────────┤ from elsewhere is 409
│ session token │
│ │
│ GET <url>/health, every 30 s │
│◄─────────────────────────────────────┤ ok and stale count as healthy;
├─────────────────────────────────────►│ 3 failures deregister
│ ok, stale or 503 disconnected │
│ │
│ POST /v1/messages │
├─────────────────────────────────────►│ chat_jid inside a registered
│ inbound; held locally on failure │ prefix, sender inside its
│ or left on the platform cursor │ scheme; PutMessage appends
│ │
│ POST <url>/send, idempotency_key │
│◄─────────────────────────────────────┤ the pending bot row is the
│ deliverOnce: claim, post, record │ outbox
├─────────────────────────────────────►│ 200 sent; 4xx dead-letters;
│ 200 {id}, 400, 501 or 502 │ 5xx retries at 30 s, doubling
│ │
│ POST <url>/typing, /<verb> │
│◄─────────────────────────────────────┤ only for an advertised
│ typing TTL: 90% of RUNED_RUN_TIMEOUT │ capability; else typing sends
│ │ nothing and a verb gets 501
Two details in the figure matter. First, the outbound queue is only the pending row of routd. The adapter keeps a record of what it already posted, not a queue. Second, a verb that the adapter never advertised causes no network call: routd answers the agent at once with an “unsupported” hint.
Trust works the same in both directions: both are ES256 bearers, verified against the JWKS of authd, with no shared HMAC secret. For inbound calls, routd requires a service: subject. For outbound calls, the chanlib gate of the adapter requires exactly service:routd. Each token comes from AUTHD_SERVICE_KEY and rotates about every hour. See auth.
Six adapters ship with arizuko
| name | platform | language | notes |
|---|---|---|---|
| slakd | Slack | Go | The assistant pane with suggested prompts and a conversation title. Reactions map to likes. |
| teled | Telegram | Go | The Bot API with long polling, file uploads and native voice messages. |
| discd | Discord | Go | The gateway over a websocket, ActionRow buttons and channel threads. |
| emaid | Go | IMAP in, SMTP out, and threads by Message-ID. | |
| whapd | TypeScript | A Baileys client, QR pairing and voice notes. | |
| gitd | GitHub | Go | Signed webhook intake. With GITHUB_TOKEN, it posts comments. Merges, labels and reviews go through the GitHub MCP connector. |
One table lists which adapter has which verb: [EXTENDING.md § Verb support matrix]. An adapter without a platform verb returns chanlib.ErrUnsupported and does not fail without a sign. The agent reads that response and picks another action.
The details of each platform stay inside its adapter. Slack threads, Discord ActionRows, WhatsApp QR pairing and IMAP folder watching never reach routd. The store sees the same messages row for every source.
The core messaging skill gives agents formatting guidance for each channel. Slack uses mrkdwn, while other channels use their supported text format.
The upstream SDK decides the language. Most platforms have a maintained Go client, so their adapter is Go. WhatsApp has its best client in TypeScript, Baileys, so whapd is TypeScript and runs on Node. The HTTP contract is the same for all adapters.
A new adapter is a daemon and a compose fragment
A new adapter needs two files and no edit to proxyd or compose:
- Write the daemon. Implement
chanlib.BotHandler, and embedNoSocialandNoVoiceSenderfor what the platform does not have. Serve the standard HTTP endpoints onLISTEN_ADDR=:8080. - Add
template/services/<daemon>/<daemon>.ymlfor Docker andarizuko.ymlfor package metadata. Declare inbound web paths in the manifest'sproxyd_routesblock.
[chanlib/README.md] has the delivery contract of every adapter and the error classes it returns. [EXTENDING.md] has the extension points.
A reply can leave on another channel
The inbound and the outbound channel do not have to be the same daemon. A message can arrive by email, and the reply can go out on Slack: the route table decides. See howto/asymmetric-channels.
Three sources cover the details
- [chanlib/handler.go]:
BotHandler, the fourteen verbs and their request types. chanlib/andchanreg/: the shared HTTP code that every adapter mounts.- concepts/routing: routd, the other end of every adapter call, and how an inbound message picks its group and reply channel.