Skip to content

Latest commit

 

History

History
267 lines (216 loc) · 16 KB

File metadata and controls

267 lines (216 loc) · 16 KB

Self-hosting: bring your own realm

commy has no hosted service. To run it you supply your own Zulip realm and a minter user on that realm. The minter is a human-type Zulip user that owns every bot identity the substrate mints; it must be a member of the realm's can_create_bots_group.

Environment contract

Three required credentials identify the realm and the minter. They are the same values whether you run the MCP server directly or via the Claude Code plugin (where they are prompted as plugin userConfig — the API key lands in the system keychain, never settings.json).

Env var Required Purpose
ZULIP_SITE yes Base URL of the Zulip realm, e.g. https://chat.example.com. Used by every Zulip HTTP call.
ZULIP_MINTER_EMAIL yes Delivery email of the minter user that owns all managed bots. Must be in the realm's can_create_bots_group.
ZULIP_MINTER_API_KEY yes The minter's API key. Used to mint and regenerate bot credentials. Sensitive — keep it out of source control.

Optional knobs that shape boot-time behaviour (all default to sensible no-op-ish values when unset):

Env var Required Purpose
COMMY_BOT_NAME no Persistent mode: a stable identity acquired eagerly at boot (for concierges / scheduled agents). Omit for ephemeral, per-session identities.
COMMY_PROJECT no Project slug used for channel naming and a persistent agent's project subscriptions. When unset it is derived per-session from the calling cwd (git remote / git root).
COMMY_SUBSCRIBE no Comma-separated tokens that bootstrap a new bot's subscriptions, once: <channel> for a whole channel, <channel>/<thread> for one topic in it, new-topics:<channel> for the first message of each new topic. Applied when the bot is created and never again — see COMMY_SUBSCRIBE bootstraps a bot; it does not configure one. Blank means no bootstrap — the bot still receives its own mentions, which are implicit and need no token. The retired channel: / thread: prefixes and the retired mentions token are rejected as config errors. Under the Claude Code plugin this may also arrive as COMMY_SUBSCRIBE_USER_CONFIG, which takes precedence; see Two supply paths for the optional COMMY_* values.
COMMY_CATCHUP_WINDOW_SECONDS no How far back to fetch recent messages across the boot-time subscribe set on a persistent restart. Default 14400 (4 hours); 0 disables.
COMMY_QUEUE_IDLE_TIMEOUT_SECS no How many seconds an ephemeral session's events queue survives without a poll before Zulip garbage-collects it, sent as idle_queue_timeout on /register. Default 86400 (24 hours); clamped to Zulip's 7-day MAX_QUEUE_TIMEOUT_SECS ceiling (604800). A non-positive or non-integer value fails boot with a config error.
COMMY_DOWNLOAD_DIR no Base directory for download_file attachments. When set, each download's fresh temp subdirectory is created under it so files land somewhere an allowlisted agent can Read; when unset, downloads go to $TMPDIR. Must be an existing directory — a non-directory value fails boot with a config error.
COMMY_NPM_MIN_RELEASE_AGE no Operator override for npm's min-release-age supply-chain soak, scoped to commy's own npx launch. Set to 0 to run a freshly-published release that your global soak would otherwise block with ENOVERSIONS. Leave unset to fully respect your own npm setting. See Running outside Claude Code.

The live-test suite additionally needs a channel to exercise against:

Env var Purpose
ZULIP_LIVE_CHANNEL_NAME Name of an existing channel the live tests post into.
ZULIP_LIVE_CHANNEL_ID Id of that same channel.

A template for the live-test env ships in the repo root — copy it and supply your own realm's values (it references a secret manager for the actual values; copy the variable shape, supply your own).

Two supply paths for the optional COMMY_* values

COMMY_SUBSCRIBE and COMMY_CATCHUP_WINDOW_SECONDS can each be supplied two ways, and the two compose rather than collide:

  1. The Claude Code plugin's user config, which the plugin manifest writes to a suffixed key — COMMY_SUBSCRIBE_USER_CONFIG, COMMY_CATCHUP_WINDOW_SECONDS_USER_CONFIG.
  2. The ordinary inherited environment, under the bare name.

The suffixed key wins when it carries a value; an empty one falls through to the bare name. Everything downstream reads a single resolved value, so which path supplied it changes nothing about behaviour.

The suffix exists because .mcp.json is static JSON with no way to omit a key conditionally: every ${user_config.KEY} it declares is substituted and written into the MCP child's environment whether or not you supplied that key, and an unsupplied optional field lands as an empty string. Written to the bare name, that empty string overrode whatever you had set by other means — a systemd unit, a pane env, a nix module — and the plugin then read "no subscriptions" and booted a seat that was silently deaf to every channel it was meant to watch. Giving the manifest its own key space makes that impossible: the plugin can only ever clobber a name it alone owns.

If you configure subscriptions and want to confirm they arrived, the boot that creates the bot logs a line naming the applied tokens in the same vocabulary you wrote them:

commy plugin: applied 2 boot-time subscribe target(s): myproject, general/standup

That line appears once in a bot's life. On a later launch you get this instead, which is the design working rather than a fault:

commy plugin: COMMY_SUBSCRIBE not applied — myproject-concierge already exists
and owns its subscriptions.

COMMY_SUBSCRIBE bootstraps a bot; it does not configure one

COMMY_SUBSCRIBE is applied at the moment a bot is created, and never read again. From then on the bot owns its subscriptions: they live in the realm under its own principal, and they change when the agent itself calls subscribe or unsubscribe.

Editing COMMY_SUBSCRIBE for a bot that already exists has no effect. To change what a running agent listens to, ask the agent — it has the tools. To start over from the launcher, delete the bot; the next boot creates a new one and bootstraps it from the current value.

The reason is that a launcher value and an agent's own calls cannot both be authoritative. When both were, the same COMMY_SUBSCRIBE read at two launches of one bot was indistinguishable from the bot's own runtime changes, and the two quietly fought. The realm holds one answer now, and the agent is the one who changes it.

One consequence for an upgrade: a bot that already existed before this behaviour landed is not bootstrapped retroactively. It keeps whatever the realm holds for it — and a bot that finds itself with nothing can subscribe to what it wants.

Realm settings that shape commy behaviour

Some of your realm's own Zulip settings — operator knobs on the realm, not commy env vars — change how the substrate behaves. Two of them govern edit-in-place.

The first is allow_message_editing. Turn it off and no message on the realm is editable by anyone. commy reads this setting once when a seat connects, and withholds the edit_message tool entirely on a realm that has editing switched off — an agent is never offered a capability the realm cannot honour. If you run anchors that need to stay current in place, leave this on.

Because the setting is sampled at connect, flipping it does not reach seats that are already running: a seat connected while editing was on keeps edit_message in its tool list until it reconnects. Calls made in that window are refused by Zulip with reason editing-disabled, and the recovery — for every emitter, at every age — is to re-post. Restart your seats after changing this setting if you want the tool surface to match immediately.

If commy cannot read the setting at connect (a network blip, a rate-limited realm), it offers edit_message and lets the substrate refuse: a transiently hidden capability is harder to diagnose than one that refuses with a reason.

The second is message_content_edit_limit_seconds: how long after posting an author may still edit a message's content.

It governs edit-in-place, which is how an emitter keeps a long-lived decision anchor current. Once the window passes, edit_message is refused (commy surfaces this as a typed MessageEditRefused with reason window-expired) and the only recovery is to re-post. A long window makes edit-in-place viable for anchors that live for hours; a short one — the stock default is minutes — forces re-posts. commy cannot widen this from code; set it on the realm to match how long your anchors need to stay editable.

There is a third edit wall no realm setting lifts: Zulip only lets the original sender edit content, and commy's ephemeral identities are per-session. So a message that outlives its authoring session is uneditable at any age — edit_message refuses it with reason not-original-sender, and the emitter must re-post. Widening the time limit does not change this; a persistent COMMY_BOT_NAME identity (above) is what keeps authorship across sessions.

Your realm's language does not affect any of this. Zulip gives these three walls no distinguishing error code — only the human-readable message differs — so commy tells them apart by matching that message, and pins Accept-Language: en on every API request to keep the responses English whatever the realm's or the bot's own language is. A non-English realm still classifies refusals correctly; only commy's own API traffic is English.

Running outside Claude Code

The MCP server is a plain stdio server with no Claude Code dependency at runtime: any host that can spawn a subprocess and speak MCP over stdin/stdout can drive it. Claude Code (via the plugin) is one such host; it is not required. The runtime is node — the server is published as a self-contained bundle on npm, so the entry point for an operator is:

npx -y @codeforbreakfast/commy-mcp

npx pulls the published @codeforbreakfast/commy-mcp package — a single server.js with every dependency inlined — and runs it under node; there is no install step and nothing to stage. (The package carries the @codeforbreakfast scope because @commy is taken on npm; the substrate is otherwise commy throughout.) If you run npm's min-release-age supply-chain soak — which holds back a freshly-published release so a compromised one can be caught before you auto-pull it — a commy release younger than your window fails to resolve (ENOVERSIONS) until it ages in. That soak guards you against the publisher's own not-yet-vetted code, so waiving it is a decision to trust the commy publisher and run a new release immediately: set COMMY_NPM_MIN_RELEASE_AGE=0 (which the launcher threads as npm_config_min_release_age). Because the bundle has no dependencies, that waiver is scoped to exactly this one package — no transitive dependency tree rides along — and an unset value never weakens your own setting. Working from a source checkout instead, the dev toolchain runs the TypeScript entry point directly under Bun with bun packages/mcp/server.ts — bun is the development runtime, never a consumer prerequisite. nix is not a runtime dependency either; the flake's dev shell stays supported for those who want it, but is never required to run the server. The Claude Code plugin launches the same published bundle via npx, with cwd set to ${CLAUDE_PLUGIN_ROOT}.

stdout carries only JSON-RPC. Every log line goes to stderr — the host must not expect diagnostics on stdout, and nothing else may write there, or the MCP channel corrupts.

A persistent, post-only identity

To run a persistent, post-only identity (the shape a non-CC agent runtime uses to post into a channel without the per-session Claude Code hooks), set the three credentials plus a stable bot name:

Env var Value
ZULIP_SITE realm base URL
ZULIP_MINTER_EMAIL minter email (the same minter the plugin uses — do not provision a second)
ZULIP_MINTER_API_KEY minter API key
COMMY_BOT_NAME a stable name (bootstrap.ts brand: lowercase ASCII / digits / - / _, starts with a letter, ≤40 chars)

Setting COMMY_BOT_NAME flips on persistent mode: the identity is acquired eagerly at boot and reused for every call, so the per-session session_id/cwd that the Claude Code plugin injects become irrelevant — no CC coupling for posting. COMMY_SUBSCRIBE is not needed to post; a post-only bot wants no subscriptions. Point XDG_STATE_HOME at a writable directory — the bot persists inbound read-cursors under $XDG_STATE_HOME/commy/cursors (default $HOME/.local/state/…); for a post-only bot the writes are non-fatal boot bookkeeping, but a writable path keeps stderr clean.

For a container, pin the published package version at image build time — e.g. npm install -g @codeforbreakfast/commy-mcp@<version> — and make the runtime command commy-mcp (or npx @codeforbreakfast/commy-mcp), so boot resolves the already-present bundle and never reaches the network.

An ephemeral non-CC host must supply a session id to receive

Behaviour change. Receiving is state the realm holds on the agent's behalf, so a seat's event queue and its subscriptions now live under its own principal rather than the shared minter's. A seat therefore has to be able to obtain an identity before it can subscribe or receive at all — and an ephemeral identity is named from the session id (cc-[<project>-]<first-8>), so no session id means no name to mint under, and no reception.

This affects one host class: an ephemeral host (no COMMY_BOT_NAME) that injects no session id. Such a seat previously received channel traffic through the minter's queue, since the minter was subscribed to every public stream and listened on behalf of un-minted seats. That crutch is being retired, so the seat's inability to identify itself is now visible instead of masked: its boot-time subscribe is refused, it registers no queue, and it logs a warning naming what it lost. It keeps serving; it does not receive.

Two supported ways to avoid it, both already documented above:

  • Set COMMY_BOT_NAME for a persistent identity — the session id is irrelevant in that mode, and the bot subscribes under its own stable principal.
  • Pass a UUID session_id in the tool-call arguments, which is the binding an ephemeral non-CC host has (docs/claude-channel-inbound-contract.md). The tools accepting it are post, edit_message, react, unreact, current_identity, subscribe and unsubscribe. It is not advertised on their inputSchema — a session id is the host's to supply, not something a model could fill, so it is documented here rather than shown to the agent. Anything that fails UUID validation is treated as missing: the server returns the unbound-stub error rather than minting a malformed cc-* identity.

Claude Code seats are unaffected: the plugin injects CLAUDE_CODE_SESSION_ID into the MCP child's environment at spawn, so the id is known before the seat's first subscribe.

Inbound is host work

A standalone MCP client on the open pipe physically receives inbound events (each is a server→client JSON-RPC notification, method: notifications/claude/channel), but rendering one into the agent's turn is the host's job — Claude Code does it; another runtime must recognise the method and inject the payload itself. So a standalone bot can post today, but it is deaf to reactions, replies, and DMs until its host implements that receive-and-render path. The full host-neutral contract — frame shape, meta field catalogue, and the render-into-turn obligation a non-CC runtime must meet — is specified in claude-channel-inbound-contract.md.