Session retrospective terminal workspaces for AI agents

Herding Claude: a real herdr setup, end to end

One terminal, every client, every agent — resumable from a phone.

A retrospective of a single Claude Code session that researched, configured, tested, and shipped this setup — including the exact prompt that kicked it off.

2026-07-20 herdr 0.7.4 Claude Code macOS • Ghostty • Tailscale

01The problem

Plain Ghostty tabs sprawl. You end up with a dozen of them, and the moment you close the window your sessions seem to just disappear.

The goal was a single terminal that organizes itself around how the work actually splits up:

  • Clients always present, sorted by frecency (frequency + recency), so the ones I touch most float to the top.
  • A flagship product — the flagship product — with a dev space (complex, multi-session: research + engineering) and a deployments space (spinning up instances for new clients).
  • A scratch space for random one-off work.
  • iPhone access over Tailscale, so a running agent session is reachable from a phone.

Before landing on herdr, the tools tried were cmux, zellij, and tmux — each got part way, none nailed the whole picture. herdr (a terminal workspace manager built for AI coding agents) was the one that fit, but it needed configuring.

02The promptthe brief

The message that kicked off the session — lightly anonymized and cleaned up.

~
me → Claude Code

Hey — I just started using herdr. Plain Ghostty gives me tab sprawl and I keep losing track of my sessions. I want my client projects (one folder per client) always present, sorted by frecency; then our flagship product with a dev space (complex, multi-session — research and engineering in parallel) and a deployments space (I spin up instances for new clients); and a scratch space for random stuff. I previously tried tmux, zellij, and cmux — the old configs show what I mean. Research what people are actually doing, configure herdr for me, then teach me what you did so I get a highly productive Claude Code setup. I also want to reach all of this from my iPhone with Termius, and I use Tailscale — make it all work smoothly.

03What the session actually did

Ten beats, from cold start to shipped-and-validated.

  1. Found herdr 0.7.0 running on stock defaults, plus leftover zellij layouts — treated those old KDL layouts as the informal spec for what the setup should feel like.
  2. Launched two parallel research agents: one for an official-docs deep-dive, one for a community field report scraped from Hacker News, GitHub issues, and Reddit.
  3. Learned the load-bearing facts: no declarative layouts (you script the CLI), no native frecency, session state persists to session.json.
  4. Learned the Claude Code integration resumes conversations via claude --resume, and that Homebrew installs cannot use herdr update --handoff.
  5. Wrote config.toml — keys, UI, toasts, one experimental flag.
  6. Dispatched an implementer agent to write the bootstrap script against a written spec.
  7. Dispatched a peer-review agent over the resulting diff, then reconciled the feedback.
  8. Validated everything against a sandboxed second herdr server (HERDR_SESSION + HERDR_SOCKET_PATH) with a stubbed claude binary — so no tokens were burned during testing.
  9. Hit two real bugs in the process (see below) and fixed both.
  10. Shipped with a phone-access recipe for Termius + Tailscale + Mosh.

04The mental model

herdr nests four things. Agents are detected inside panes. The server owns the PTYs (pseudo-terminals — the actual running shells); clients just attach and detach.

session (one per user, persisted to session.json)
workspace • "space" (e.g. flagship dev)
tab (e.g. research)
pane
● claude (working)
pane
● claude (idle)
pane
shell

Three persistence layers

These are independent — knowing which is which is the whole trick to never losing work.

1. Detach

Close the client and processes never stop. The server keeps every PTY alive; you reattach later and everything is exactly where you left it.

2. Restart

A full server restart restores from the session.json snapshot, plus — with the experimental pane_history flag — optional screen-content restore.

3. Claude resume

Claude conversations come back via the integration's saved session refs (claude --resume), independent of the terminal state.

05The config

