Running an agent team in tmux

An agent team in tmux is four pieces: a window per role, send-keys to dispatch work between them, an append-only file as the shared channel, and a polling loop that decides whether a member is idle. All four are buildable in an afternoon. None of them can tell you a member is blocked.

Worth separating two things that share a name first, because the search term collides. Claude Code has a feature called Agent Teams: a lead agent spawning teammates inside its own process, sharing one context, on one machine. That is not what you build in tmux. A tmux agent team is several independent agent processes, possibly different CLIs, that no single orchestrator holds, wired together by you. The topology is different and so are the failure modes, and this post is about the second one.

If you want the tmux fundamentals first, start with tmux for AI coding agents.

the layout

One session for the project, one named window per role. Names, not indexes, because indexes shift when a window closes and every script you write from here on refers to a member by name.

tmux new-session -d -s app -n planner -c ~/Projects/app
tmux new-window  -d -t app  -n backend -c ~/Projects/app
tmux new-window  -d -t app  -n frontend -c ~/Projects/app
tmux new-window  -d -t app  -n reviewer -c ~/Projects/app

Start an agent in each. Roles are just different opening prompts, so put them in the launch:

tmux send-keys -t app:backend 'claude' Enter
tmux send-keys -t app:frontend 'codex' Enter

Mixing CLIs across windows works fine, which is one of the genuine advantages of the tmux version over an in-process team. tmux does not care what’s running in the pane.

dispatch: send-keys, with the sharp edges filed off

The naive call works until a message contains a quote, a $, or a newline. Two rules fix most of it: send the text literally with -l, and send Enter as a separate call.

#!/usr/bin/env bash
# dispatch <session:window> <message...>
set -euo pipefail
target="$1"; shift
tmux send-keys -t "$target" -l -- "$*"
sleep 0.3
tmux send-keys -t "$target" Enter

Why each piece is there:

  • -l sends the argument as literal text. Without it, tmux tries to interpret words as key names, and a message containing the word Enter or Space does something you did not ask for.
  • -- stops tmux parsing a message that starts with a dash as a flag.
  • One quoted argument. Pass the whole message as a single argument. A ; sitting as its own argument is a tmux command separator, and splitting the message across arguments is how it ends up there.
  • Enter as a second call. With -l in effect, the string Enter is text, not a keypress. It has to be its own call without -l.
  • The sleep 0.3. Agent CLIs are full-screen TUIs with input handling that is not always ready the instant text lands. Sending the newline in the same burst as the text drops it often enough to matter. A short pause is the cheap fix, and it is a real fix, not superstition.

Now dispatching between members is one line, from your shell or from inside another agent:

dispatch app:frontend "backend endpoints are on branch api-v2, wire the components"

the shared channel

Agents need a place to write things the whole team can read. The file wins over anything cleverer, but the format matters.

Use an append-only log, one JSON object per line, and never a markdown table or a document anyone edits in place. Two agents doing a read-modify-write on the same file will clobber each other. Two agents appending a short line each will not, because a short write to a file opened in append mode lands whole. Keep lines under a few kilobytes and this holds in practice.

#!/usr/bin/env bash
# say <from> <message...>
set -euo pipefail
from="$1"; shift
printf '{"ts":"%s","from":"%s","msg":%s}\n' \
  "$(date -u +%FT%TZ)" "$from" "$(printf '%s' "$*" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')" \
  >> .team/log.jsonl

Give each agent that script and a line in the project’s CLAUDE.md or AGENTS.md telling it to append after every meaningful step and to read the log before starting work. That instruction is the whole protocol. There is nothing enforcing it.

A separate .team/tasks.jsonl with claim lines works the same way. A member appends {"task":"auth-refactor","claimed_by":"backend"} before starting. It is advisory, not a lock. Two agents can claim the same task inside the same second and both will be right about having appended.

For a hard sync point, tmux has a primitive built for it:

# in the coordinator, blocks until someone signals
tmux wait-for backend-done

# in the backend agent's shell, when it finishes
tmux wait-for -S backend-done

That is an actual rendezvous, not a poll. Use it when frontend genuinely cannot start until backend finishes.

