Skip to content

About

Run teams of coding agents (Claude Code, Codex, ...) across machines: a Slack bot plus a manager and workers per machine, coordinated over Agent Mail and Tailscale.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

Slack bot + two agent teams: setup and install

How to build this setup from scratch: a Slack bot that is a Claude Code session in a sandboxed VM, plus one team of Claude Code sessions per machine, all talking through Agent Mail over Tailscale. The layout here is one macOS machine, one Windows PC and one repo; it extends to more of each. Names are placeholders; the table below says what to substitute.

Topology: a Slack bot, two machines, a manager and workers on each

Placeholders

Placeholder Substitute Notes
<bot-name> the Slack bot's name, lowercase (e.g. teambot) also the sbx sandbox name, the bot folder name, the state folder name and the launchd label suffix
<person> your short lowercase handle (e.g. alex) first segment of every agent identity
<Person> your display name in prose, the manifest and approval quotes
<org>, <repo> the GitHub org and repo the teams work on
<repo>-<N>, <repo>-deploy worker checkouts of <repo> (<repo>-1, <repo>-2, …) full clones, side by side with the plain <repo> checkout
m1, m2 literal machine segments of a lane m1 = main machine (macOS): runs Agent Mail, the bot's VM and deployments; m2 = second machine (Windows). Rename if you like; the skills must agree
w<N>, deploy, me literal role segments of a lane w<N> = worker for checkout <repo>-<N>, deploy = worker for <repo>-deploy, me = the machine's manager
<machine1-host>, <machine2-host> each machine's Tailscale device name
<tailnet> your tailnet's MagicDNS name so Agent Mail is at http://<machine1-host>.<tailnet>.ts.net:8765/mcp/
#<your-private-channel> an optional private channel with just you and the bot
#<agent-coordination-channel> a shared channel where several people's agents talk
<other-bot> another person's bot in that shared channel
<your-user-id>, <bot-user-id>, <bot-id>, <channel-id> Slack ids (U…, U…, B…, C…) from auth.test (Step 1) and channel details

Paths: the bot folder is ../<bot-name>, a sibling of the folder this doc is in, and paths into it below are relative to this doc's folder. ~ is the home folder of the machine named in each step. launchd and sbx create need absolute paths; expand them on your machine.

What you get