The philosophy: only override what you mean to. Everything else keeps herdr defaults (see them with herdr --default-config). Reload live — no restart — with herdr server reload-config.

  • zellij muscle memory: alt+[ / alt+] hop workspaces, alt+hjkl moves pane focus without the prefix.
  • Jump to workspace N: prefix+shift+1..9.
  • Hunk-diff command pane: prefix+alt+d opens a throwaway diff-viewer pane.
  • mobile_width_threshold = 72: reliably triggers the single-column phone layout in Termius portrait.
  • agent_panel_sort = "priority": agents needing attention float to the top of the sidebar.
  • Toast delivery = "terminal": escalates to the outer terminal (Ghostty → real macOS notifications), works over SSH.
  • pane_history tradeoff: restores screen contents after a restart, but scrollback may hold secrets at rest.
~/.config/herdr/config.toml
# herdr config — only overrides; everything else keeps herdr defaults.
# Full annotated defaults: herdr --default-config
# Apply changes to the running server: herdr server reload-config

onboarding = false

[keys]
# Project-hopping muscle memory ported from zellij (Alt+[ / Alt+] there
# switched project tabs; here projects are workspaces).
# Requires Ghostty's macos-option-as-alt = true (already set).
previous_workspace = "alt+["
next_workspace = "alt+]"
# Jump straight to workspace N.
switch_workspace = "prefix+shift+1..9"
# zellij's Alt layer: move pane focus without the prefix.
focus_pane_left = "alt+h"
focus_pane_down = "alt+j"
focus_pane_up = "alt+k"
focus_pane_right = "alt+l"

# Port of the claude.kdl hunk pane: prefix+alt+d opens a temporary
# pane with the hunk diff viewer, closes when you quit it.
# (Optional upgrade once on 0.7.4+: type = "popup" with width/height
# floats it over the layout instead of splitting.)
[[keys.command]]
key = "prefix+alt+d"
type = "pane"
command = "hunk diff"

[ui]
# iPhone/Termius portrait can exceed 64 cols with a small font; 72 keeps
# the single-column mobile layout kicking in reliably on the phone.
mobile_width_threshold = 72
# Agents needing attention float to the top of the sidebar instead of
# staying grouped under their workspace.
agent_panel_sort = "priority"

[ui.toast]
# "terminal" escalates agent-needs-attention popups to the OUTER terminal
# (Ghostty -> real macOS notifications) and works over SSH. Popups are
# suppressed for the active tab, so only background work alerts you.
delivery = "terminal"

[experimental]
# Restore recent pane screen contents after a full server restart (stored
# in session-history.json). Trade-off: scrollback may contain secrets at
# rest; acceptable on this single-user FileVault Mac.
pane_history = true

06The bootstrap script

herdr has no declarative layouts, so the setup is a script against the socket CLI. Key design decisions:

  • zoxide as the frecency source, with per-client scores summed across all subpaths under the client root.
  • Idempotent by label — safe to re-run; each workspace is created only if its label is absent.
  • Knobs: MAX_CLIENTS, EXTRA_CLIENTS (always-include pins), EXCLUDE.
  • HERDR_SPACES_CLAUDE override lets you swap in a stub claude binary for testing without burning tokens.
  • CLIENT_ROOT is $HOME/Clients here — whatever folder holds one subdirectory per client.
~/.local/bin/herdr-spaces
#!/usr/bin/env bash
# herdr-spaces — idempotently bootstrap herdr workspaces (safe to re-run; each
# workspace is keyed by its label and created only if absent).
set -euo pipefail

# ---- config (edit these) --------------------------------------------------
MAX_CLIENTS=10                              # keep the top-N frecent clients
EXTRA_CLIENTS=("$HOME/Projects/side-project") # always-include absolute paths
EXCLUDE=()                                  # client dir basenames to skip
CLIENT_ROOT="$HOME/Clients"                # frecency root (zoxide)
FLAGSHIP="$HOME/Projects/flagship"
CLAUDE_CMD="${HERDR_SPACES_CLAUDE:-claude}" # override for testing, e.g. a stub
created=0; skipped=0

