Agentz #integration #discord #slack

One agent, four messengers: Discord, Matrix, Zulip, and Slack

An agent that lives only inside its own desktop window is an agent you forget to talk to. Agentz agents now answer on four chat platforms: Discord (since December), Matrix, Zulip, and — as of this week — Slack. Each platform binds a channel to an agent, turns conversations into threads, and routes messages through the same agent brain that powers the app's own chat.

The shape all four share

Every integration is a service class with the same anatomy:

  • Two mirror maps — channel → agent mapping and agent → channel — so lookups are O(1) in both directions.
  • A per-channel FIFO queue. Messages for one channel process strictly in order; different channels proceed independently. Without this, two quick messages in one channel race each other into the same conversation.
  • A rate-limit handler wrapping every outbound call.
  • Identical reconnect logic: five attempts, exponential backoff capped at 30 seconds.
  • Response chunking at each platform's message limit — Discord 2,000 characters, Slack 4,000, Matrix 4,096, Zulip 10,000 — with a 500ms delay between chunks.

Inbound flow is the same everywhere: filter out our own echoes, look up the channel's agent, enqueue. The handler finds or creates a conversation keyed by the platform's thread ID, stores the message, runs the agent, and posts the reply back into the thread. Replies go out as complete messages rather than token streams — chat platforms rate-limit edits aggressively enough that "type, then send" beats simulated streaming.

Here's the confession: these four services share a shape but no base class. Slack was written by copying the Zulip service and adapting it, which copied Matrix, which copied Discord. I tried to extract the abstraction twice and stopped both times, because the platforms differ in exactly the places an interface would have to pretend they don't — what a "channel" is, what a "thread" is, when an event is an echo. Four honest copies have so far been cheaper than one dishonest interface. (Ask me again after platform five.)

Where they differ, they really differ

Discord is the most conversational fit. A new message in a linked channel spawns a thread (24-hour auto-archive, named from the message's first 47 characters), and everything in that thread continues one agent conversation. Discord also does interactive buttons and typing indicators, and if someone deletes a thread, the deletion syncs back into the app.

Matrix brought end-to-end encryption, via matrix-bot-sdk with the Rust crypto store. Rooms can be created encrypted with Megolm, and the service listens for both plaintext and decrypted events — which means the same event can arrive twice, so there's a deduplication set (capped at 1,000 event IDs) standing guard. Matrix also taught me to always attach a default error listener: an EventEmitter error with no listener doesn't log, it throws and takes the process down.

Zulip has no websocket API — you register an event queue, then long-poll it with a 90-second timeout, advancing a cursor. The failure-handling matrix is delicate: a BAD_EVENT_QUEUE_ID means re-register, but a 429 means back off without re-registering, because re-registering is itself rate-limited and you can spiral. Zulip is also where Tailscale enters the picture: my Zulip is self-hosted on a tailnet, so the app grew a small Tailscale CLI wrapper plus an option to accept the self-signed certificates that tailnet-internal services tend to have.

Slack, this week's addition, runs Bolt in Socket Mode — the whole reason it's viable at all. A desktop Electron app cannot host a public webhook endpoint, and Socket Mode replaces the webhook with an outbound websocket. Slack threads map via thread_ts, and agents can now upload files into the thread, so when an agent references a chart it generated, the actual image lands in Slack rather than a filename.

Slack was also the source of this week's best bug: the echo loop. The bot uploads a file, Slack emits a file_share message event from the bot, the bot treats it as a new user message, responds, and so on. The fix is filtering on subtype, bot ID, and self-user ID before anything touches the queue — the same lesson every platform re-teaches in its own dialect.

One button, four backends

With four integrations, the per-agent UI was heading toward a row of four link buttons, twelve modals, and a support matrix nobody wanted. The settings got consolidated instead: each service registers its icon, label, and status elements in a typed record, and the agent window shows a single messenger button wearing the icon of whichever service the agent is linked to. Each integration also gained a proper enable/disable toggle — a disabled integration skips initialisation entirely instead of connecting and idling.

What routing through chat actually buys

The point of all this isn't novelty; it's that conversations stop being trapped in the desktop app. The same agent conversation is reachable from the app window and from a Discord thread on your phone. Scheduled jobs can post their results into a channel where you'll actually see them. And because every platform funnels into the same conversation store, switching platforms mid-conversation just works — the agent doesn't know or care that message three arrived via Zulip.

Four platforms in, the marginal integration cost is a few days: the service class pattern is stable, the queue and rate-limit machinery is understood, and the hard-won list of echo-loop filters transfers. The first integration took two weeks. The abstraction I refused to write is, apparently, living in my head instead — which is, for now, exactly where it costs the least.