TL;DR
- Two Claude agents running in separate project contexts share a private Mattermost channel to ask each other for help without human intervention.
- A deterministic polling gate (bash + curl) checks for new messages before spawning the expensive responder LLM — idle polls cost almost nothing.
- The responder runs on the Claude Code subscription (keychain OAuth), not API credits, and keeps the LLM cheap.
- Scheduled every ~3 minutes by macOS launchd; armed/disarmed by loading/unloading the launch agent.
- Supervised autonomy: the responder can read, triage, draft, and explain — but NOT commit, spend money, change access, or deploy.
Why one agent asks another
I run parallel Claude Code sessions on different projects. They’re isolated — each has its own repo context, its own workflow, its own focus. But sometimes they need each other.
Agent A (running in one project) hits a question it can’t answer alone: “Did we push the config change to staging yet? What’s the current build status?” Agent B (running in a separate project) has access to that repo, the CI logs, the git history. Instead of copying context between sessions or merging them into one, why not let Agent A ask Agent B directly?
The bridge lets both agents stay focused on their own work while still being able to consult each other. No human copy-paste. No context switching. Just a question posted to a shared channel, and a response comes back when the responder wakes up.
Mattermost as the message bus
Mattermost is already running in the homelab for other reasons — it’s reliable, it’s private, and it has an API. The bridge uses a single private channel as the message queue.
- Agent A (the asker) posts a question:
@Bridge, is the deploy healthy? - Agent B (the responder, running on a schedule) wakes up, reads the question, and answers it in a threaded reply.
- Audit log gets a one-line summary of what Agent B did (in a separate ops channel), so the operator can see what happened while away.
The channel is private, never indexed, and treated like a secret. The Mattermost API token has read/post access to that channel — it’s not a full admin token, but treat it like one anyway.
The cheap polling gate: deterministic before LLM
Here’s the thing: spawning a Claude responder costs tokens. If Agent B wakes up every 3 minutes and there’s no question, that’s token waste. So the bridge uses a polling gate — a simple bash script that runs on every interval and decides whether to spawn the responder.
The gate does ONE job: check if there are new messages since the last successful run. It’s not an LLM; it’s deterministic.
#!/bin/bash
# Pseudocode polling gate
CURSOR_FILE=~/.claude/bridge_cursor.json
CHANNEL_ID="<bridge-channel-id>"
MATTERMOST_URL="https://chat.internal.local"
TOKEN="$MATTERMOST_TOKEN"
# Fetch new messages since cursor
LAST_TS=$(jq -r .last_timestamp "$CURSOR_FILE" 2>/dev/null || echo 0)
RESPONSE=$(curl -s -H "Authorization: Bearer $TOKEN" \
"$MATTERMOST_URL/api/v4/channels/$CHANNEL_ID/posts?after=$LAST_TS")
MESSAGE_COUNT=$(echo "$RESPONSE" | jq '.posts | length')
if [ "$MESSAGE_COUNT" -gt 0 ]; then
# Messages exist — spawn the responder
unset ANTHROPIC_API_KEY # Use subscription auth instead
claude -p bridge-responder
# Only advance cursor on clean exit
if [ $? -eq 0 ]; then
jq ".last_timestamp = $(date +%s)" "$CURSOR_FILE" > "$CURSOR_FILE.tmp"
mv "$CURSOR_FILE.tmp" "$CURSOR_FILE"
fi
else
# No messages — exit silently
exit 0
fi
The cursor (a JSON file with a timestamp) only advances when the responder exits cleanly. If Agent B crashes mid-response, the cursor doesn’t move — the next poll will re-run the responder on the same message. This isn’t ideal (you might get duplicate answers), but it’s safer than silently dropping a message because the cursor advanced before the response was posted.
The headless responder
Agent B runs as a headless Claude Code session using claude -p bridge-responder. It’s not interactive; it’s a one-shot prompt that reads the channel, formulates an answer, and posts it back.
The responder gets three things:
- The question from Mattermost (fetched by the gate script)
- Repo context from its own
-pprofile (the bridge-responder prompt has its own git repo set, its own working directory) - The subscription token, not API keys
This last part is critical: the gate script unsets ANTHROPIC_API_KEY before spawning the responder, so Claude Code uses keychain OAuth instead. That means the run bills to your Claude Code subscription, not your Anthropic API account. If you’re running a lot of these, subscription is cheaper.
The responder’s prompt is tight and prescriptive:
You are a bridge agent. A colleague agent has asked a question in the channel: [QUESTION]. Use your repo context, logs, and git history to answer it. Keep it brief. Post your answer in threaded reply format. Do NOT attempt to make changes, commit, or deploy.
The key constraint: no side effects. Read-only context gathering. Triage. Drafting. Explaining. But no writes.
Arm, disarm, schedule
The gate runs every ~3 minutes, triggered by a macOS launchd plist:
<!-- ~/.claude/launchd/bridge-poller.plist (pseudocode) -->
<plist version="1.0">
<dict>
<key>Label</key>
<string>local.claude.bridge-poller</string>
<key>ProgramArguments</key>
<array>
<string>~/.claude/bin/bridge-gate.sh</string>
</array>
<key>StartInterval</key>
<integer>180</integer> <!-- Every 180 seconds -->
<key>StandardOutPath</key>
<string>/var/tmp/bridge-poller.log</string>
<key>StandardErrorPath</key>
<string>/var/tmp/bridge-poller-error.log</string>
</dict>
</plist>
To arm the bridge (enable polling):
launchctl load ~/.claude/launchd/bridge-poller.plist
To disarm it (stop all polling):
launchctl unload ~/.claude/launchd/bridge-poller.plist
The bridge starts disarmed. Load it when you want it active; unload when you’re done or need to test. Logs go to /var/tmp/ so you can tail them if something’s wrong.
Guardrails: supervised autonomy
Agent B has read access to its own repo, logs, and CI pipeline. It can answer status questions, triage issues, and draft responses. But it has hard boundaries:
Agent B CAN:
- Read git logs, CI output, config files
- Answer status questions (“Is the build passing?”)
- Triage (“This error looks like X, check Y”)
- Draft text (a commit message, an alert, a plan)
- Explain (“Here’s why that failed”)
Agent B CANNOT:
- Commit or push changes
- Modify access, secrets, or credentials
- Spend money (no cloud operations, no deployments to production)
- Send email outside the team
- Merge or deploy
These rules are enforced in the responder prompt and baked into the launch environment: no git push access, no AWS credentials, no deployment tooling. If Agent B tries to do something forbidden, it fails cleanly (the responder errors, the gate logs it, the operator sees it in the audit channel).
Audit trail and escalations
Every time the gate runs, it posts a one-line summary to an ops/audit channel:
[BRIDGE] No messages. (180s poll)[BRIDGE] Answered "Deploy status?" — response posted. (45s)[BRIDGE] ERROR: Failed to post response (out of tokens?). Escalating.
If Agent B encounters something it can’t answer or thinks needs human attention, it posts:
@Operator: This question is outside my scope. Check [context].
And the audit channel gets:
[BRIDGE] NEEDS_HUMAN: Question escalated. Operator notified.
This way you can check the audit channel while away and see what happened, or set a notification rule on it to alert you to escalations.
Failure modes
A few things can go wrong.
Double-post on crash: If Agent B crashes after answering but before the cursor advances, the next poll retries the same message and posts a duplicate. Mitigate with a lock file: if a responder is already running, the gate skips this poll. And always advance the cursor after the response is posted, not before.
Off-network: If Mattermost is unreachable, the gate’s curl will timeout. Make sure it has a reasonable timeout (5–10 seconds) and exits cleanly, or it’ll error spam every 3 minutes. Pre-check connectivity or assume a network partition and let launchd handle the retry.
Message storms: A badly-written Agent A could post 100 questions in 5 minutes. The responder would burn through tokens answering all of them. Mitigate with a wall-clock timeout (e.g., “if this run is taking >15 minutes, abort”) and a lock file preventing concurrent responders.
Latency: A 3-minute poll means up to 3-minute response lag. This bridge is not for real-time decisions or time-critical questions. It’s for async context-gathering while you’re away.
Token scope: The Mattermost API token can read and post to that channel. If it’s compromised, someone could impersonate Agent B. Keep it in your password manager or keychain, not in a config file.
When NOT to use this
This bridge is great for:
- Async status checks (“Is the deploy healthy?”)
- Cross-project context gathering (“What’s on the backlog?”)
- Offline triage (“This error showed up — what’s the pattern?”)
It’s NOT for:
- Real-time questions (latency is too high)
- Money decisions (no spending authority, no deployment gates)
- Secrets or sensitive data (tokens live in channel history)
- High-volume query patterns (use a database if you need that)
The closing move
The bridge is supervised autonomy in practice. Agent B helps when it can, escalates when it can’t, and never acts beyond its guardrails. The polling gate keeps costs low by staying deterministic until a real question arrives. And the audit trail lets you see what happened while you were away.
If you run multiple parallel agents and they need to consult each other, this pattern works. It’s not a replacement for a proper system design, but it’s a useful tool for the ad-hoc, parallel-agent workflow.