# ---- helpers --------------------------------------------------------------
die() { echo "herdr-spaces: $*" >&2; exit 1; }
workspace_exists() {  # $1=label
  herdr workspace list | jq -e --arg l "$1" 'any(.result.workspaces[]?; .label==$l)' >/dev/null
}
workspace_id_for() {  # $1=label -> workspace_id | ""
  herdr workspace list | jq -r --arg l "$1" 'first(.result.workspaces[]? | select(.label==$l) | .workspace_id) // empty'
}
first_tab_id() {      # $1=workspace_id -> lowest-numbered tab_id | ""
  herdr tab list --workspace "$1" | jq -r '((.result.tabs? // []) | sort_by(.number))[0].tab_id // empty'
}
tab_id_for_label() {  # $1=workspace_id $2=label -> tab_id | ""
  herdr tab list --workspace "$1" | jq -r --arg l "$2" 'first(.result.tabs[]? | select(.label==$l) | .tab_id) // empty'
}
pane_id_for_tab() {   # $1=workspace_id $2=tab_id -> pane_id | ""
  herdr pane list --workspace "$1" | jq -r --arg t "$2" 'first(.result.panes[]? | select(.tab_id==$t) | .pane_id) // empty'
}
is_excluded() { local x; for x in "${EXCLUDE[@]}"; do [[ "$1" == "$x" ]] && return 0; done; return 1; }
run_in_tab() {        # $1=workspace_id $2=tab_label $3=command — type command into that tab's shell
  local tid pid
  tid=$(tab_id_for_label "$1" "$2"); [[ -n "$tid" ]] || { echo "  ! no tab '$2' in $1" >&2; return; }
  pid=$(pane_id_for_tab "$1" "$tid"); [[ -n "$pid" ]] || { echo "  ! no pane for '$2' in $1" >&2; return; }
  herdr pane run "$pid" "$3" >/dev/null
}
ensure_client() {     # $1=dir $2=label $3=note
  if is_excluded "$2"; then echo "skip $2 (excluded)"; skipped=$((skipped+1)); return; fi
  [[ -d "$1" ]] || { echo "skip $2 (missing dir)"; skipped=$((skipped+1)); return; }
  if workspace_exists "$2"; then echo "skip $2 (exists)"; skipped=$((skipped+1)); return; fi
  echo "create workspace $2 ($3)"
  herdr workspace create --cwd "$1" --label "$2" --no-focus >/dev/null; created=$((created+1))
}
setup_ws() {          # $1=label $2=tab1 $3=cmd1 $4=tab2 $5=cmd2 — create the 2-tab ws only if absent
  local wid tid
  if [[ ! -d "$FLAGSHIP" ]] || workspace_exists "$1"; then
    echo "skip $1 (exists or missing)"; skipped=$((skipped+1)); return
  fi
  echo "create workspace $1"
  herdr workspace create --cwd "$FLAGSHIP" --label "$1" --no-focus >/dev/null; created=$((created+1))
  wid=$(workspace_id_for "$1"); [[ -n "$wid" ]] || { echo "  ! lost $1 after create" >&2; return; }
  tid=$(first_tab_id "$wid")
  if [[ -n "$tid" ]]; then
    herdr tab rename "$tid" "$2" >/dev/null || echo "  ! rename failed in $1" >&2
  fi
  sleep 1; run_in_tab "$wid" "$2" "$3"
  herdr tab create --workspace "$wid" --cwd "$FLAGSHIP" --label "$4" --no-focus >/dev/null \
    || { echo "  ! tab create failed in $1" >&2; return; }
  sleep 1; run_in_tab "$wid" "$4" "$5"
}