the polling loop

The last piece is knowing whether a member is working, waiting, or done. tmux gives you no state, so you compare screenshots of each pane and call it idle when nothing changes.

#!/usr/bin/env bash
# idle <session>: report which windows have stopped changing
set -euo pipefail
session="$1"
state="${TMPDIR:-/tmp}/agent-idle/$session"
mkdir -p "$state"

tmux list-panes -s -t "$session" -F '#{window_name} #{pane_id}' | while read -r name pane; do
  now=$(tmux capture-pane -p -t "$pane" | shasum | cut -d' ' -f1)
  prev=$(cat "$state/$name" 2>/dev/null || true)
  printf '%s' "$now" > "$state/$name"
  [ "$now" = "$prev" ] && echo "$name quiet" || echo "$name changing"
done

Run it on an interval. The first run is always wrong because it has no previous sample, and a run that catches a pane mid-redraw reports changing on a pane that has been still for ten minutes. Both are survivable.

tmux’s own version of this is monitor-silence, which is less code and roughly the same accuracy:

tmux setw -t app monitor-silence 60
tmux set  -t app visual-silence on

Either way, what you have is a quiet detector. Neither one distinguishes an agent that finished from an agent that is thinking hard from an agent that has been sitting on a permission prompt since you got coffee. They all produce a pane that stopped changing.

the four guarantees you don’t have

Everything above works. It is also a coordination protocol held together by shell scripts, and it is worth being precise about what it doesn’t promise.

No delivery guarantee. send-keys writes bytes to a pty. If the receiving agent was mid-render, or between turns, or already had a half-typed line in its input buffer, your message merges into that buffer or vanishes. send-keys returns success either way, because from tmux’s side the bytes were delivered. Nothing tells the sender the message was actually read as a turn.

No acknowledgement. A member can append to the log claiming it finished, and be wrong, and nothing checks. The protocol is entirely voluntary. An agent that drifts off its instructions stops participating and the rest of the team keeps writing to a channel it has stopped reading.

No blocked signal. This is the one that costs real time. A member stuck on an approval prompt looks exactly like a member working quietly, and every detector above reports the same thing for both. You find out by cycling windows, which means the team’s throughput is capped by how often you remember to look.

No ordering. The log is a pile of appends. If frontend reads it while backend is mid-sequence, it acts on a partial picture, and the only thing preventing that is the prompt you wrote telling it not to.

None of these are tmux doing something wrong. tmux moves bytes between processes and a screen, and a team needs the members’ state, which never enters the byte stream. The gap is structural. That is also the argument in the tmux setup people build for parallel agents.

the coordinated version

crystl quest is the same shape with the four gaps closed. Agents run as independent processes, each in its own session, exactly like the tmux version, but coordination goes through a real channel instead of send-keys into a pty. Messages route to a named agent or broadcast to everyone, delivered sequentially so each member sees what the previous one did rather than racing. Agents talk to each other with a quest_msg function, and an agent that needs a human targets you directly and triggers a notification instead of parking silently. Each member can run in its own git worktree, so the claim-a-task-and-hope pattern is replaced by actual file isolation. The full walkthrough covers roles, isolation modes, and merging at the end.

The blocked-member problem is handled a layer down, by the terminal rather than by quest: a pending approval surfaces as a panel with the tool call in it, plus a notification, and the agent activity panel shows every agent’s live activity in one view. That is state read from the agent’s hooks, not inferred from a pane that stopped scrolling.

build the tmux one anyway

Nothing above is an argument against wiring it yourself. Do it, especially if the team lives on a remote box, because detached sessions surviving a dropped SSH connection is something tmux does and almost nothing else does.

Build it with the failure modes in view. Make the channel append-only from day one, so you skip the week where two agents overwrite a shared markdown file. Use -l and a separate Enter in dispatch from the first version. Reach for wait-for instead of a polling loop wherever the dependency is genuine. And expect that the piece you keep rewriting is the one that tries to work out whether a member is blocked, because that is the one where the information you need was never made available to you.

crystl is free. Sign up at crystl.dev/login.