Add a channel adapter

The channel boundary is arizuko's most important seam. Everything outside it is replaceable.

A channel adapter is any process that speaks a 3-endpoint HTTP protocol. The gateway never imports adapter code. To add a new platform — SMS, Slack, IRC, a proprietary API — you run a process that implements three endpoints, calls one registration endpoint at startup, and posts inbound messages as JSON. That is the entire contract.

The 3-endpoint protocol

Your adapter must expose two HTTP endpoints and call one on the gateway:

DirectionEndpointWho calls it
Outbound deliveryPOST /send on your adapterGateway calls this to deliver a message to the platform
Health checkGET /health on your adapterGateway polls this to track liveness
RegistrationPOST /v1/channels/register on the gatewayYour adapter calls this once at startup

Registration payload

At startup, POST to http://<gateway>:8080/v1/channels/register:

{
  "name":         "myplatform",
  "url":          "http://myadapter:9005",
  "prefixes":     ["myplatform:"],
  "capabilities": ["send_text", "send_image"]
}

name is the internal identifier. prefixes declares what JID namespaces this adapter owns — the gateway uses this for prefix-scan routing when no JID→adapter mapping exists yet. capabilities is informational.

Inbound messages

When a user sends a message on your platform, POST to the gateway's inbound endpoint:

POST /v1/messages
{
  "chat_jid":    "myplatform:channel123",
  "sender_jid":  "myplatform:user456",
  "content":     "hello",
  "adapter":     "myplatform",
  "timestamp":   1711500000
}

Use chanlib.RouterClient to handle auth headers and retries:

rc := chanlib.NewRouterClient(routerURL, channelSecret)
err := rc.PostMessage(ctx, chanlib.InboundMessage{
    ChatJID:   "myplatform:channel123",
    SenderJID: "myplatform:user456",
    Content:   msg.Text,
    Adapter:   "myplatform",
})

Minimal adapter in Go

package main

import (
    "encoding/json"
    "net/http"
    "os"

    "github.com/onvos/pub/arizuko/chanlib"
)

type Adapter struct {
    rc *chanlib.RouterClient
}

// handleSend is called by the gateway to deliver a message to the platform.
func (a *Adapter) handleSend(w http.ResponseWriter, r *http.Request) {
    var msg chanlib.OutboundMsg
    if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
        http.Error(w, err.Error(), 400)
        return
    }
    // deliver msg.Content to msg.ChatJID on your platform
    // e.g. call your platform's API here
    w.WriteHeader(200)
}

func (a *Adapter) handleHealth(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(200)
}

func main() {
    routerURL := os.Getenv("ROUTER_URL")     // e.g. http://gated:8080
    secret    := os.Getenv("CHANNEL_SECRET")
    listenURL := os.Getenv("LISTEN_URL")     // e.g. http://myadapter:9005

    rc := chanlib.NewRouterClient(routerURL, secret)
    a  := &Adapter{rc: rc}

    mux := http.NewServeMux()
    mux.HandleFunc("/send",   a.handleSend)
    mux.HandleFunc("/health", a.handleHealth)

    // register with the gateway
    if err := rc.Register(chanlib.Registration{
        Name:         "myplatform",
        URL:          listenURL,
        Prefixes:     []string{"myplatform:"},
        Capabilities: []string{"send_text"},
    }); err != nil {
        panic(err)
    }

    http.ListenAndServe(":9005", mux)
}

That is ~60 lines including imports. The adapter does not need to know anything about groups, routing, or MCP. It delivers messages and receives deliveries.

Add it to docker-compose

Add a service TOML in your instance's services/ directory. The compose generator picks it up:

# /srv/data/arizuko_myinstance/services/myplatform.toml
image = "myplatform:latest"

[environment]
ROUTER_URL     = "http://gated:8080"
CHANNEL_SECRET = "${CHANNEL_SECRET}"
LISTEN_URL     = "http://myplatform:9005"
LISTEN_ADDR    = ":9005"

Re-run arizuko generate myinstance and the new service appears in docker-compose.yml.

How it fits the system

chanreg.Registry is a name→URL map maintained in memory. When the gateway needs to deliver an outbound message, HTTPChannel.Owns(jid) does a prefix scan across registered adapters. On first inbound message, the gateway calls RecordJIDAdapter(chatJID, adapterName) — subsequent outbound messages to that JID route directly without the prefix scan.

The gateway has zero imports from any adapter package. core.Channel is an interface with two methods: Send and Owns. HTTPChannel implements that interface by calling your adapter's /send endpoint. This is why the WhatsApp adapter is TypeScript and every other adapter is Go — the protocol is the contract, not the language.

Adding a new platform does not touch any gateway code. The registration payload is the only configuration.