# ---- preflight ------------------------------------------------------------
command -v jq    >/dev/null 2>&1 || die "jq not found"
command -v herdr >/dev/null 2>&1 || die "herdr not found"
# No pipes here: grep -q closes the pipe early and herdr panics on
# SIGPIPE (exit 101), which pipefail then propagates. Capture instead.
status_out="$(herdr status 2>/dev/null)" || true
[[ "$status_out" == *"status: running"* ]] || die "no running herdr server"
if [[ "$status_out" == *"compatible: no"* ]]; then
  die "client/server protocol mismatch — restart the herdr server first (herdr server stop; herdr)"
fi

# ---- 1. client workspaces, ranked by summed zoxide frecency ---------------
client_lines=()
if command -v zoxide >/dev/null 2>&1; then
  mapfile -t client_lines < <(
    zoxide query -ls 2>/dev/null | awk -v root="$CLIENT_ROOT/" '
      { line=$0; sub(/^[ \t]*[^ \t]+[ \t]+/,"",line)          # drop score, keep the path
        if (index(line,root)==1) {
          rest=substr(line,length(root)+1); s=index(rest,"/")
          c=(s>0)?substr(rest,1,s-1):rest                     # first component = client dir
          if (c!="") sum[c]+=($1+0) } }
      END { for (c in sum) printf "%.1f\t%s\n", sum[c], c }' \
    | sort -rn | head -n "$MAX_CLIENTS"
  )
else
  echo "herdr-spaces: zoxide not found, skipping frecency clients" >&2
