Help us shape the future of Go! Take the 2026 Go Developer Survey

zulip-acp

module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 30, 2026 License: MIT

README

zulip-acp

ci release Go Reference

Talk to a coding agent from your phone, through your own Zulip server.

zulip-acp bridges a self-hosted Zulip instance to an ACP-speaking agent (fir --mode acp, Claude Code, …) over stdio. Each Zulip topic is a conversation: ask a question, get a streamed answer, follow up in the same topic and the agent remembers. Rename the topic ten turns in and the session follows it.

It is the third relay in a family with poe-acp and slack-acp; all ACP-side machinery is shared in acp-kit.

Why Zulip

  • No inbound HTTP. The relay dials out only, over /events long polling. No tunnel, no webhook, no public exposure — it runs happily on a tailnet.
  • Topics are sessions. No thread-id mapping table to lose, and topics can be renamed in place.
  • Streaming actually streams. Zulip sustains ~15 message edits/sec; Slack's chat.update is ~1/sec/channel.
  • It is yours. Self-hosted, HTTP Basic auth, one bot, no app manifest.

Install

brew install kfet/ai/zulip-acp

or grab a binary from releases, or:

go install github.com/kfet/zulip-acp/cmd/zulip-acp@latest

Quick start

  1. Create a bot. In Zulip: Settings → Personal → Bots (or <site>/#organization/bots) → Add a bot → type Generic. Copy its email and API key. Subscribe it to the channels it should serve.

  2. Set message editing to unlimited. Organization settings → Message editing → Message edit limit → Unlimited.

    ⚠️ This is not optional. A fresh realm defaults to 10 minutes (message_content_edit_limit_seconds = 600). The relay edits one message for the duration of a turn, so any turn longer than that starts failing with HTTP 400 mid-answer.

  3. Write ~/.config/zulip-acp/config.json:

    {
      "site": "https://zulip.example.com",
      "bot_email": "fir-relay-bot@zulip.example.com",
      "channels": ["fleet"],
      "agent_cmd": ["fir", "--mode", "acp"]
    }
    
  4. Run it, keeping the key out of the config file:

    ZULIP_API_KEY=… zulip-acp --config ~/.config/zulip-acp/config.json
    
  5. Say hello. In the fleet channel, start a topic and @-mention the bot:

    @**fir-relay** what's in this repo?

    Follow-ups in the same topic need no mention.

How it behaves

  • @-mention in a topic the relay does not know → starts a conversation, and the answer streams into a single message that is edited as it arrives.
  • Any message in a topic the relay has already engaged → answered too. If the agent decides the message was not for it, it emits the silent sentinel and the relay posts nothing at all.
  • Answers over ~9500 characters roll over into further messages, marked *(continued below)* / *(continued from above)*. Fenced code blocks are closed and reopened, with their language tag, across the seam. No text is ever dropped — Zulip's 10000-character limit truncates silently, so the relay counts for itself.
  • Files. Anything the agent writes into outbox/ in its working directory is uploaded and linked at the end of the turn.
  • Restarts are safe. An interrupted turn is marked *(relay restarted — turn interrupted)*, and the next message in the topic picks the session back up.
  • The relay never answers a bot, including its own messages and Zulip's system notices.

Configuration

Every key is optional except the credentials and channels.

key default meaning
site — Zulip base URL. Env: ZULIP_SITE
bot_email — bot's Zulip email. Env: ZULIP_EMAIL
bot_api_key — bot's API key. Env: ZULIP_API_KEY (preferred)
channels — channel names or ids to serve; also the allowlist
allowed_user_ids everyone restrict who the relay answers
agent_cmd ["fir","--mode","acp"] agent argv
state_dir $XDG_STATE_HOME/zulip-acp per-conversation cwds + journal
session_idle_timeout_seconds 1800 idle session GC
prompt_timeout_seconds 600 wall-clock cap on one turn
system_prompt — appended to the built-in Zulip formatting block
disable_system_prompt false skip injection entirely
hide_thinking false suppress the agent's thought lines
silent_sentinel <<SILENT>> agent output meaning "don't reply"; "" disables abstain
max_message_chars 9500 per-message budget, in code points
seal_marker *(continued below)* closes a rolled-over message
continuation_marker *(continued from above)* opens a continuation
edit_interval_ms 300 streaming edit coalescing

The API key is declared as a secret and is scrubbed from the agent's environment before the child process starts.

Useful flags: --print-paths, --version, --channels, --agent-cmd, --state-dir. Set ZULIP_ACP_DEBUG=1 for protocol-level logging.

Running as a service

# ~/.config/systemd/user/zulip-acp.service
[Unit]
Description=zulip-acp
After=network-online.target

[Service]
EnvironmentFile=%h/.config/zulip-acp/env
ExecStart=%h/.local/bin/zulip-acp --config %h/.config/zulip-acp/config.json
Restart=always
RestartSec=5

[Install]
WantedBy=default.target
chmod 600 ~/.config/zulip-acp/env      # holds ZULIP_API_KEY=…
systemctl --user enable --now zulip-acp
journalctl --user -u zulip-acp -f

Documentation

  • docs/zulip-acp-design.md — architecture, the three decisions that matter, and what was deliberately not ported from slack-acp.
  • docs/zulip-protocol-reference.md — the Zulip wire protocol as measured, including the three traps that cost real debugging time: silent truncation, the /register narrow operand, and system bots posting into your topics.
  • BACKLOG.md — what is deliberately not done yet, and why.

Development

make          # vet, race+shuffle tests with a 100% coverage gate, 5 cross-builds, licenses
make test     # quick

Live tests run against a real server and are excluded from the coverage gate:

ZULIP_LIVE=1 ZULIP_SITE=… ZULIP_EMAIL=… ZULIP_API_KEY=… ZULIP_CHANNEL=fleet \
  go test -v ./test/

They exist to pin server behaviour — most importantly that oversized messages are still silently truncated — so a future Zulip upgrade that changes it is caught deliberately rather than discovered by losing someone's output.

A note on push notifications

Self-hosted Zulip cannot send iOS push notifications without registering with Zulip's Mobile Push Notification Service, which sees notification metadata (sender, channel, topic, volume). zulip-acp takes no position on that and does nothing about it — it is a privacy trade-off for you to make deliberately. Without it, the mobile app only updates while it is open.

License

MIT — see LICENSE.

Directories

Path Synopsis
cmd
zulip-acp command
zulip-acp bridges a self-hosted Zulip server to an ACP-speaking coding agent (fir --mode acp, Claude Code, …).
zulip-acp bridges a self-hosted Zulip server to an ACP-speaking coding agent (fir --mode acp, Claude Code, …).
internal
config
Package config loads the zulip-acp JSON config file.
Package config loads the zulip-acp JSON config file.
handler
Package handler turns inbound Zulip events into ACP prompts and streams the agent's answer back into the originating topic.
Package handler turns inbound Zulip events into ACP prompts and streams the agent's answer back into the originating topic.
journal
Package journal is the relay's durable, local cache of conversation identity.
Package journal is the relay's durable, local cache of conversation identity.
rollover
Package rollover splits an append-only transcript into a chain of chat messages, none of which exceeds a budget measured in Unicode code points, and drives a dumb poster to publish them.
Package rollover splits an append-only transcript into a chain of chat messages, none of which exceeds a budget measured in Unicode code points, and drives a dumb poster to publish them.
statusline
Package statusline is the Zulip-markdown renderer for the dev.acp-kit.status-line/v1 ACP extension.
Package statusline is the Zulip-markdown renderer for the dev.acp-kit.status-line/v1 ACP extension.
sysprompt
Package sysprompt composes the durable per-session system prompt.
Package sysprompt composes the durable per-session system prompt.
zulipproto
Package zulipproto is a minimal Zulip REST client: HTTP Basic auth, message send/edit/read, one-shot file uploads, channel resolution, and the event-queue primitives the long-poll runner is built on.
Package zulipproto is a minimal Zulip REST client: HTTP Basic auth, message send/edit/read, one-shot file uploads, channel resolution, and the event-queue primitives the long-poll runner is built on.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL