A localhost dashboard and control surface for herdr terminal sessions. herdr manages a herd of AI agent sessions; corral is where you pen and control them — a Kanban board over your sessions with live pane output, an attention feed, and a real in-browser terminal.
Running many concurrent agent sessions has a specific shape: almost all of the time nothing needs you, and then two things do — a session blocks waiting on your input, or a long delegated task finishes. corral surfaces exactly those transitions across every machine you work on, and lets you respond in place.
- Kanban board over live sessions — group sessions into boards and task cards; every card shows real-time status (working / blocked / idle), the session's recap, and Claude metrics (model, context %, cost, account rate-limit windows).
- Card log — each card carries an append-only log beside its description: sessions write decisions and blockers, corral stamps the lifecycle. The board badges a card with entries you have not seen; the card's Log tab shows them, filterable to notes only.
- Attention feed — a deterministic transition detector (no LLM, no polling races) that tells you which session blocked or finished, with a tail of its output.
- Live terminal — click a card, get a fully bidirectional xterm.js terminal attached to the real session over WebSocket; close the modal and control returns to your own terminal.
- Spawn — launch new agent sessions into a chosen environment and repo from the UI.
- Multi-environment — local sockets and remote boxes over SSH, in one view.
- Multi-account Claude — work and personal Claude accounts side by side, each with its own rate-limit windows (see below).
- Self-diagnostics — the 🛟 health panel in the side rail runs install and connectivity
checks (local, remote, and a corral-update check) on a schedule and folds anything worth
seeing into a badge; a real problem opens the panel once, everything else stays one click
away. Fix issues turns that into a spawned session: an ad-hoc card pre-filled with the
outstanding problems, ready for the
corral-doctorskill to work through (see below).
Prerequisites: Node ≥ 20.11, herdr ≥ 0.7.1 on this
machine (and on any remote box you add) — two features want more: corral sizing a spawned pane to
your terminal panel needs herdr ≥ 0.8.0, and headless_cols/headless_rows need ≥ 0.8.2 (see
Upgrading herdr) — Claude Code on every machine you spawn sessions on,
and jq — required if you want the live Claude metrics (model / context % / cost /
rate-limit windows). The metrics capture is optional; jq is not optional for it. Both
helper scripts hard-depend on jq and are deliberately best-effort, so without it they write
nothing and log nothing: the cards just show no metrics, which is indistinguishable from "no
data yet" — and corral's own self-diagnostics report a missing jq as a fatal verdict. corral
appends --name (and, when chosen, --model / --remote-control) to every
non-resume launch; this has only been verified against Claude Code 2.1.232 — an older CLI
lacking one of these flags will fail the launch visibly in the pane. The cross-session messaging
the corral skill describes was verified on that same build; per the Claude Code changelog it landed
in 2.1.224, which is older than anything available here to check against.
# 0. prerequisite check — without jq the metrics capture silently does nothing
command -v jq >/dev/null || echo "install jq first — the live metrics need it"
# 1. herdr's Claude integration (per machine) — REQUIRED: recaps, live metrics and live session state
herdr integration install claude
# 2. configure your environments
mkdir -p ~/.corral
cp environments.example.json ~/.corral/environments.json
$EDITOR ~/.corral/environments.json
# 3. one herdr server per session named in environments.json — see "Running herdr" below.
# Without them the board comes up empty. Each session gets its own socket, so each
# environment points at its own; repeat the line per session, or run just one.
# nohup matters: herdr server dies on SIGHUP, i.e. when you close the window.
nohup herdr --session work server >/dev/null 2>&1 &
nohup herdr --session personal server >/dev/null 2>&1 &
# 4. run — from a terminal OUTSIDE Claude Code (see "Launching corral" below)
npm install # node-pty is native — compiles against your Node ABI
npm run dev # Vite (http://127.0.0.1:5173) + API (http://127.0.0.1:8787), proxied
# production:
npm run build && npm start # serves API + built UI on http://127.0.0.1:8787
# 5. per-config-dir helper files — needed for the live metrics (and the optional theme).
# Skip this and roughly half of each card's information is simply not wired up.
D=~/.claude # repeat for every ~/.claude* dir you want surfaced, on every machine
cp scripts/corral-status-capture.sh "$D/corral-status-capture.sh"
cp scripts/statusline-command.sh "$D/statusline-command.sh" # skip if you have your own
chmod +x "$D/corral-status-capture.sh" "$D/statusline-command.sh"
# ...then register the statusline in "$D/settings.json" — see the full section below for
# that snippet, the optional theme, remote boxes and multiple config dirs.That gets you the board, attention feed, live terminal and recap — plus the live Claude metrics, which are what step 5 buys. Step 5 is summarised above and documented in full under Claude statusline and Installing the Claude helper files below.
The server binds 127.0.0.1 only and refuses other hosts. There is no auth — corral trusts
whoever can reach the loopback interface. On a single-user machine that's just you; on a shared
or multi-user box, any other local user or process that can reach 127.0.0.1 has the same
access, including the session-attach endpoint.
Putting a reverse proxy in front of corral removes that protection, and the proxy must
therefore do the authenticating itself. The loopback bind is the whole access control, so
anything the proxy can reach, its callers can reach: spawning sessions, closing and resuming
them, killing a pane, reading pane contents, writing theme files. Do not expect corral's
non-loopback Host rejection to catch this — a default proxy_pass sends the loopback upstream
as the Host, so the check passes. Note also that the live terminal will not work through a
proxy: its WebSocket Origin allowlist is loopback-only by design.
corral is a client: it talks to a herdr server over a unix socket and never starts one for you, so that server's lifetime is yours to manage. Verified against herdr 0.8.2 on macOS and 0.7.5 on Linux.
The TUI is optional — corral needs the server, not the terminal UI:
nohup herdr --session work server >/dev/null 2>&1 &One server per session, and each local environment in environments.json points at one of them —
so if you keep separate sessions for separate Claude accounts, you run one of these per session. The
session is created on first start; there is no herdr session create and nothing to set up
beforehand. Run it from a terminal outside Claude Code, for the same reason corral itself refuses to
start there (see Launching corral).
Recommended on macOS: skip the hand-started form above and go straight to a LaunchAgent per session (Keep it running, below). A hand-started server does not survive logout or reboot; a LaunchAgent does, starts clean (never inherits a Claude Code session's environment), and is what shows up as an ordinary entry under System Settings → General → Login Items & Extensions — one entry per session, matching the one-server-per-session model above.
Use the --session <name> flag, not the HERDR_SESSION environment variable. Every herdr pane
exports HERDR_SOCKET_PATH, and that variable silently outranks HERDR_SESSION — run
HERDR_SESSION=work herdr server from inside a pane and it reports herdr server is already running
against whichever session that pane belongs to, which is not a session you named. The flag wins over
the ambient variable, so it works from anywhere.
nohup is not decoration: herdr server is an ordinary child of your shell and dies on SIGHUP, so
a bare herdr --session work server & goes down when you close the terminal window. (The TUI form,
herdr --session work, forks a detached daemon instead and does survive — that is why servers started
that way outlive their tab.) A supervisor, below, removes the question entirely.
That command puts the socket at ~/.config/herdr/sessions/work/herdr.sock, and that exact path is
what the environment's socket must contain in environments.json. Pin it explicitly rather than
leaving socket out: a local environment with no socket inherits the launching shell's ambient
HERDR_SOCKET_PATH, and once servers start from a launchd agent or a systemd unit there is no such
variable to inherit.
herdr --session work with no server argument attaches to a server that is already up. Run it
when nothing is up and it starts one inside your terminal tab — that works, and Terminal.app's close
dialog will list your whole fleet, but closing the tab does not end the session.
A hand-started server does not survive a reboot. Give it a supervisor. One service per session, named so you can find it later.
Check first — herdr will not let two servers share a session (the second exits with herdr server is already running and the first is untouched), but the supervisor then keeps retrying a job that cannot
succeed:
herdr session list # already serving this session by hand?
launchctl print gui/$UID/dev.corral.herdr.work # macOS: non-zero exit means not installed
systemctl --user list-unit-files 'corral-herdr*' # Linux: same questionmacOS — ~/Library/LaunchAgents/dev.corral.herdr.work.plist. ProgramArguments[0] must be an
absolute path: launchd gives an agent a minimal PATH, and a bare herdr fails with EX_CONFIG
before it ever runs. The agent panes themselves need no PATH entry — the pane's shell supplies that.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>dev.corral.herdr.work</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/herdr</string> <!-- substitute $(command -v herdr) -->
<string>--session</string>
<string>work</string>
<string>server</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/dev.corral.herdr.work.log</string>
<key>StandardErrorPath</key>
<string>/tmp/dev.corral.herdr.work.log</string>
</dict>
</plist>launchctl bootstrap gui/$UID ~/Library/LaunchAgents/dev.corral.herdr.work.plistlaunchd does not re-read a plist you have edited — launchctl bootout gui/$UID/dev.corral.herdr.work
and bootstrap it again.
Two macOS-only things to expect. A LaunchAgent belongs to the GUI login session, so the server comes
back when you log in — not while the machine sits at the login window; a Mac used as an unattended
host wants something else. And macOS raises a "herdr can run in the background" notification and adds
an entry under System Settings → General → Login Items & Extensions, marked Item from unidentified
developer. That entry is named after the binary, so it reads herdr regardless of the Label — and
if anyone turns its toggle off, the server stops starting at login and nothing says so.
Linux — ~/.config/systemd/user/corral-herdr-work.service:
[Unit]
Description=herdr server (work)
[Service]
Type=simple
ExecStart=%h/.local/bin/herdr --session work server
Restart=always
RestartSec=2
[Install]
WantedBy=default.targetsystemctl --user daemon-reload
systemctl --user enable --now corral-herdr-work.service
loginctl enable-linger "$USER" # survive logout, come up at bootDid it work? herdr session list shows the session running, and the server's own log is at
~/.config/herdr/sessions/work/herdr-server.log. If the session never appears, the server never ran,
so that log will not exist — look at the supervisor instead: launchctl print gui/$UID/dev.corral.herdr.work and read last exit code (plus /tmp/dev.corral.herdr.work.log), or
journalctl --user -u corral-herdr-work.service. The two supervisors give up differently: launchd
retries indefinitely, while systemd stops after a burst of restarts and parks the unit in failed.
Once you have fixed the cause, systemctl --user start corral-herdr-work.service brings it back.
Stopping it. herdr server stop on its own will not keep it down — the supervisor replaces the
process within seconds — and it acts on whichever server your shell's socket points at, not on a
session by name. Stop the service, then the session:
launchctl bootout gui/$UID/dev.corral.herdr.work # macOS
systemctl --user stop corral-herdr-work.service # Linux
herdr session stop workThat is also the herdr-upgrade sequence; start the service again afterwards (on macOS, bootstrap it
again — bootout only lasts until the next login). To remove supervision for good, delete the plist
or systemctl --user disable corral-herdr-work.service.
What supervision buys is precise: the server comes back on its own, and the workspace/tab/pane layout
comes back with it — but the processes inside those panes do not. A restarted server hands you the
same panes running fresh shells. For agent panes, herdr's [session] resume_agents_on_restore is what
relaunches the agent into its conversation session.
Keep resume_agents_on_restore off. Recovery goes through corral instead, and how depends on the
restart: after one that loses herdr's own state, fleet:restore resumes the mirrored sessions; after
one that keeps it, the panes come back dormant and each is resumed by opening it (see Fleet mirror &
restore).
A herdr upgrade does not stay contained to itself. herdr's CLI is one binary per machine, but its servers are one per session, and corral shells out to that CLI for every call — so a single upgrade mismatches the whole fleet at once, and the board goes dark with:
protocol_mismatch: client protocol N is newer than server protocol M
until every server has restarted. Protocol bumps are not reliably announced — 0.8.2 bumped the
protocol (19 → 20) with no changelog entry — so treat any herdr upgrade as requiring a fleet
restart, and check the new CLI against a live server before relying on it: herdr pane layout
returns the mismatch immediately.
Restart each server with the Keep it running stop-then-start sequence, one session at a time. Homebrew installs have herdr's live handoff disabled, so restarting is the only path, and it kills the processes running in that session's panes — the sessions themselves stay resumable from the board, but work in flight does not survive. Restart the session that holds your own corral agent last, so you keep a working terminal for the rest of the fleet.
Once every server is back, [server] headless_cols / headless_rows in
~/.config/herdr/config.toml (herdr ≥ 0.8.2) sets the width a pane is born at when no client is
attached — raise it if spawned sessions come up narrow. A pane created through the socket API is
born at headless_cols minus the sidebar width, so 220 yields a 188-column pane. corral separately
sizes each pane it spawns to the last width your terminal panel reported (herdr ≥ 0.8.0, where the
command corral uses for that exists); the two are complements — the config covers panes corral did
not create, and panes created before corral has ever seen a terminal panel.
Do not keep herdr's TUI and corral's in-browser terminal attached to the same pane at once. The pane's
geometry follows whichever client was active last, so moving between them resizes it and rendering
tears. herdr has no setting that pins pane size, so the fix is to use one at a time: ctrl+b then q
detaches the TUI and leaves the server and every agent running.
Attaching the TUI to an already-headless server is otherwise fine.
Already started one inside a tab? It cannot be moved out without stopping it, and stopping it takes the agents with it. Adopt the recipe above at your next reboot or planned restart.
Scroll speed. A pane running a TUI — every Claude Code session — gets at most one wheel report per
wheel event, and a trackpad flick or a finger drag already reaches that ceiling, so xterm's own
scrollSensitivity has no headroom left to give there. The gear in the top right of the board header
sets how many wheel events corral emits per real one (1–10, default 3), which does cross it. It acts on
pixel-mode wheel input — Chrome, Safari, trackpads, touch — and leaves Firefox's line-mode wheel alone.
Stored per browser in localStorage, so a laptop and a phone keep independent values, and it applies to
sessions opened after the change.
herdr integration install claudeinstalls herdr's own Claude Code hook, once per machine — not per config dir, not per
session. It is what makes a Claude-launched pane's agent record carry source: "herdr:claude"
and a live agent status (working / blocked / idle) alongside the Claude session UUID that record
is contributed to; without it, a pane herdr owns is indistinguishable from an ordinary shell.
herdr integration status reports one of current, outdated, or not installed per Claude
config dir — run it after every herdr upgrade, the same way you'd check the helper
files after a git pull, since the two can drift out of sync independently of each
other.
What stops working without it: the attention feed never fires. corral detects a session blocking or finishing entirely from herdr's own agent status — there is no second source for that state, and a remote environment has no local fallback to read it from instead. An environment with the integration missing or outdated still renders a board, still spawns sessions, and still shows a live terminal; its cards simply never transition, which looks exactly like a quiet fleet until you notice nothing has moved in hours.
Killing the herdr server kills every pane; sessions survive only as transcripts. corral keeps a
running mirror of the live fleet (fleet-mirror.json in the board store, gitignored) so it can
bulk-resume it after a restart that loses herdr's own state: npm run fleet:restore [-- --dry-run] [-- --env <id>] re-lists each environment, skips whatever is already alive, and resumes the rest via
claude --resume <uuid>. Run --dry-run before killing herdr as a pre-upgrade check — a nonzero
unmirrored count means the mirror is lagging and it is not yet safe to kill the server. See
design-spec.md §18 for the write policy and its residual risks, and above for the
resume_agents_on_restore requirement.
Recovering a mirror you don't trust: stop corral, delete fleet-mirror.json, start corral.
This discards every record that is not currently live, so only do it with a healthy, fully-up
fleet — it is not a fix for a fleet that is itself mid-outage.
After a restart with herdr's own state intact (not a state-loss restart — see design-spec.md
§18), herdr's panes come back as metadata only: dormant until something opens them. corral reads a
dormant pane as a live session, so the board shows it idle rather than detached, and
fleet:restore answers skipped_alive for it instead of resuming it. Opening the pane (attaching
to it) is what actually materializes the agent.
Launch from a normal terminal, never from inside a Claude Code session. corral refuses to start there, and prints why:
corral preflight
✗ launched from inside a Claude Code session
Two things go wrong when it does. corral passes its whole environment to every child process, so every
herdr call and every live-terminal attach carries that Claude session's variables. And any local
environment without an explicit socket inherits HERDR_SOCKET_PATH from the pane — so corral talks
to that pane's herdr and shows you a real, healthy fleet belonging to the wrong machine. Nothing on
the board looks wrong, which is what makes it worth refusing.
To override for a single launch:
CORRAL_ALLOW_UNDER_CLAUDE=1 npm run devPrefer that to exporting it in your shell profile: exported, the guard is off everywhere and you stop noticing. While the override is active every start prints a reminder that it is disabled.
What the check cannot see: an herdr server that was itself started from a Claude session. That process's environment is not readable on macOS. If sessions behave oddly and corral is clean, relaunch your herdr session servers from a terminal outside Claude Code too.
A missing or invalid environments.json now stops the launch too, with the validation error
rendered inside the preflight report instead of a stack trace — npm run dev aborts before Vite
starts rather than serving a page over a dead API.
After a reboot, herdr servers must be up before npm run dev — automatically if you followed
Running herdr, by hand otherwise. Then open the UI. corral
renders the board from persisted state, so it comes up looking healthy whether or not herdr is
running; the cards simply stop changing.
Create sessions from the corral UI, not by typing claude into a pane yourself — the UI uses the
environment's spawnCommand (e.g. a profile-specific claude-work).
This section covers upgrading corral itself. Upgrading herdr is a separate operation — see Upgrading herdr.
# If package-lock.json is the only dirty file and the diff is `libc` / `hasInstallScript` churn,
# that is your npm version rewriting it — discard it, `npm install` regenerates it below.
git checkout -- package-lock.json
git pull --ff-only
npm install # node-pty is native — it may need a rebuild across Node versions
npm run build # production only
npm run check # optional and fast: typecheck + lint + testsThen restart — and signal the process that actually holds the port, not the npm wrapper.
npm start is sh -c → tsx → node, and killing the top of that chain leaves the node
grandchild listening, so the next start fails with EADDRINUSE for no visible reason:
# Linux
kill "$(ss -ltnp 2>/dev/null | awk -F'pid=' '/127.0.0.1:8787/{split($2,a,","); print a[1]}')"
# macOS
kill "$(lsof -nP -iTCP:8787 -sTCP:LISTEN -t)"git pull does not touch your installed copies. corral-status-capture.sh,
corral-claude-hook.sh, skills/corral/ and skills/corral-doctor/ are installed into
~/.claude* — outside this checkout — by the per-config-dir install step (see Installing the
Claude helper files), so if they are copies,
an upgrade that changes any of them leaves the installed copy running the old version until you
re-copy it (cp locally, scp again for a remote config dir). Link them once instead and this
stops being a step — ln -sf for the files, and for the two skill directories remove the old copy
first, because ln -s into an existing directory nests the link inside it rather than replacing
it. Local only: there is no scp equivalent of a symlink for a remote config dir.
Install the theme only after upgrading to ≥ v0.3.2 — the theme-sync-on-mount fix landed there, so installing it against an older build means testing the old behaviour.
You don't have to track releases by hand: the health panel's update-check row asks GitHub for
this repository's latest release — once every six hours after an answer, once every fifteen minutes
after a failure — and shows "Update available" when there is one, linking the releases page so you
can read everything published since your build. It is a recommendation, so it lights the rail's muted dot
rather than the problem count. Every way the check can fail to answer — GitHub unreachable, rate-limited, or the
check switched off — reads n/a and says which in the row's own title. Turn it off with
UPDATE_CHECK_ENABLED=false; docs/adr/0006
records what the request does and does not carry.
Environments live in a JSON config file, not in source — everyone runs their own boxes.
Default path ~/.corral/environments.json (override with $CORRAL_CONFIG; the data
home ~/.corral is $CORRAL_HOME). Loaded and Zod-validated once at startup; the
server fails fast with a clear message if missing. Environments are never editable via the
API — a runtime-set sshHost would turn the server into an SSH relay.
Each entry describes one place corral can see and spawn sessions into:
id/label—idis the stable, URL-safe key corral routes on (letters, digits,.,_,-);labelis the human name shown in the UI. Both required.kind: "local"— talks to a herdr socket on this machine. With nosocketit inherits the ambientHERDR_SOCKET_PATH(launch corral from the right herdr context or set it).kind: "remote"— talks to a box over SSH (sshHost,socket,herdrBinrequired). Every ssh call corral makes to a remote host — polling, reads, file writes and the live terminal — shares one connection per host (sshControlMaster, socket in$CORRAL_HOME/ssh/), whatever your own~/.ssh/configsays. An unreachable environment keeps its last-good snapshot and its cards stop changing. The 🛟 health panel in the right-hand rail flags it —env-reachableturns amber there with the reason — and the server records it in the log as well; corral also names a missingherdr/sshat startup.spawnCommand— what corral runs to start a new agent session in this environment. Defaults toclaude. One hard constraint: it must be a single token — no spaces and no arguments, which the config schema rejects outright, so wrap any flags in a script. It does not have to be an executable file: corral types the launch line into the pane withherdr pane run, and that pane runs an interactive shell, so a function or alias from your.zshrc/.bashrcresolves there just as an executable onPATHdoes. Put it in the interactive rc file, not.zprofile/.bash_profile— herdr's[terminal] shell_modedefaults to a login shell only on macOS. A script is still the more portable choice, and it is what the Multiple Claude accounts example below uses. corral now appends--name <session-name>— plus--model <model>and--remote-control <session-name>when those are chosen — to every launch except--resume, so the command must forward its arguments. Theexec … claude "$@"wrapper shown under Multiple Claude accounts below already does. One that hard-codes its arguments drops the flags: the session starts, but with Claude's auto-generated name, the last-used model and no Remote Control.mcpSocket(remote only, optional) — an absolute path on the REMOTE box where corral forwards an MCP socket, so sessions over there get thecorral_*tools. Absent means they do not. Each remote environment needs its own path — two sharing one is refused at startup, because a session would then resolve to the other environment's cards. See MCP on a remote environment.claudeConfigDirs— which~/.claude*dirs corral scans on this box for recap and the statusline metrics (local defaults to~/.claude; set it for profile-split or remote — see the statusline section).repos— the repositories you can spawn sessions into on this environment (details below).
repos maps a short name → directory, and the UI's Spawn button offers those names.
It's per environment — list a repo under the env you want to launch it in, pointing the name
at the directory the session should start in:
{
"id": "local", "label": "Local", "kind": "local",
"repos": { "corral": "~/code/corral", "api": "~/code/my-api" }
}When you spawn, corral opens a fresh herdr workspace/tab in that directory and runs the
environment's spawnCommand there — so each path must be a real directory on that
environment's machine. Path rules follow the shell that cds into them: local paths may
use ~ (~/code/corral); remote paths must be absolute (/home/me/svc) — ~ is not
expanded on the remote shell. A repo you didn't list can't be spawned into by name; you can still
spawn into an already-open herdr workspace instead. See environments.example.json for
complete local and remote entries.
The names are also what corral_spawn's repo argument takes, and a name that isn't configured
comes back as a refusal listing the ones that are — from the UI you pick from a menu, and an
agent gets the same menu the moment it guesses wrong. One workspace per repository is the
convention corral spawns by: naming a repo lands the session in that repository's workspace,
joining it if it already exists and creating it at the configured path otherwise, and the new tab
starts at that configured path either way.
If you keep separate Claude accounts (say, work and personal), give each one its own config dir
and a tiny wrapper script on PATH. A shell function works too (see spawnCommand above), but a
script is portable across shells and across remote environments, and it is the form the
argument-forwarding example below depends on.
# ~/bin/claude-work — and ~/bin/claude-personal alongside it, with ~/bin on PATH
#!/bin/sh
exec env CLAUDE_CONFIG_DIR="$HOME/.claude-work" claude "$@"chmod +x ~/bin/claude-work ~/bin/claude-personalEach account also needs its own herdr socket. A local environment with no socket inherits
whatever HERDR_SOCKET_PATH corral itself was launched under — so if two environments both omit
socket they route to the same herdr instance and show the same sessions twice. Give each
account its own named herdr session — herdr --session work server,
herdr --session personal server, one supervised service each (see
Running herdr) — and point that environment's socket at the session's own
~/.config/herdr/sessions/<name>/herdr.sock. Only one local environment should omit socket — the
zero-config one that inherits whichever session you launched corral from.
Then wire each account to an environment in environments.json: spawnCommand says which
wrapper launches sessions there, and claudeConfigDirs tells corral which config dirs to
scan. Recap, model, context %, cost, and the account-wide 5h/7d rate-limit windows are
surfaced per account — you can see one account approaching its window while the other is
fresh. The work / personal entries in environments.example.json show the full pattern.
Recap needs only the herdr integration above. The live metrics on each card — model, context %, cost, and the account-wide 5h/7d rate-limit windows — come from your Claude statusline.
How it works. Claude Code runs a statusline command on every refresh (debounced ~300 ms)
and pipes it a JSON blob — model, cost, context window, session id — on stdin; whatever
the command prints becomes your statusline. You register that command per config dir in
settings.json:
{ "statusLine": { "type": "command", "command": "~/.claude/statusline-command.sh" } }corral's scripts/corral-status-capture.sh reads that same stdin JSON, maps it to corral's
schema, and writes <configDir>/corral-status/<session_id>.json — which corral reads (locally
or over SSH) and never persists. It hooks in via one non-blocking line that tees stdin to the
capture script:
printf '%s' "$input" | "$CONFIG_DIR/corral-status-capture.sh" "$CONFIG_DIR" >/dev/null 2>&1 &Which script do you point command at?
- You already run your own statusline script — keep it. Add just the inject line above,
after your script has read stdin into
$inputand resolved$CONFIG_DIR(the config dir, e.g.${CLAUDE_CONFIG_DIR:-$HOME/.claude}). Do not also install corral'sstatusline-command.sh. - You don't have one — use corral's ready-made
scripts/statusline-command.sh. It reads stdin, resolves the config dir, renders a compactmodel · dir · ctx% · costline, and runs the inject for you — a working statusline and corral metrics in a single file.
Requires jq. Best-effort: the inject is backgrounded, so it never delays or breaks the
statusline (Claude cancels a slow statusline mid-run). The 5h/7d windows appear only for
Pro/Max accounts, after the first API response.
corral can live-flip the light/dark base of a Claude custom theme from the web theme
toggle — it rewrites only the base field of <configDir>/themes/corral.json, and Claude Code
hot-reloads it so the TUI follows. It never creates that file. Remote environments follow too: corral
reads and rewrites the file over the shared ssh connection, for each of the environment's
claudeConfigDirs that has one (a remote that cannot be reached is skipped). Once the file is in
place, set "theme": "custom:corral" in that dir's settings.json (or run /theme and pick
corral). Edit overrides in the preset to taste; only base is machine-managed.
Recommended — a session that cannot see its own context pressure has to guess when to hand
off, and a late guess costs more than an early one. corral can tell a session about its own
context-window pressure before it asks — a
UserPromptSubmit hook injects a short [corral] ctx {pct}% (notice|nudge|urgent) signal once
context crosses 30/40/60%, and a SessionStart hook primes the protocol for what to do about it
once per session (and again after /compact). Both read the same
corral-status/<session_id>.json the statusline capture already writes — this hook reads the
file corral-status-capture.sh writes, so that script must already be installed and wired into
your statusline command (see above) — without it this hook silently does nothing (best-effort,
same contract as the capture script).
Thresholds are configurable in ~/.corral/config.json (falls back to 30/40/60 if absent or
malformed):
{ "hooks": { "ctxThresholds": [30, 40, 60] } }Requires jq and the skills/corral/ skill installed in the same config dir (see the table
below) — the hook reads its context-pressure protocol out of SKILL.md rather than duplicating
the text.
The same script also carries a second, independent signal: [corral] card empty, on the same
UserPromptSubmit event, when the session's bound card has a blank description. It asks corral's
own API (GET /api/card-signal, loopback-only, same address resolution as the MCP
server) whether the pane's card is empty, with a 1-second timeout — a missed check
is silent and changes nothing. Preconditions, each failing silently: skills/corral/SKILL.md must
carry its own card-signal marker pair (independent of the ctx-signal pair above — either can be
missing without disabling the other); HERDR_PANE_ID must be set (every herdr pane sets it); curl
must be installed; and the corral server must answer within a second. CORRAL_URL (or
HERDR_DASH_PORT) must reach it exactly as address resolution describes for the MCP
server — this hook is a second client of the same address. Turn it off without touching
SKILL.md:
{ "hooks": { "cardSignal": false } }Remote environments do not get this signal — resolveSelf filters to local before it looks at the
snapshot, so a remote pane's card is never resolved and the check always answers "not empty" for
one.
The statusline and theme pieces live per Claude config dir — every ~/.claude* dir you
want surfaced, on every machine. Local and remote are the same files in the same place; only
the copy command differs (cp vs scp + ssh). Into each config dir:
| File | Source in this repo | When you need it |
|---|---|---|
corral-status-capture.sh |
scripts/corral-status-capture.sh |
always (it writes the metrics file) |
statusline-command.sh |
scripts/statusline-command.sh |
only if you have no statusline script of your own |
themes/corral.json |
themes/corral.json |
only for the optional theme |
skills/corral/ |
skills/corral/ |
recommended with the MCP server; only on the machine running corral |
skills/corral-doctor/ |
skills/corral-doctor/ |
optional — needed by the 🛟 Health panel's Fix issues flow |
corral-claude-hook.sh |
scripts/corral-claude-hook.sh |
recommended — proactive context-pressure signal |
Local (default ~/.claude; repeat for each extra dir such as ~/.claude-work):
Link them rather than copy them, when the config dir is on the same machine as the checkout.
A copy goes stale on the next git pull and has to be redone by hand; a link is simply correct
again. helper-drift follows symlinks when it hashes, so a linked file is never reported as
drifted — nothing to notice, nothing to redo.
D=~/.claude
R=$(pwd)
mkdir -p "$D/themes" "$D/skills"
ln -sf "$R/scripts/corral-status-capture.sh" "$D/corral-status-capture.sh"
ln -sf "$R/scripts/corral-claude-hook.sh" "$D/corral-claude-hook.sh" # context-pressure hook
ln -sf "$R/themes/corral.json" "$D/themes/corral.json" # optional theme
cp "$R/scripts/statusline-command.sh" "$D/statusline-command.sh" # copy — see below
chmod +x "$D/statusline-command.sh"
# Directories: ln -s into an existing directory silently nests the link INSIDE it, so clear it first.
rm -rf "$D/skills/corral" && ln -s "$R/skills/corral" "$D/skills/corral"
rm -rf "$D/skills/corral-doctor" && ln -s "$R/skills/corral-doctor" "$D/skills/corral-doctor"
echo "corral-status/" >> "$D/.gitignore" # if the config dir is version-controlledEvery line above is safe to re-run over an existing install — that is what ln -sf and the
rm -rf before each directory link are for. Plain ln -s is not: onto an existing file it stops
with File exists, and onto an existing directory it silently creates the link inside that
directory and leaves the stale copy in place, which is the one failure you would not notice.
ln -sfn does not help there either — only removing the directory first does.
statusline-command.sh is copied, not linked, because it is the one file you may own: if you
already run your own statusline, keep it and add just the inject line instead (see
Statusline). That is also why helper-drift ignores this file — an edited copy is
expected. If you use corral's script unmodified you can link it like the others.
Use cp instead of ln when a link cannot work: a remote config dir (the scp step below has no
equivalent), a config dir that must outlive the checkout being moved or deleted, or a checkout you
switch branches in — a linked skill follows whichever branch is checked out.
Remote (over SSH — H is the environment's sshHost, D its config dir, e.g.
/home/me/.claude):
H=my-ssh-host; D=/home/me/.claude
scp scripts/corral-status-capture.sh "$H:$D/corral-status-capture.sh"
scp scripts/statusline-command.sh "$H:$D/statusline-command.sh" # skip if it has its own
scp scripts/corral-claude-hook.sh "$H:$D/corral-claude-hook.sh" # recommended — context-pressure hook
ssh "$H" "chmod +x $D/corral-status-capture.sh $D/statusline-command.sh $D/corral-claude-hook.sh && mkdir -p $D/themes"
scp themes/corral.json "$H:$D/themes/corral.json" # optional themeThen, in each config dir's settings.json (edit it on the box where the dir lives), point
the statusline at the script and — if you copied the theme — select it:
{
"statusLine": { "type": "command", "command": "/absolute/path/to/statusline-command.sh" },
"theme": "custom:corral"
}If you installed the context-pressure hook, register it under both events in the same
settings.json:
{
"hooks": {
"SessionStart": [
{ "matcher": "startup|resume|clear|compact",
"hooks": [{ "type": "command", "command": "/absolute/path/to/corral-claude-hook.sh" }] }
],
"UserPromptSubmit": [
{ "hooks": [{ "type": "command", "command": "/absolute/path/to/corral-claude-hook.sh" }] }
]
}
}The dirs you install into must match each environment's claudeConfigDirs in
environments.json — that's exactly where corral looks for the metrics file.
- Loopback only — the server binds
127.0.0.1and refuses anything else; there is no auth layer to get wrong. - Anti-DNS-rebinding
Hostcheck — every REST request must carry a loopbackHost(127.0.0.1,::1, orlocalhost), or none at all. This is what actually enforces the loopback boundary in-app: a page whose DNS has rebound to127.0.0.1becomes same-origin and would otherwise reach the whole API despite the bind. - The attach endpoint is the highest-privilege surface (
GET /api/sessions/:env/:paneId/attach, WebSocket): Origin allowlist (WebSockets bypass same-origin policy — this is the CSRF guard), a spawn cap + token-bucket rate limit, heartbeat + pty reaping, and an audit log at$CORRAL_HOME/attach-audit.log(keystroke content deliberately not logged — it would capture operator secrets). - xterm output is untrusted — clipboard-write (OSC 52), web links, and window report/response sequences are all disabled, so hostile pane output can't inject synthetic keystrokes into a live agent.
- Upload endpoint (
POST /api/envs/:env/uploads, the drag-to-attach file surface) — local environments write to a temp file on this machine; remote environments stream the bytes over the shared ssh connection into a private (0700)corral-upload.*directory in the remote's temp dir, which the remote OS clears (corral does not sweep it). TheHostcheck above applies here too, but multipart is a CORS-simple content type, so it is not sufficient on its own: the route adds an Origin allowlist and a 25 MB body-size cap. - All herdr/SSH calls use
execFilewith argument arrays — no shell string interpolation; remote commands quote user tokens withshell-quote. - Environments are trusted startup config — never writable through the API.
- Outbound traffic is nameable —
sshto whichever remote environments you configured is inherent to using them and has no switch; on top of that, two optional checks can be turned off independently: the self-diagnostics remote probe's own SSH round (REMOTE_PROBE_ENABLED), and one request toapi.github.comasking whether this repository has a newer release (UPDATE_CHECK_ENABLED). The GitHub request carries no body, no query and nothing about your fleet, and never follows a redirect; GitHub sees the request and your IP, as it would for agit fetch. Behind a cache it runs once every six hours after an answer and once every fifteen minutes after a failure — so a repository GitHub cannot answer for at all, such as a fork with no releases yet, costs about a hundred requests a day rather than four. Set either variable tofalseto stop that half. Seedocs/adr/0006.
HERDR_DASH_PORT (8787) · HERDR_DASH_HOST (127.0.0.1) · HERDR_DASH_POLL_MS (30000 — the
cheap poll driving the attention feed) · ATTENTION_MIN_WORK_MS (600000 — a delegated task
must run ≥10 min for its finish to count) · CORRAL_HOME (~/.corral) ·
CORRAL_CONFIG ($CORRAL_HOME/environments.json) · BOARD_DATA_DIR (defaults to
$CORRAL_HOME — see Architecture for why this must be a fresh directory) ·
RECAP_ENABLED (true) · STATUSLINE_ENABLED (true) · FOCUS_TRANSLATION_ENABLED (true — corral
focuses a session's herdr tab when you open its terminal and restores the previously focused tab when
you close it, so the pane goes through a real focus-out and Claude can write its own recap again. Note
that Claude also refuses to write one unless the account's rate-limit status is exactly allowed, which
corral cannot see or change — see docs/adr/0005; set to false to leave herdr's focus untouched) · CORRAL_ALLOW_UNDER_CLAUDE (unset — set to
exactly 1 to start anyway from inside a Claude Code session; see Launching corral).
WebSocket attach: WS_MAX_CONCURRENT (3) · WS_RATE_PER_WINDOW (10) / WS_RATE_WINDOW_MS
(10000) · WS_HEARTBEAT_MS (30000) · WS_KILL_GRACE_MS (2000) · WS_PROBE_GRACE_MS (2000).
Zombie-tab reaper (closes the shell-only tab left when Claude exits; logs each reap as
zombie_reaped): ZOMBIE_REAP_ENABLED (true) · ZOMBIE_REAP_GRACE_MS (180000 — clamped up at boot
to a floor derived from HERDR_DASH_POLL_MS; to disable, use the flag, never a short grace).
Self-diagnostics sweep: DIAGNOSTICS_INTERVAL_MS (60000 — set to 0 to turn the background sweep
off entirely; POST /api/diagnostics/refresh still runs one on demand) · DIAGNOSTICS_VERSION_TTL_MS
(600000, floor 1000 — how long a herdr/Claude version probe is cached before the sweep re-runs it) ·
REMOTE_PROBE_ENABLED (true — set to false to disable the outbound SSH probe of remote
environments' install health; their remote rows then read n/a naming this switch) ·
SSH_CONTROL_PERSIST_S (600 — how long the shared ssh connection to a remote host stays open after its
last use; 0 is ssh's "indefinitely") · UPDATE_CHECK_ENABLED (true — set to false to stop corral asking GitHub whether a newer release
exists; the update-check row then reads n/a naming this switch, and no outbound HTTP request is
made at all).
It also reads $CORRAL_HOME/config.json for hooks.ctxThresholds and hooks.cardSignal — see
Claude context-pressure hook.
MCP server: CORRAL_URL (defaults to http://127.0.0.1:$HERDR_DASH_PORT — read by
the MCP process and by corral-claude-hook.sh's card-empty check, see the note there) ·
BRIEF_MAX_BYTES (16384) · BRIEF_CLEANUP_DELAY_MS (600000
— backstop only; a brief is normally deleted by the launch command that reads it).
For deeper live scrollback set pane_history = true in ~/.config/herdr/config.toml.
corral also ships an MCP server (mcp/index.ts) so a Claude session running inside a herdr pane
can see its own assignment and drive its own lifecycle instead of an operator retyping it by hand
— see ADR 0002 for why. Register it with absolute
paths, so it resolves from whatever directory a session happens to start in:
claude mcp add --scope user corral -- /path/to/corral/node_modules/.bin/tsx /path/to/corral/mcp/index.tsOnce per Claude config dir, not once per machine. --scope user writes into the config dir
that is active when you run it, so a session started under a different one simply has no corral
tools — no error, just nothing there. If you run the split-account setup above, repeat it for each:
CLAUDE_CONFIG_DIR=~/.claude-work claude mcp add --scope user corral -- /path/to/corral/node_modules/.bin/tsx /path/to/corral/mcp/index.tsRegister it that way only in config dirs on the machine running corral. That MCP process talks to the corral server over that machine's loopback, which a remote box cannot reach. Remote environments get the tools a different way — see MCP on a remote environment.
The Claude integration is a prerequisite here, not just for recaps. corral discovers sessions by
running herdr agent list, which comes in two halves. herdr itself reports each pane's coordinates
and agent status — enough for corral_whoami to identify the caller and find its card. But the
Claude session UUID is contributed by the integration (herdr labels it source: "herdr:claude"),
and that UUID is the key corral uses to find a session's transcript and its statusline file. So
without herdr integration install claude, corral_whoami reports session id: not registered yet
permanently and leaves model, ctx and cost empty — which removes exactly the signal a session
watches to notice its own context pressure and hand off in time.
It also gates the board's live session state: a pane with no session id has nothing to join Claude's own registry record to, so its row reads "starting" — permanently, on a machine where the integration is missing.
The same is true of a pane herdr's hook never saw, integration or not: the hook reports nothing unless
HERDR_ENV, HERDR_SOCKET_PATH and HERDR_PANE_ID are all set in that pane, so a session started by
hand rather than through herdr reads "starting" for its whole life. Start sessions through herdr (or
corral) and this does not arise.
It resolves that address from its own environment (CORRAL_URL, else
http://127.0.0.1:$HERDR_DASH_PORT), and it inherits the pane's shell, not the corral server's. So
if you run corral on a non-default port, either export HERDR_DASH_PORT where Claude starts or pin
it at registration with --env CORRAL_URL=http://127.0.0.1:<port> — otherwise every tool reports
unreachable against port 8787 while the server is running perfectly well somewhere else.
corral-claude-hook.sh's card-empty check (above) resolves the same address the same way, but it is
a second, independent client — pinning CORRAL_URL at MCP registration reaches only the MCP
process, not the hook, since the hook inherits the pane's shell directly rather than an --env
flag. Exporting HERDR_DASH_PORT (or CORRAL_URL) in the shell where Claude starts reaches
both, because both are children of that shell.
The ten tools:
corral_whoami— this session's pane/tab/workspace, Claude session id, model/context/cost, and its bound task card; call it first.corral_fleet— one bounded line per session across every environment, for cross-session triage.boardIdandtaskIdnarrow it to the sessions on one card,boardIdalone to one board;offsetpages pastlimit.corral_task_bind— link this session to an existing task card. Called without ids it lists the open cards on every board, or on one withboardIdalone, sorted by priority and then newest first, 50 per page withoffsetfor the next.corral_task_create— create a card on a board, with no session attached; which session created it is written as its first log entry.corral_board_read— survey one board, including cards in closed columns: open cards first, then closed ones, each group by priority and then newest first.status,open,priorityandq(a title substring) narrow the list;offsetpages it, and the footer of a full page names the next value to pass with the same filters. Astatusthat is not a column of that board is refused with the ids that are.corral_task_read— a card in full (the bound card, or another by{boardId, taskId}): its description with itsdescription rev, and the recent part of its log.corral_whoamirenders the description as a one-line preview and the log as a count, because that call is repeated many times a session and inlining either on every one would flood the context; this is the opt-in full read, and the one to call before rewriting the description.kindnarrows the log to what a session wrote (note) or to corral's own lifecycle events;beforepages back further, taking the cursor id from an earlier read's entry headers or paging footer.corral_task_update— update a card's title, description, status, or priority: the bound card, or another by{boardId, taskId}.descriptionis a full-replacement write guarded by compare-and-swap — pass thedescription revcorral_task_readprinted asbaseRev, and a write over a description that changed since is refused. Another card cannot be moved into a closing column.corral_task_log— append one entry to a card's log (the bound card, or another by{boardId, taskId}). Append-only, so two sessions on one card never overwrite each other; the server stamps the time and the writer, and the oldest entries are evicted once the card is full.corral_spawn— start a new session on this session's card, with a brief as its first message;reposays which project it lands in.corral_session_close— stop this session (target: "self") or one on the same card; suspend, not destroy.targethas no default.
Every tool refuses an argument it does not declare, rather than dropping it and acting on the caller's own card or pane.
The server registers no tools outside herdr — launched with HERDR_ENV/HERDR_PANE_ID unset,
it connects but declares no tool capability at all, so a non-herdr session sees no corral tools and
pays nothing for the connection. (Because the capability is absent rather than empty, a tools/list
sent anyway comes back Method not found — expected, not a fault.) Installing it at user scope is
therefore safe for any non-herdr session too. A spawn brief works for local and remote environments alike (a remote one is streamed over ssh into a private temp directory on that machine), and the audit
trail it leaves records coordinates and size, never contents. Where the new session lands is the
caller's to state: omit repo and it joins the caller's own workspace (a new tab beside it, so
a worktree checkout stays visible); pass repo and it lands in that repository's workspace, at
its configured path. There is no inferred default — a spawn with neither (a cross-environment
handoff, say, where the caller has no workspace over there) is refused with the target
environment's configured repository names, rather than guessed at. Phase 1 has no path to write into another
existing session's pane — the spawn brief is the only agent-authored text that reaches a pane at
all, and that pane is always brand-new. corral_spawn and corral_session_close carry MCP's
destructiveHint annotation (and say so in their descriptions); corral_whoami, corral_fleet and
corral_task_read carry readOnlyHint. Those are hints for the harness, not enforcement — nothing in the server
requires confirmation. The actual control is the operator's Claude Code permission configuration:
simply don't allowlist the two destructive tools, same as any other destructive tool call.
A session on a kind: "remote" environment can have the same tools, over the SSH connection corral
already holds to that box. corral reverse-forwards a unix socket onto the remote and serves MCP on
it, from the corral host; on the remote, a small shim pipes Claude's stdio into that socket. Nothing
listens on a TCP port, corral's HTTP port is never forwarded, and there is no corral checkout on the
remote to keep in step with the server. ADR
0009 is the full
reasoning; this is the setup.
1. Let corral's key forward. A key pinned with restrict refuses forwarding, which is usually
the right default and has to be relaxed on purpose, for this one key. On the remote, in
~/.ssh/authorized_keys, add port-forwarding to corral's line:
restrict,port-forwarding,pty,from="<corral host>" ssh-ed25519 AAAA… corral@<corral host>
That is enough — AllowStreamLocalForwarding already defaults to yes, so sshd_config needs no
change. corral will not edit authorized_keys for you: relaxing your own hardening is your call.
2. Name the socket in the environment's config. An absolute path on the remote, its own per environment:
{ "id": "<env>", "kind": "remote", "sshHost": "<host>", "socket": "…", "herdrBin": "…",
"mcpSocket": "/home/<remote user>/.corral/mcp.sock" }corral creates the parent directory (mode 700) and manages the socket itself. Restart corral to pick the config up — it is read once at startup.
3. Install the shim on the remote, per config dir. Copy scripts/corral-mcp-shim.mjs over (it is
dependency-free and needs only Node 18+, no corral checkout), then register it in each Claude config
dir on that box, pointing it at the same path you configured above:
scp scripts/corral-mcp-shim.mjs <host>:~/.corral/mcp-shim.mjs
ssh <host> 'claude mcp add --scope user corral \
--env CORRAL_MCP_SOCKET=/home/<remote user>/.corral/mcp.sock \
-- node /home/<remote user>/.corral/mcp-shim.mjs'The path is given once here rather than defaulted in the shim, so there is a single source of truth: a shim pointed at a path corral does not serve fails with a connect error naming neither side.
What a remote session gets, and what it does not. The card tools behave exactly as they do
locally — the board is shared. What is scoped is the corral host: corral_spawn starts sessions
only on the session's own environment, corral_session_close closes only sessions there (a card can
hold sessions on several environments, including panes on your own machine), and corral_fleet is
not offered at all. When corral is down or the tunnel is not up, the shim exits with "corral is not
connected" and the session simply has no corral tools — it never serves a stale card. The
context-pressure and card-empty hook signals stay local-only.
The 🛟 health panel checks this surface: mcp-tunnel per environment (up or down, read from the
tunnel loop), and mcp-registered / mcp-shim-installed per config dir. An environment without
mcpSocket reads "not configured", never a fault. mcp-registered also runs on local config
dirs, as an info recommendation.
A tool description explains its own tool. None of them can carry the vocabulary they all assume — board, card, link, detached — or the orderings that span several tools, and getting one of those wrong loses work: a handoff that closes the session before writing the card arrives empty. Two pieces cover that, and they are not alternatives.
Automatic, nothing to install. The server sends a short orientation as MCP instructions, which
the client puts in the session's context before it does anything. That is what makes a spawned
session work at all: it learns that corral_whoami exists in time to call it. Like the tools, this
is sent only inside herdr, so a non-herdr session pays nothing.
Recommended, and you should install it. The orientation is deliberately minimal — it is context cost in every corral session, so it carries only the vocabulary and the hazardous orderings. The skill carries the rest: the workflows, how to write a brief worth handing over, what the tools do not expose, and what each failure means. A session without it works from the summary alone and will get the details wrong. Copy it into each config dir you registered the server in (see the helper-file table):
mkdir -p ~/.claude/skills
# rm -rf first: ln -s into an existing directory nests the link inside it instead of replacing it.
rm -rf ~/.claude/skills/corral && ln -s "$(pwd)/skills/corral" ~/.claude/skills/corralIt loads only when a session actually reaches for corral, so it costs nothing the rest of the time.
A second skill, for corral's own health. skills/corral-doctor covers configuring, running,
upgrading and troubleshooting corral itself — the knowledge a session needs to act on a 🛟 Health
panel row. The panel's Fix issues button (visible once something needs fixing) opens a new card
with a /corral-doctor prompt pre-selected in its Run tab's start-command picker; without the skill
installed, that command falls through as plain text instead — the prompt names the file directly as
a fallback. Nothing runs on its own: the card and its prompt are prepared, and the operator clicks
Run Claude themselves, through the same spawn path every other session starts from. Same install,
same directory:
rm -rf ~/.claude/skills/corral-doctor && ln -s "$(pwd)/skills/corral-doctor" ~/.claude/skills/corral-doctorLinking matters a little more here than for the skill above: this one's instructions are about
repairing your install, so a stale copy hands a fixer session repair steps for an older corral.
helper-drift tracks it, but only once it is installed — an absent corral-doctor is not reported,
because the skill is optional and the Fix issues prompt names its file directly as a fallback.
Messaging between sessions is Claude Code's own (SendMessage / ListAgents), not
corral's — but corral supplies the address: corral_fleet prints the name each session actually
answers to, which is not always the label on its card. The skill documents how to use it and where
the reach ends; two things are worth setting up once, on each machine:
crossSessionInbound. A message is held for the receiving operator's hand approval when the two
sessions' permission modes differ — and corral panes typically run in bypass mode while an ordinary
terminal session does not, so a pane messaging a hand-started session is held by default. The sender
is told it was held and to carry on, so nothing hangs; the message waits for an approval that may
never come, and expires if it does not. Set this in the settings.json of every config dir you
registered corral's MCP server in:
{ "crossSessionInbound": "accept" }Values are accept / hold / refuse. The setting is checked before any permission-mode comparison,
so it decides outright. accept means messages from your other sessions reach this one unreviewed — treat their content as untrusted input, which is the same rule that already applies
to recaps and card text. Managed (organization) settings and a repository's own settings.json can
only tighten this, never loosen it.
Remote Control, if you run corral across machines. A session on another machine is addressable by
name only over Remote Control — and it only becomes visible to you if the session doing the
addressing has Remote Control on too: with it off locally, no other machine appears in ListAgents. corral_fleet marks a remote-environment session that has it off as
rc: off. Without it, a cross-machine session is still visible in corral and still spawnable; it
just cannot be messaged.
TypeScript end-to-end. Backend: Hono + SSE, shelling out to the herdr CLI via execFile
(never a string shell). Frontend: React + Vite + Tailwind + dnd-kit. Storage: JSON files in a
dedicated git repo under $BOARD_DATA_DIR (defaults to $CORRAL_HOME) that corral git inits
and auto-commits every 10s — point it at a fresh directory, never inside an existing repo. A
deterministic state + view + control substrate with a clean API — a future LLM agent is just
another API client, never embedded.
Full design: docs/specs/design-spec.md. Durable decisions:
docs/adr/.
See CONTRIBUTING.md. The short version: strict TypeScript conventions —
no any, no type assertions (enforced by ESLint), Zod at every boundary (convention) — Vitest,
and one gate: npm run check. PRs must say what changed and why. Security reports go through
SECURITY.md, not public issues.