fi
seen=()
for line in "${client_lines[@]}"; do
  score=${line%%$'\t'*}; client=${line#*$'\t'}
  ensure_client "$CLIENT_ROOT/$client" "$client" "score $score"; seen+=("$CLIENT_ROOT/$client")
done

# ---- 2. pinned extra clients (dedup against the frecency set) -------------
for dir in "${EXTRA_CLIENTS[@]}"; do
  dup=0; for s in "${seen[@]}"; do [[ "$s" == "$dir" ]] && { dup=1; break; }; done
  [[ $dup -eq 1 ]] && continue
  ensure_client "$dir" "$(basename "$dir")" "pinned"
done

# ---- 3 & 4. flagship dev + deploy ------------------------------------------
# NOTE: prompts below are typed into the pane wrapped in single quotes —
# keep apostrophes out of them (use em dashes / rewording instead).
setup_ws "flagship dev" research "$CLAUDE_CMD" eng "$CLAUDE_CMD"
deploy_cmd="$CLAUDE_CMD 'You are the deploy operator for this product. Read the Redeploying section of CLAUDE.md. Wait for my instructions — when I say deploy, run the smoke check (bash tests/local_smoke.sh) first, then bash deploy/redeploy.sh, then verify healthz. You also handle provisioning new boxes when I ask.'"
monitor_cmd="$CLAUDE_CMD 'You are the monitor for this product. Check https://yourapp.example/healthz, systemd unit status on the app box (ssh root@yourbox), and recent logs. Give me a one-screen health summary now, then wait for my instructions. Read-only: never restart or deploy anything.'"
setup_ws "flagship deploy" deploy "$deploy_cmd" monitor "$monitor_cmd"

# ---- 5. scratch (skip if a "scratch" or the current "~" workspace exists) --
if workspace_exists scratch || workspace_exists "~"; then
  echo "skip scratch (exists)"; skipped=$((skipped+1))
else
  echo "create workspace scratch"
  herdr workspace create --cwd "$HOME" --label scratch --no-focus >/dev/null; created=$((created+1))
fi

echo "$created created, $skipped skipped"

# ---------------------------------------------------------------------------
# Pin a client:     add its absolute path to EXTRA_CLIENTS, e.g.
#                     EXTRA_CLIENTS=("$HOME/Projects/side-project" "$HOME/Projects/x")
# Exclude a client: add its directory basename to EXCLUDE, e.g. EXCLUDE=("OldClient").
# Show more/fewer frecent clients: change MAX_CLIENTS at the top.
frecency ranking, illustrated

With the client root scanned and scores summed, the ranked output feeding the top-N looks like:

23.5	ClientOne
8.4	ClientTwo
5.0	ClientThree

07Two real bugs found

The war-story section. Both were found during validation, before anything shipped.

bug #1

Piping herdr into grep -q panics it under pipefail

herdr status | grep -q ... fails deterministically in bash with set -o pipefail. Here's the chain: grep -q exits at the first match and closes the pipe → herdr (a Rust binary) gets SIGPIPE on its next write and panics with exit 101pipefail sees the non-zero producer and propagates it. Your script dies for no visible reason.

Fix

Never pipe herdr into an early-exiting consumer. Capture the output with $( ) and substring-match instead:

status_out="$(herdr status 2>/dev/null)" || true
[[ "$status_out" == *"status: running"* ]] || die "no running herdr server"
bug #2

The "sandbox" server wrote back to the live session.json

herdr session persistence is per-usersession.json lives next to the default config, and is not isolated by HERDR_CONFIG_PATH or HERDR_SOCKET_PATH. So a "sandbox" server started on a different socket happily loaded and then wrote back the live session.json — nearly clobbering the real workspace state during testing.

Fix

True isolation needs a named session. Set both:

HERDR_SESSION=sandbox \
HERDR_SOCKET_PATH=/tmp/herdr-sandbox.sock \
herdr

State then lives under ~/.config/herdr/sessions/sandbox/, fully separate from the live session.

08Phone access (iPhone, Termius, Tailscale)

Any SSH client works; this uses Termius over Tailscale. The whole point: reconnect to a running agent session from a phone, with agents still churning in the background.

  1. Install the Tailscale app: https://apps.apple.com/app/tailscale/id1470499037
  2. Install the Termius app: https://apps.apple.com/app/termius-terminal-ssh-client/id549039908
  3. New host: address your-mac.your-tailnet.ts.net, port 22.
  4. Set the startup snippet to just herdr — leave "close session after running" OFF and leave targets unset.
  5. Enable both "Use SSH" and "Use Mosh".
  6. Set the Mosh command (the gotcha below) so mosh-server is actually found:
Termius → Mosh command
/opt/homebrew/bin/mosh-server new -s -c 256 -l LANG=en_US.UTF-8

Why: non-interactive shells don't see Homebrew's PATH, so Termius can't find mosh-server unless you give the absolute path. With mobile_width_threshold at 72, anything ≤72 columns drops you into the single-column mobile layout automatically.

Caveats

The Mac must stay awake (or it can't serve the session). Expect a one-time macOS firewall prompt for mosh-server. Moshi is a mosh-native iOS alternative to Termius. And remember: detach is ctrl+b then q — nothing dies when you do it.

09Cheat sheet

Prefix is ctrl+b throughout.

KeysAction
alt+[ / alt+]Previous / next workspace
prefix shift+1..9Jump to workspace N
prefix gGoto (workspace picker)
prefix wPicker
prefix oJump to notification
prefix cNew tab
prefix 1..9Switch to tab N
alt+h/j/k/lMove pane focus (no prefix)
prefix v / prefix -Split pane (vertical / horizontal)
prefix zZoom pane
prefix eOpen scrollback in editor
prefix shift+gNew git worktree
prefix qDetach (nothing stops)
prefix alt+dHunk diff pane

10Sharp edges & credits

  • Claude state is screen-detected. herdr infers agent status by watching the pane's screen, so it can stick on "idle" when Claude is actually working (tracked as issue #1630). Don't fully trust the dot.
  • Homebrew installs update differently. herdr update is disabled on brew installs. Update with brew upgrade herdr, then herdr server stop && herdr to restart on the new binary.
  • Worktrees are the community's killer feature for running parallel agents on one repo: herdr worktree create --branch x gives each agent its own checkout so they don't step on each other.