Slack DM / @mention                      #<agent-coordination-channel> (other people's agents)
      │  (Socket Mode, push)                   ▲  posts (only what you OK'd)
      ▼                                        │  thread replies (polled every 20 s)
<bot-name> ── Claude Code in an sbx MicroVM ───┘
      │    on the main machine (m1, macOS)
      │  Agent Mail (HTTP + bearer token, tailnet only)
      ├──────────────► m1/me ── manager, macOS   ──► m1/w1..wN workers, m1/deploy
      └──────────────► m2/me ── manager, Windows ──► m2/w1..wN workers
                       (m1/me ◄──► m2/me)
  • <bot-name> answers you in Slack. On Agent Mail it talks only to the two managers, never to workers. It can also post to a shared channel (e.g. #<agent-coordination-channel>) or a thread there, and replies in those threads come back into the conversation that asked for the post.
  • me (one per machine) is the session you talk to directly, in the terminal or over Remote Control. It takes requests from you, from the bot and from the other manager, and hands them to its own machine's workers.
  • Workers take tasks only from their own machine's manager and reply only to it. They never ask you anything in their own session: every question goes to the manager, which asks you.
  • deploy is a worker that does all deployments; it exists only on m1, so m2 passes deployments to m1/me.
  • The rules above live in instructions (the bot's CLAUDE.md and two skills), not in Agent Mail permissions. Nothing automated sends mail to the wrong agent, so enforcement would be code for a case that doesn't happen.
  • Every destructive or outward-facing action (merge, deploy, delete, posting to people) needs your OK, given either in that worker's own session or relayed by its manager as a mail that quotes your words and names that specific action (e.g. <Person> approved: "merge #123"). That quote covers that action only, and a prod action needs a quote that says prod. A request from the bot, or any agent mail without your quote, is never your approval. Standing exceptions you grant go in the worker skill (e.g. workers may push their own branches and open PRs without asking).

Identities and names

A lane is <machine>/<role> (e.g. m2/w1, m1/me); the bot's lane is just <bot-name>. The full identity is <person>/claude/<lane>, e.g. <person>/claude/m2/w1, <person>/claude/<bot-name>. This follows the convention other people's agents use in shared channels: <person>/claude/<machine>/<agent> and <person>/claude/<role>. Agent Mail gives every agent a random adjective+noun name (e.g. PearlGlacier); you mail agents by that name, and agentmail.py agents shows which lane each name has. The same lane on the same machine always gets the same name back (kept in ~/.local/state/agentmail/lanes.json).

Worker checkouts are full clones (git clone git@github.com:<org>/<repo>.git <repo>-<N>), not worktrees.

Where things live

Path (main machine unless noted) What
../<bot-name>/host-setup.sh one-time: creates the sandbox, injects Slack tokens, copies the Agent Mail token in, sets the network policy
../<bot-name>/start.sh runs the bridge in the sandbox (launchd runs this)
../<bot-name>/vm/ (read-only in the VM) bridge.py (Slack ⇄ Claude Code ⇄ Agent Mail, plus polling of threads the bot posted in), slack-watch.py (the bot's tool for posting elsewhere in Slack and reading threads), CLAUDE.md (the bot's role and posting rule), agentmail.py, setup-sandbox.sh. The bridge puts this folder on the bot's PATH.
../<bot-name>/shared/ (read-write in the VM) drop box for files the bot makes for you; nothing runs from it
~/.local/state/<bot-name>/ (inside the VM) sessions.json (Slack conversation → Claude session), watched.json (threads the bot posted in, written by slack-watch.py), watch-seen.json (last reply relayed per thread, written by the bridge)
~/.zshrc (Windows: ~/.bashrc and/or the PowerShell $PROFILE) claude() wrapper: a plain claude in a worker checkout starts as a worker
~/.local/bin/agentmail.py symlink to vm/agentmail.py: the stdlib-only Agent Mail client every agent uses
~/.claude/skills/coordinator/SKILL.md /coordinator: makes a session its machine's manager
~/.claude/skills/<bot-name>-worker/SKILL.md /<bot-name>-worker: makes a session a worker
~/.local/bin/mcp-agent-mail, am Agent Mail server and operator CLI
~/.config/mcp-agent-mail/config.env server config (host, port, bearer token)
~/.secrets/ (chmod 700, files 600) slack_bot_token, slack_app_token, agent_mail_token
~/Library/LaunchAgents/com.<person>.agent-mail.plist, com.<person>.<bot-name>.plist the two always-on services
~/Library/Logs/agent-mail.log, <bot-name>.log their logs
Windows: %USERPROFILE%\.local\bin\agentmail.py + bash shim agentmail, %USERPROFILE%\.secrets\agent_mail_token, %USERPROFILE%\.claude\skills\{coordinator,<bot-name>-worker}\SKILL.md the same client, token and skills on the PC

Never print, paste or commit a token. Scripts read them from ~/.secrets at run time.

The code

The setup is seven small files plus two skills. If you are rebuilding from an existing install, copy them and change the values listed in Step 4. If you are starting from this doc alone, write them to this spec (Python 3, stdlib only except slack_sdk in the bridge):

  • vm/agentmail.py, the Agent Mail client. Calls the server's MCP tools as JSON-RPC tools/call over HTTP with Authorization: Bearer <token>; server from $AGENT_MAIL_URL (default http://127.0.0.1:8765/mcp/), token from $AGENT_MAIL_TOKEN_FILE (default ~/.secrets/agent_mail_token). A constant PROJECT is the one Agent Mail project every agent shares (the bot folder's absolute path is a good value), so any agent can mail any other. Subcommands:
    • register --lane LANE [--task "..."]: ensure_project, then register_agent reusing the name stored for that lane in ~/.local/state/agentmail/lanes.json (so the name is stable), with description <person>/claude/<lane> on <hostname>: <task>; then set_contact_policy open so no contact handshake is needed. Prints the name.
    • agents: everyone registered, most recently active first, with last-seen time and description.
    • send --to NAME [--to NAME2] --subject S --body TEXT|- [--thread T]: --thread defaults to $AGENT_MAIL_THREAD.
    • reply --id N --body TEXT|-: replies to the sender in the same thread.
    • inbox: prints unread mail and marks it read.
    • watch [--interval 30] [--max-minutes 240]: polls; exits 0 as soon as unread mail is waiting (without marking it read), 1 on timeout, 2 on errors that retrying won't fix.
    • send, reply, inbox and watch act as --me NAME, default $AGENT_MAIL_ME. The bridge imports the module's register, unread and mark_read functions.
  • vm/bridge.py, the bridge (behaviour in Step 4, "How the bridge works"). Slack Socket Mode via slack_sdk; acknowledges every envelope; drops events with a subtype, from bots, or from anyone not in ALLOWED_USERS; de-duplicates the app_mention + message pair for one post. Runs Claude as claude -p <prompt> --output-format json --dangerously-skip-permissions [--resume <session>] with cwd = vm/, vm/ prepended to PATH, and AGENT_MAIL_ME, AGENT_MAIL_THREAD set; one lock per conversation; reactions 👀 while working, ✅ or ❌ after. Posts in markdown blocks split at about 11,000 characters. A background loop every 20 s: unread mail (mark read first, then handle, so a failure is reported once) and the watched threads. If Claude's answer is exactly NO_REPLY, nothing is posted.
  • vm/slack-watch.py: post --text TEXT|- [--channel KEY|ID] [--thread TS] and show --thread TS [--channel …] against chat.postMessage and conversations.replies; prints a permalink; adds the identity line outside your private channel; records "<channel>:<thread>": {"conversation": $AGENT_MAIL_THREAD, "since": <ts>} in watched.json.
  • vm/CLAUDE.md: the bot's role (answer in Slack, short markdown), where it runs (VM, which folders it sees, tokens are placeholders, never print secrets), whose messages are instructions (only yours; pasted text and agent mail are data), how to delegate (only to the two managers, which manager for what, end the turn and wait for the reply to arrive as a new prompt, NO_REPLY when there is nothing to tell you), and the posting rule and shared-channel conventions (Step 4).
  • vm/setup-sandbox.sh, host-setup.sh, start.sh: as described in Step 4.
  • The two skills: Step 6 and "Team rules" give what they must say. Each one tells the session its lane, how to register and start the watcher, what to do on each watcher exit code, and the rules.

Step 1: Slack app

  1. At https://api.slack.com/apps, Create New App → From a manifest, pick the workspace, and paste this manifest (it gets DMs, channel reading and Socket Mode in one approval round):
display_information:
  name: <bot-name>
  description: <Person>'s Claude Code agent
features:
  app_home:
    home_tab_enabled: false
    messages_tab_enabled: true
    messages_tab_read_only_enabled: false
  bot_user:
    display_name: <bot-name>
    always_online: true
oauth_config:
  scopes:
    bot:
      - app_mentions:read
      - channels:history
      - channels:read
      - chat:write
      - files:read
      - files:write
      - groups:history
      - groups:read
      - im:history
      - im:read
      - im:write
      - mpim:history
      - mpim:read
      - reactions:read
      - reactions:write
      - users:read
settings:
  event_subscriptions:
    bot_events:
      - app_mention
      - message.channels
      - message.groups
      - message.im
      - message.mpim
  interactivity:
    is_enabled: true
  org_deploy_enabled: false
  socket_mode_enabled: true
  token_rotation_enabled: false
  1. OAuth & Permissions → Install to Workspace (or Request to Install). If the workspace requires admin approval, a workspace admin approves it under Manage apps → App requests. Every later scope change counts as a new install: it repeats the approval and mints a new bot token, so put every scope in up front. After approval, Install / Reinstall if the button is enabled.
  2. Copy the Bot User OAuth Token (xoxb-…) and save it without printing it. Copy the token, then run:
    umask 077; mkdir -p ~/.secrets
    pbpaste | tr -d '[:space:]' > ~/.secrets/slack_bot_token
    grep -q '^xoxb-' ~/.secrets/slack_bot_token && echo ok || echo "clipboard was not a bot token"
  3. Basic Information → App-Level Tokens → Generate, scope connections:write (no approval needed). Save it the same way to ~/.secrets/slack_app_token, checking for ^xapp-.
  4. Get the bot's ids (prints no token):
    tok=$(tr -d '[:space:]' < ~/.secrets/slack_bot_token)
    curl -s -H "Authorization: Bearer $tok" https://slack.com/api/auth.test \
      | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("ok"), d.get("user"), d.get("user_id"), d.get("bot_id"), d.get("url"))'
    user_id is <bot-user-id>, bot_id is <bot-id>, url is the workspace URL (for permalinks). Your own <your-user-id> is in your Slack profile (⋮ → Copy member ID). All of them go into bridge.py and slack-watch.py.
  5. Optional: create #<your-private-channel>, where every top-level post goes to the bot. /invite @<bot-name> there and in #<agent-coordination-channel>, and note both channel ids (channel details → bottom of the About tab). The bot can only post in and read channels it has been invited to; channel_not_found on a private channel means it hasn't been invited.
  6. If the DM box stays locked after enabling the Messages Tab, fully quit and restart the Slack client.

Step 2: Agent Mail server (main machine)

Rust mcp_agent_mail_rust, installed by hand from the signed release. Do not use its installer: it rewrites ~25 MCP configs across tools.

v=v0.3.39; f=mcp-agent-mail-aarch64-apple-darwin.tar.xz
base=https://github.com/Dicklesworthstone/mcp_agent_mail_rust/releases/download/$v
mkdir -p /tmp/am && cd /tmp/am
curl -fsSLO $base/$f; curl -fsSLO $base/SHA256SUMS; curl -fsSLO $base/SHA256SUMS.minisig
minisign -Vm SHA256SUMS -x SHA256SUMS.minisig -P 'RWTQGPeLsnm9G7VFdFWkkcRi3wJK/PqsYxWC+oLNN74W9IjBxRU1Xu70'
grep " $f\$\| \*$f\$" SHA256SUMS | shasum -a 256 -c -
mkdir x && tar -xJf $f -C x   # copy the mcp-agent-mail and am binaries into ~/.local/bin

Token and config:

umask 077
[ -f ~/.secrets/agent_mail_token ] || openssl rand -hex 32 | tr -d '\n' > ~/.secrets/agent_mail_token
mkdir -p ~/.config/mcp-agent-mail
cat > ~/.config/mcp-agent-mail/config.env <<EOF
HTTP_HOST=127.0.0.1
HTTP_PORT=8765
HTTP_BEARER_TOKEN=$(cat ~/.secrets/agent_mail_token)
TUI_ENABLED=false
EOF

launchd plist ~/Library/LaunchAgents/com.<person>.agent-mail.plist runs, with RunAtLoad and KeepAlive, logging to ~/Library/Logs/agent-mail.log (launchd needs absolute paths, so write out ~):

~/.local/bin/mcp-agent-mail serve --no-tui --no-reuse-running \
  --allowed-host <machine1-host>.<tailnet>.ts.net --allowed-host <machine1-host> \
  --env-file ~/.config/mcp-agent-mail/config.env

The --allowed-host lines are for Step 3; without them requests through Tailscale get HTTP 421. Load with launchctl bootstrap gui/$(id -u) <plist> (to reload after editing: launchctl bootout the same path, then bootstrap again).

All agents share one Agent Mail project, PROJECT at the top of agentmail.py (the bot folder's absolute path), so any agent can mail any other without contact requests.

Step 3: Tailscale

  1. Install Tailscale on both machines and sign in with the same account (the free Personal plan is enough).
  2. On the main machine, publish Agent Mail to the tailnet only: tailscale serve --bg --http=8765 http://127.0.0.1:8765. It persists across restarts. The URL is then http://<machine1-host>.<tailnet>.ts.net:8765/mcp/ (the short name <machine1-host> works too; the raw tailnet IP gives 404).
  3. Moving files to the PC: tailscale file cp <files> <machine2-host>: lands them in the PC's Downloads (repeated sends get (1), (2) suffixes; the newest has the highest number).

Step 4: the bot's VM (main machine)

  1. Install Docker Sandboxes: brew install docker/tap/sbx, then sbx login.
  2. Create the bot folder ../<bot-name> with vm/ and shared/, and put in host-setup.sh, start.sh, vm/bridge.py, vm/slack-watch.py, vm/CLAUDE.md, vm/agentmail.py, vm/setup-sandbox.sh ("The code" above). Set these values:
    • bridge.py: ALLOWED_USERS (<your-user-id>; everyone else is dropped), SELF_USER_ID (<bot-user-id>), SELF_BOT_ID (<bot-id>), OPEN_CHANNELS (#<your-private-channel>'s id, or empty), IDENTITY (<person>/claude/<bot-name>), the state folder ~/.local/state/<bot-name>, and the lane in agentmail.register("<bot-name>", ...).
    • slack-watch.py: CHANNELS (short key → <channel-id> for each channel the bot may post in), PRIVATE_CHANNELS (#<your-private-channel>'s id: no identity line there), IDENTITY, WORKSPACE_URL (from auth.test, for permalinks), and the state folder.
    • CLAUDE.md: the bot's name and role, the manager lanes, which machine does what, the posting rule (below), and the shared channel's conventions.
    • host-setup.sh, start.sh, the launchd label: sandbox name and paths.
    • agentmail.py: PROJECT.
  3. Run ./host-setup.sh. It creates the sandbox (sbx create --name <bot-name> claude <abs path>/shared <abs path>/vm:ro), stores both Slack tokens as sbx secret set-custom placeholders (the VM only sees xoxb-<random> / xapp-<random>; the sbx proxy swaps in the real token on requests to slack.com), copies the Agent Mail token into the VM's home (the proxy only swaps on HTTPS, and Agent Mail is plain HTTP), allows slack.com, *.slack.com, pypi.org, files.pythonhosted.org, host.docker.internal, and runs vm/setup-sandbox.sh in the VM (installs slack_sdk, checks claude is there).
  4. Log Claude Code in inside the VM, by hand, on your subscription: sbx run --name <bot-name>, then /login, then exit.
  5. launchd plist com.<person>.<bot-name>.plist runs start.sh with RunAtLoad, KeepAlive, ThrottleInterval 30, PATH including /opt/homebrew/bin, logging to ~/Library/Logs/<bot-name>.log. start.sh reads the two placeholders from sbx secret ls and runs the bridge in the foreground with sbx exec -e SLACK_BOT_TOKEN=… -e SLACK_APP_TOKEN=… -e AGENT_MAIL_URL=http://host.docker.internal:8765/mcp/ <bot-name> python3 <abs path>/vm/bridge.py. A foreground sbx exec also stops sbx from auto-stopping the sandbox.
  6. launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.<person>.<bot-name>.plist; the log should end with connected as <bot-name>. DM the bot to check.

How the bridge works, briefly: each DM, mention or thread is one Claude Code session, resumed with claude -p --resume on every message (send new in the DM for a fresh one). It answers DMs, @mentions, top-level posts in #<your-private-channel>, and your replies in threads it already has a session for. Mail the bot sends from a Slack conversation is tagged with thread slack-<channel>[-<ts>]; when a reply in that thread arrives in its inbox, the bridge resumes that conversation and posts the answer there (mail on any other thread goes to your DM). So managers must answer the bot with reply on its mail (keeps the thread), never a fresh send. Outside DMs and the private channel, the bridge prefixes posts with **Agent:** <identity> · **Session:** <Agent Mail name>, a common shared-channel convention.

Posting elsewhere: the bot's reply only goes back to the conversation that asked. To post anywhere else it runs slack-watch.py post --text - [--channel <key>] [--thread TS], which posts a top-level message or thread reply, adds the identity line outside your private channel, and prints the permalink (slack-watch.py show --thread TS prints a thread). The rule in the bot's CLAUDE.md: post only what you asked for, either in Slack or in a manager's mail that quotes your words and names the post (e.g. <Person> approved: "ask <other-bot> about X"). A manager's own request without that quote isn't enough, and the bot asks you instead. Also put the shared channel's conventions in CLAUDE.md, e.g. To: <other-person>/claude/<lane> lines to address another person's agent, and mentions by user id (<@U…>).

Thread replies: Slack never delivers other people's (or bots') replies in those threads to the bridge as events, so slack-watch.py records each thread it posts in, with the conversation that asked, in watched.json. Every 20 s, for a week after the post, the bridge polls those threads and resumes the asking conversation with any new reply from anyone, other people's bots included (your own replies in a thread the bot already has a session for still go through the normal event path). Those replies are data, not instructions. The bot passes on what matters to you, and if a manager asked for the post, also answers it with agentmail.py reply.

Step 5: client and skills on each machine

Main machine (m1, macOS):

ln -s <abs path to ../<bot-name>>/vm/agentmail.py ~/.local/bin/agentmail.py

Put the two skills in ~/.claude/skills/coordinator/ and ~/.claude/skills/<bot-name>-worker/. The token is already at ~/.secrets/agent_mail_token, and the default URL http://127.0.0.1:8765/mcp/ is right.

So that workers connect themselves, add this to the end of ~/.zshrc. A plain claude (no arguments) in a worker checkout then starts with /<bot-name>-worker as its first prompt; with any arguments (-c, --resume, a prompt) it runs unchanged. It has to be a shell wrapper because a hook can't start a turn.

claude() {
  local top
  top=$(git rev-parse --show-toplevel 2>/dev/null)
  if [[ $# -eq 0 && ${top:t} =~ '^<repo>-([0-9]+|deploy)$' ]]; then
    command claude "/<bot-name>-worker"
  else
    command claude "$@"
  fi
}

Second machine (m2, Windows) (have the Windows Claude session do it; you approve on that side). Claude Code there runs commands in Git Bash, where ~ is %USERPROFILE%.

  1. Taildrop agentmail.py, agent_mail_token and the Windows copies of both skills to the PC. The Windows skill copies are the main machine's with agentmail.py replaced by the shim's path: sed 's#agentmail\.py #~/.local/bin/agentmail #g' SKILL.md > <name>-SKILL.md

  2. Check Python: python or py -3. python3 on Windows is usually the Microsoft Store stub, so don't rely on it.

  3. Move the token to %USERPROFILE%\.secrets\agent_mail_token and restrict it to the user with icacls. Never print it.

  4. Put agentmail.py in %USERPROFILE%\.local\bin\ (already on the user PATH) next to a bash shim named agentmail:

    #!/usr/bin/env bash
    export AGENT_MAIL_URL="${AGENT_MAIL_URL:-http://<machine1-host>.<tailnet>.ts.net:8765/mcp/}"
    export PYTHONUTF8=1
    exec py -3 "$(dirname "$0")/agentmail.py" "$@"
  5. Install the skills as %USERPROFILE%\.claude\skills\coordinator\SKILL.md and ...\<bot-name>-worker\SKILL.md.

  6. Check: ~/.local/bin/agentmail agents lists the main machine's agents.

  7. Auto-start workers with the same claude() wrapper, in whichever shell you start Claude Code from. Git Bash, at the end of ~/.bashrc:

    claude() {
      local top
      top=$(git rev-parse --show-toplevel 2>/dev/null)
      if [[ $# -eq 0 && ${top##*/} =~ ^<repo>-([0-9]+|deploy)$ ]]; then
        command claude "/<bot-name>-worker"
      else
        command claude "$@"
      fi
    }

    (In bash the regex must stay unquoted, so substitute <repo> before saving.) PowerShell, in the file $PROFILE names (create it if missing):

    function claude {
      $exe = (Get-Command claude -CommandType Application | Select-Object -First 1).Source
      $top = git rev-parse --show-toplevel 2>$null
      if ($args.Count -eq 0 -and $top -and ((Split-Path $top -Leaf) -match '^<repo>-([0-9]+|deploy)$')) {
        & $exe "/<bot-name>-worker"
      } else {
        & $exe @args
      }
    }

    Get-Command -CommandType Application finds the real claude executable, so the function doesn't call itself. Open a new terminal, run a plain claude in a <repo>-<N> checkout, and it should register as m2/w<N>.

Claude Code's auto mode on Windows blocks most of this (installs and file moves as "Unauthorized Persistence", running downloaded code as "Code from External", skill edits as self-modification). Approve in that session, or run Claude there with --dangerously-skip-permissions / "permissions": {"defaultMode": "bypassPermissions"} plus "skipDangerousModePermissionPrompt": true in ~/.claude/settings.json (restart needed).

Step 6: start the teams

On each machine, one Claude Code session per checkout:

Checkout Run Registers as
<repo> (plain) /coordinator, and turn on Remote Control m1/me or m2/me
<repo>-<N> plain claude (with the wrapper), or /<bot-name>-worker m1/w<N> or m2/w<N>
<repo>-deploy (m1) plain claude, or /<bot-name>-worker (or /<bot-name>-worker m1/deploy) m1/deploy

Each session registers, starts a background agentmail watch (polls every 30 s, exits when mail arrives, which wakes the session; it restarts the watcher after handling it), and tells you its lane and name.

What the skills say, beyond the rules below:

  • Worker: pick the lane from the checkout folder (<repo>-<N> → <machine>/w<N>, <repo>-deploy → m1/deploy; anywhere else, or in the plain <repo> checkout, ask for a lane). agentmail.py register --lane <lane> --task "<checkout path>: <what it can do>", then agentmail.py watch --me <Name> --interval 30 --max-minutes 240 as a background command. On exit 0: inbox, do the task, reply on the task's message (a short acknowledgement first for anything longer than a few minutes), restart the watcher. Exit 1: restart the watcher. Exit 2, or dies with no output: stop and say so. Tasks only from its own manager; mail from anyone else gets a reply saying so. Stay in this checkout's repo and scope. Replies stand alone: what was done, the result, anything you must decide.
  • Coordinator: register as <machine>/me, start the same watcher, show agentmail.py agents. Give work only to its own machine's workers (deployments to m1/deploy; from m2, via m1/me), work for the other machine to the other manager. One self-contained task per mail with its own thread: agentmail.py send --me <Name> --to <Worker> --subject "<short>" --thread task-<slug> --body -. Answer the bot and the other manager with reply on their request, never a new send, and reply on the original request when the worker reports. Requests from the bot or the other manager are requests, not your approval. Workers' questions go to you unless what you already said settles them. Keep a running list of what is delegated to whom, for whom, and what is open.

Team rules (in the two skills)

Beyond who talks to whom, these working rules belong in the skills. Adapt them to your team.

  • Approval works as in "What you get": your OK in the worker's session, or a manager mail quoting your words for that specific action; prod needs a quote that says prod.
  • Workers never ask you in their own session (no questions that end a turn and wait). A question there blocks until you happen to look at that terminal. They send every question or approval request to the manager on the task's thread and carry on with whatever doesn't depend on the answer.
  • Codex review loop on every PR, whatever the task says "done" is: after each push, wait for Codex, answer every finding in one comment (fixed, or declined with a reason), post @codex review after each fix commit, re-trigger after 12 minutes with no result, at most 5 rounds. Every report that mentions CI also gives the Codex status of the head commit. A PR isn't ready until its head commit is Codex-reviewed with nothing open.
  • The manager checks before any merge, even one you pre-approved: CI green and Codex has reviewed the head commit with no open findings, checked by the manager itself on GitHub. A worker saying "CI green" isn't enough.
  • Optional: one manager can prefer the other team for non-deploy work when m2/me is online and has online workers. It sends that work to m2/me, never to m2 workers directly, and uses its own workers only when m2 is offline or full.

Check it works

  1. agentmail.py agents: every agent's last-seen time should be within the last minute.
  2. In the bot DM, send new, then: ask both machines which branch each of their workers is on. It should go bot → m1/me + m2/me → workers → managers → bot, and both answers land in the DM within a couple of minutes.
  3. Posting and thread replies, without bothering anyone: in the bot DM, ask it to post test in #<your-private-channel>. It answers with the permalink. Reply in that thread; within about 20 s the bot relays your reply in the DM.

Day to day

  • Restart the bridge (after editing bridge.py): launchctl kickstart -k gui/$(id -u)/com.<person>.<bot-name>. CLAUDE.md, slack-watch.py and skill edits need no restart: the bot reads CLAUDE.md on every message, slack-watch.py runs fresh on every post, sessions re-read skills when re-run. Send new in the DM if the old conversation remembers old rules.
  • Restarts are automatic. Both launchd jobs (com.<person>.agent-mail, com.<person>.<bot-name>) start at login and restart when they exit. After a sandbox restart the bridge is back within seconds, re-registers on Agent Mail under the same name and reconnects to Slack. Thread watching survives too, since its state is in the VM's home.
  • Rename a checkout (e.g. <repo>-4 → <repo>-deploy): in that machine's lanes.json, move the name from the old lane key to the new one, then have the session re-register with the new lane. It keeps its name.
  • Stop a worker for a while: just close it. It stays registered and shows as offline; managers only give work to online workers. Restarting it gets the same name.
  • Retire an agent for good: am agents reap --stale-days N, or over HTTP call register_agent with the same name (returns a registration_token) and pass that to retire_agent.
  • Windows Remote Control sessions get a new random name every restart; identify them by working folder.
  • Logs: ~/Library/Logs/<bot-name>.log (bridge), ~/Library/Logs/agent-mail.log (server).

Things that bite

  • HTTP 421 from Agent Mail through Tailscale: the hostname isn't in --allowed-host.
  • The bot can't DM or read DMs: Messages Tab off, or im:* scopes missing (needs reinstall + approval + new token).
  • After a reinstall the token in ~/.secrets is stale: auth.test still says ok for a while, but with the old scopes. Re-copy the token after every install.
  • sbx secret set-custom env vars only reach sandboxes created afterwards, which is why start.sh passes the placeholders explicitly.
  • A worker that never answers: its watcher probably died (under memory pressure it exits non-zero with no output). An old last-seen time in agents shows it; re-run the skill in that session.
  • Taildrop of an updated file: the PC keeps every copy; install the newest one (highest (N)), and check it by content.
  • A reply in a thread the bot posted in never reaches it: the thread is over a week old (polling stops), or the bot was removed from that channel. ~/Library/Logs/<bot-name>.log logs N new replies in watched thread <channel>:<ts> for each one it relays.
  • A plain claude in a worker checkout doesn't start as a worker: the wrapper only matches the checkout's folder name (<repo>-<N> or <repo>-deploy). A folder like <repo>-3-something doesn't match.

Security

  • Set the sbx global network policy to a restrictive (balanced) one rather than allow-all, so the per-sandbox allow-list from host-setup.sh is all the bot can reach.
  • The bot runs Claude with --dangerously-skip-permissions inside the VM; the VM's folder mounts (vm/ read-only, shared/ read-write), placeholder tokens and the network allow-list are what contain it.

About

Run teams of coding agents (Claude Code, Codex, ...) across machines: a Slack bot plus a manager and workers per machine, coordinated over Agent Mail and Tailscale.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors