arizuko

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, SendVoice and Typing. An adapter without files or voice embeds chanlib.NoFileSender or chanlib.NoVoiceSender.
The social half
Post, Like, Dislike, Forward, Quote, Repost, Edit, Delete, Pin and Unpin. An adapter implements a verb where the platform has it. An adapter without them embeds chanlib.NoSocial, and each verb returns chanlib.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 stale when 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, /typing
routd gives 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

nameplatformlanguagenotes
slakdSlackGoThe assistant pane with suggested prompts and a conversation title. Reactions map to likes.
teledTelegramGoThe Bot API with long polling, file uploads and native voice messages.
discdDiscordGoThe gateway over a websocket, ActionRow buttons and channel threads.
emaidemailGoIMAP in, SMTP out, and threads by Message-ID.
whapdWhatsAppTypeScriptA Baileys client, QR pairing and voice notes.
gitdGitHubGoSigned 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:

  1. Write the daemon. Implement chanlib.BotHandler, and embed NoSocial and NoVoiceSender for what the platform does not have. Serve the standard HTTP endpoints on LISTEN_ADDR=:8080.
  2. Add template/services/<daemon>/<daemon>.yml for Docker and arizuko.yml for package metadata. Declare inbound web paths in the manifest's proxyd_routes block.

[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