Board guide
For anyone using the Leg board day to day: what every element means and when
it shows up. The board is one page, read top to bottom: the verdict (one
sentence saying what to do next), the capacity strip (one token per login,
with the login panels behind its disclosure), Terminals
(one row each), the ledger (finished terminals, what landed, background
tasks, as three counts that open), then Settings. Start a session
(leg claude) or the background-task board (npm start), see
getting-started.md, then use this as a reference.
The visual system, and why it is what it is, is DESIGN.md at the repo root.
Screenshots
docs/screenshots/ (listed here so you know what exists before you look for
one). Nine were retaken on 2026-09-19 against the polish pass (0.16.0), on a
board seeded by scripts/seed-wes-board.mjs; the rest are from the 2026-09-17
and 2026-09-15 sweeps and are noted below:
terminals-1280.png the whole board at 1280 px (2026-09-19)
board-400px.png the same board at 400 px (2026-09-19)
capacity-drawer-1280.png the strip with Capacity and models open (2026-09-19)
board-details-open.png a terminal row with its expansion open (2026-09-19)
board-handoff.png one row waiting on you, with the question (2026-09-19)
background-1280.png the Background panel and the one-line entry (2026-09-17)
settings-ladder-1280.png the ladder editor in Settings (2026-09-19)
board-empty.png the first-run panel: no terminal yet, three commands with Copy (2026-09-19)
board-running.png one card running its first agent (2026-09-17)
board-drawer.png that card expanded: where, runs, take over, timeline (2026-09-17)
board-done.png the card finished, on the agent that finished it
floor.png /floor with nothing queued, every station one line (2026-09-19)
floor-landing.png /floor with every lane full
floor-final-1280.png /floor after a card finished
floor-final-400.png the same at 400 px
share-owner-1280.png a shared board as its owner
share-guest-1280.png the same board as a guest: prompts and repos redacted
demo-1-claude-running.png leg 1 running on fake-claude
demo-2-limit-hit.png limit hit, bundle written, the row waiting on you
demo-3-handoff-bundle.png the row expanded: bundle path, run signal, timeline
demo-4-codex-running.png leg 2 running on fake-codex
demo-5-done.png done, both legs on the chain
new-card-dialog.png More settings: the task and who runs it (2026-09-19)
The five demo-*.png are one run of the sequence in DEMO.md, all at
1280 px.
Still stale, as of 2026-09-19: share-owner-1280.png,
share-guest-1280.png, board-done.png, background-1280.png,
board-running.png, board-drawer.png, floor-landing.png, the two
floor-final-*.png and the five demo-*.png predate the 0.16.0 polish pass:
their rows still show the 2x2 button grid, coloured login words and the old
mark, and the oldest of them show the pre-strip top of the board. What each
one documents is still true. Retake them with the commands below rather than
trusting the chrome in any of those pictures.
To retake one, seed a board with the shape a real one has and drive it to the state the shot needs:
node scripts/seed-wes-board.mjs # 4 live terminals, 5 finished, long paths
node scripts/seed-floor-board.mjs # cards in every floor lane, plus a landing
node scripts/board-shots.mjs <port> <tag> # shoot 1280 and 400, print the numbers
node scripts/board-jump-probe.mjs # seeds, serves and drives its own board
Each seeder writes a throwaway LEG_HOME under the OS temp dir; serve it on a
spare port with LEG_TRUST=never. Never use port 4747, that is the live
board, with real sessions on it.
board-jump-probe.mjs needs no port and no seeder: it makes its own home, binds
an ephemeral port, kills everything it started, and prints a table of where the
expanded region sat in the viewport across thirteen pushes with the list
re-sorting under it. Run it after any change to renderSessions or
renderDrawer. A scroll-hold probe cannot see this class of defect: scrollY
never moves, the content under it does.
Measure against those seeds and never against a board of healthy terminals: styling scoped to live rows and measured on a clean board reported 40px terminal rows while the real screen showed 400px.
The verdict and the logins
src/board/sessions.js draws all of it, from the accounts array of
GET /api/sessions and the sessions beside it.
The verdict is the largest thing on the page and it is a sentence, not a number. It names the one fact that decides what happens next:
Fable is at 63% of its week, the only login open.
The sentence comes from a branch table with a fixed precedence, and the order
is what the table is for. A human waiting on a terminal outranks every usage
figure, because attention is the scarcer thing. Then a login whose whole
account is nearly spent (claude has 3% left, shared by every model.), because
no model switch can help there. Then a single model at its wall with the login
still open (Fable is out until 9:14 PM; opus is open.), which is the one case
where a same-login switch is the answer. Then the burn rate, when it is known
(About 2h 40m of Fable left.), because the decision is about the afternoon
and not about the number. A model bucket that came back while a terminal is
still downshifted (Fable is back; leg#7f3a is still on opus.) sits right after
the walled-model sentence, since a state change outranks a standing figure.
Then one login carrying every terminal, several logins carrying work, and the
quiet states: nothing running and no figure anywhere. Everything walled
(Every login is at its limit; codex is back first.) is decided before any
per-login sentence, because it is a fact about the whole board. There is
always a sentence.
Every time figure carries the volume it was drawn from, always:
From 9 samples over 4h. Opus and Sonnet have their own buckets. The rate is
measured from the readings Leg has kept for that one bucket, and three rules
hold it honest. Under three samples spanning ten minutes there is no time
at all: the row prints the percentage and the reset clock instead, because a
slope drawn through two readings a minute apart is a guess printed in the
largest type on the page. A rate never crosses a reset: the samples are
cleared when the window rolls over, and the time is capped at the reset even
when the slope says there is more. A figure that has held flat for an hour is a
measured zero, not a forecast, and a percentage that fell inside one window is
a data error rather than a refund; neither prints a time. The second sentence
names the other models on that login, because a Fable figure is not the login's
figure and the reader who takes it for one plans the wrong afternoon; on a
login where every model shares the window it says Shared by every model.
The headline is capped at 56 characters, and that number is a measurement,
not a preference: at 1280 the verdict column is 26ch and 300 sampled sentences
per length still fit two 52px lines at 60 characters, so 56 is that ceiling
with slack for a long login name. test/board-verdict.test.mjs holds every
branch under it, including branches fed deliberately long repository and
account names. A headline that wrapped to three lines is what put a thousand
pixels between the top of the page and the first terminal row.
Under it, one line carrying the age of the reading and which direction it is wrong in:
Measured 2h 13m ago. 4 terminals run on it, so the real figure is higher, never
lower.
A reading taken two hours ago is a floor, not a measurement, and saying so is the entire reason to print its age. Any login at a wall other than the one in the headline is named on the same line.
The capacity strip is the band under the verdict: one token per login, a dot in its identity colour, its name, a 120 by 6 track and one figure. The figure is the binding bucket, the one that will actually stop the work: the bucket the endpoint itself marks active, else the highest percentage it reported, else the hottest of the two legacy windows. That distinction is the reason the strip exists. A login whose weekly account window reads 95% and whose active Fable bucket reads 63% is stopped by the 63, and the board used to print the 95.
claude |=======---| 63% fable week codex |==========| back Sat 10:11 PM
agy no figure grok no reading
63% fable weekis a fresh reading with its bucket word.100% Tue 9:55 AMis a reading older than five minutes: the percentage with the clock it was taken at, because it describes a moment, not now.back Sat 10:11 PMis a login at its wall. The track fills in the wall colour and the words carry the state, which is the one place on the board a word may wear a severity colour: the word is itself the failure.no figureis agy, which publishes no percentage, ever. No track is drawn. An empty track reads as a measurement of zero.no readingis a login that has a window and has never reported it.not sharedis what a guest sees: usage figures are the owner's.
Each track is a meter with an aria-valuetext carrying the percentage, the
reset, the source, any wall and the age of the reading, the same sentence the
gauges carry. A login with no percentage is not a meter at all, because
aria-valuenow would have to be a number nobody measured.
Capacity and models, the disclosure at the end of the strip, opens a drawer holding
the login panels unchanged, plus a rail of model chips on each panel head:
fable 63% for a measured bucket, fable out until 9:14 PM for a model at its
wall, and a bare model name where Leg has a name but no figure. A percentage is
measured and a wall is attributed from wording, so the two are never printed as
each other. The drawer remembers whether you left it open. The
Times are local. caption lives inside it.
An open chip is a button: pressing it puts that rung at the top of one
terminal's ladder, so the next hand-off takes it. The terminal is the one whose
expansion is open if it is on this login, else the first live row on it, and the
press is the same POST /api/sessions/:id/handoff-order the ladder editor
makes. A chip stays plain text when it is walled, when it is the model that
terminal is already running, or when no live terminal on this login can take a
ladder change: a control that would do nothing is not drawn as one.
The login panels, in that drawer, are one per login, and how much surface a panel gets is the design saying how much it matters:
- The login carrying the terminals gets a wide panel, lit one step brighter than the rest, with both of its windows drawn. The window closest to a wall is the full-size instrument; the other is the same instrument at half height with a smaller numeral, so which one to read is not a question.
- A login with a single fact to report gets a half panel: its gauge if it has a
reading, then the fact. A walled login reads
At the wallandBack Saturday 10:11 PM. Nothing runs on codex until then. - A login that publishes no figure draws no instrument at all. agy's panel says
agy publishes no usage figure, ever. Leg shows its terminals and their elapsed time instead.An empty track reads as a measurement of zero to anyone glancing at it, so none is drawn.
The gauge is a track, a fill and a numeral. The fill runs in the login's own
identity colour up to 85 percent and in the over colour past it, and a 2px notch
is cut through the bar at 85 at all times, including at zero fill: you can see
the reserve before you reach it. A window that has never been read draws no fill
and prints no reading, because a zero is a reading and Leg does not print
one it does not have.
Nothing here is on hover. A screen reader gets the whole answer from each
track's aria-valuetext, including the reset, the source and the staleness; a
window with no value is not a meter at all and carries the same sentence as
its label. The caption at the foot of the drawer reads Times are local.
For Codex, the board reads the app-server's read-only
account/rateLimits/read response every 60 seconds and maps its 300- and
10080-minute windows to 5h and 7d. The active leg codex attach also polls
that response. A percentage alone does not clear a limit wall: Leg waits for
an explicit available answer from the backend.
Terminals
src/board/sessions.js renders the region from GET /api/sessions, refreshed
by the server-sent sessions event, re-sorted every 15 s, with the elapsed
clocks ticking every second. With no sessions it prints No terminal is running. Start one in any repo: leg claude, leg codex or leg agy. It appears here within a second of the agent's first turn.
The region head carries one verdict with its volume: 3 running, 2 waiting on you, or 3 running, nothing is waiting on you, or nothing is running, with
, 3 share the claude login when two or more live rows are on one login, and
, last landed 11:02 PM appended when a terminal on the page has landed. The
share clause is the only place per-login burn is addressed: a figure on a row
is per model, which is real, and the reader who would add three rows' figures
together is stopped here, once, at the region.
Terminal row
One row per live session, inside a single panel, separated from its siblings by
a hairline. Rows that need an answer come first; the rest follow in start order.
A row that needs you says waiting on you where its status word would be, so
the state is never carried by colour or position alone.
Reading across the row: what it is doing, what it is working on, how long it has been at it, and what you can do about it.
- The register, one line of the smallest type on the board, read left to
right: the status word with its dot, then where the work is (
leg on main, or the folder when it is not a repo), thendirty 3andahead 2when the git poll has counted them, then the model tokenclaude/fable, thenquiet 4mwhen the terminal has said nothing for two minutes or more. After those come the words that are unusual about this terminal: the account when it is notdefault, the owner on a shared board,from <agent>when it was handed off,own worktree, from mainwhen it cut its own.dirty 3isfiles_dirty,ahead 2is the commit count against the base. Neither is printed as a zero, andaheadis absent on a record written by an older Leg rather than shown asahead 0.- The model token is the agent and the model this leg resolved to
(
claude/fable,codex/gpt-5.6-sol). With no model on the record it is the agent alone,claude: a printed model nobody chose is a wrong number in disguise, so there is never a default. For Claude it is refreshed from the transcript, so a silent fallback off Fable shows up here. quiet 4mislast_activity, in the muted tone, for every agent. It is an observation, not a demand. A terminal that is waiting on you says that instead, and a finished one says nothing: it has stopped, not gone quiet.
- The prompt, as a button: the first prompt of the session, carried in full
in its tooltip and in the expansion. A pasted screenshot arrives as an
<image name=... path=...>tag with an absolute temp path in it; the tag is replaced by(image)and the text, so the operator's home directory is not on screen and the sentence starts where the sentence starts. - Under the prompt, exactly one sentence, the highest-ranked thing true
about this terminal, then
also: <names>naming every sentence it is holding back, then the files this session is touching as comma-separated basenames up to six, then, and N more. The whole path stays on the title and in the expansion; printed in full it was ninety characters of temp path per file. A file another live session is also touching is printed in the warning colour. A terminal that is merely running prints no sentence at all: its own row already says so, and four rows each sayingactivityis four lines of noise. - The capacity phrase, at the right of the files line:
63% of the fable week, the bucket that will actually stop THIS terminal. It isbinding(usage, session.model), computed per request, so a row on Fable and a row on Sonnet in the same login can print different figures and both are true. The figure is per model; the reader who would add three rows' figures together is stopped by the share clause at the region head, once. Nothing is printed when no bucket is known: a percentage nobody measured is worse than no percentage at all.- Once that bucket has three readings spanning ten minutes, the phrase
becomes the time instead:
about 2h 40m of fable left, from 9 samples over 4h. The sample count is part of the phrase and is never dropped. Under the gate it stays the percentage and the reset clock, and the time never runs past the reset: a rate measured in this window says nothing about the next one. - The strip token above keeps the percentage even while the row says the time. The strip is the measurement and the time is the inference, and the inference lives where its sample count can sit beside it.
- Once that bucket has three readings spanning ten minutes, the phrase
becomes the time instead:
Back to fable, a link on the capacity line beside that figure, on a row that Leg dropped to a lower model and can climb again. It appears only when both facts are known: the row's model is below the top rung of its own login's ladder, and that top rung is open (no wall on the model, no wall on the login, and either no bucket for it or a bucket under 100). Pressing it replaces the button row with one sentence,Hands off now. The current turn stops and fable continues from the bundle., orfrom the conversationwhen that rung is one a--resumekeeps. Confirming posts the hand-off with the model on it; a rung that walled between the draw and the press comes back as the server's own 409 sentence in the row. It is a link and not a fourth button: the button row is the shipped shape and does not reflow.- The clock: elapsed since the session started (
4h 24m), and the session's short id. The id used to print asclaude-7f3aimmediately after the wordclaude; the prefix is the agent name twice and it is gone. - The buttons, one row under the clock at the right, so every row's controls sit in the same place: Land, Hand off now, Details, End. Land is drawn only when it can actually run, and then it is the one primary button on the board; when it cannot, the row does not draw it and its reason is printed once above the panel, never left in a tooltip. Hand off now, Details and End are quiet controls: the accent means "this can land" and nothing else.
A terminal waiting on a human says so, and the tab says it too. Claude
Code's Notification hook writes waiting on the record when it puts up a
permission prompt, when it has been idle at the prompt, or when a subagent asks
for input; the row's status word becomes waiting on you, its sentence is the
question itself (rank 3 below), and it sorts to the top of the region and into
the region head's count. While any row is waiting the tab title reads
(1) Leg and the favicon carries a dot, always, with no permission and no
setting. codex, agy and grok publish no such signal, so their rows never say
waiting on you; they say quiet 4m.
A fact true of every row is said once, at the region. Three rows all reading
this terminal works in the checkout itself: there is no branch of its own to land is one fact and two lines of noise, so it is hoisted to the region head as
every terminal here works in the checkout itself.... The per-row copy stays in
the DOM, visually hidden, so each Land button's aria-describedby still resolves
to its own reason. The same applies to any sentence two or more terminals would
print identically, except muted ones: turn 12, last activity 1:04 AM is shared
by coincidence, not a fact about the board.
Finished terminals leave this panel. A terminal that has ended or been lost is history, and after a day's work it is most of the list. It moves to the ledger (below) as part of a count that opens. A finished terminal that still needs you, or whose expansion you have open, stays in place.
With the portable harness on, the register also carries one chip for the leg
now running: harness synced, harness partial, harness stale, harness attention or harness refused (harness.md, "States"). Nothing shows when
the feature is off.
The one sentence
rankedNotes in src/board/sessions.js is the single source of every sentence
a panel prints. It builds the list, sorts it, prints the first, and names the
rest behind also:. Rank 1 is worst.
| rank | what it says |
|---|---|
| 1 | every option is out, first back: codex 10:11 PM, or limit: <reason>, back <time> |
| 2 | a bounced Land, or the landing was cut off (the board restarted); press Land again |
| 3 | waiting on you: permission to run Bash(git push origin HEAD), asked 40s ago, waiting on you: idle since 11:04 PM, or an overlap with another live session |
| 4 | sam asked to take this terminal at 11:04 PM |
| 5 | waiting for codex at 10:11 PM |
| 6 | handing off to codex, <reason>, 2m |
| 7 | landing <branch> onto main: commit, rebase, test, fast-forward |
| 8 | near the 5h wall, next: codex |
| 8.5 | fable at 92% of its week; Hand off > claude/opus keeps this terminal, or claude at 97%, shared by every model; next off claude: codex |
| 9 | a finished Land, nothing to land, or the button you last pressed |
| 10 | turn 14, last activity 11:04 PM, the fallback that is always true |
A sentence at rank 4 or better is what raises the panel, sorts it to the top and counts it in the region head, so those three cannot disagree.
Two of them in full, because they are what the board exists to say:
- Overlap, two live sessions in one repo whose
files_touchedorfiles_dirtysets intersect (sessions.overlaps), so it catches a file one agent has edited and another has only made dirty. In separate checkouts:codex (codex-99ab) is changing src/server.mjs in another checkout; whoever lands second rebases. In the same checkout:codex (codex-99ab) is editing src/server.mjs too. Three file names, then, and N more. - A Land. Bounced:
Land was attempted at 6:40 PM onto main and bounced: CONFLICT (content): Merge conflict in src/server.mjs. The branch still holds every commit; nothing was lost.Finished:landed on <base>, <7-char sha>, <n> files, +<added>/-<removed>, with, untestedwhen the land recorded no test run. A finished Land stops being printed ten minutes after it happened. Nothing to land:nothing to land: <branch> has no changes beyond main.
Status words
| status | word |
|---|---|
starting |
starting |
running |
running |
warning |
near limit |
limit |
limit hit |
handing_off |
handing off |
waiting |
waiting for reset |
handed_off |
handed off |
ended |
ended |
lost |
lost |
The word carries the meaning. The 7 px square beside it is aria-hidden and
only repeats the tone. waiting means every option is walled and the terminal
is counting down to the first reset; its sentence reads waiting for <agent> at <time>, and End quits that terminal with exit 3. lost means the runner
process that owned that terminal is gone. It is never counted as live.
A tenth word, waiting on you, replaces the status word on any row that needs a
human. Two different things live on the record's waiting key and the board
tells them apart by type, never by which fields are set: type: reset is the
all-out countdown above, where the child is already dead and nobody is being
waited on, and permission_prompt, idle_prompt, agent_needs_input and
quota_auto_resume are a human being waited on. quota_auto_resume is the one
that prints Claude Code is waiting at the limit itself; Leg is not handing this one off., because two waiters on one terminal is the failure to avoid.
Terminal buttons
The order is fixed, Land, Hand off now, Details, End, and it never reflows by availability: a button that does not apply is omitted, never moved. A Land that cannot run is omitted too, with its reason hoisted above the panel.
| button | shown when | what it does |
|---|---|---|
| Land | always, disabled with the reason printed under it when the session has no worktree of its own, while it is landing, or when the worktree is gone | POST /api/sessions/:id/land (202): commits what the agent left on leg/<id>, rebases it onto its base, runs the tests, fast-forwards the base or bounces; the panel's sentence shows the result |
| Request handoff | the board is shared and this terminal is someone else's | POST /api/sessions/:id/request-handoff (202): asks the owner; nothing happens until they approve |
Approve <name> / Dismiss <name> |
the board is shared and someone asked for a hand-off on your terminal | POST /api/sessions/:id/requests/<name>/approve (or /dismiss): approving hands the terminal off, and the event says who it was for |
| Details | any terminal of yours | GET /api/sessions/:id/detail: the transcript, the files changed with their line counts, the timeline and the current bundle; GET /api/sessions/:id/diff?file=<path> for one file, capped at 400 lines, refused for any path outside that terminal's own tree |
| Hand off now | the session is active | asks first in the row's confirm row (Hands off to the first open rung of this terminal's ladder. The current turn stops.), then POST /api/sessions/:id/handoff: saves the bundle, stops this agent, starts the next option in the same terminal. It stops a working agent mid-turn, so it asks whether the mouse or the h key pressed it |
| Change the ladder | inside the expansion, while the status is starting, running, warning, limit or waiting |
POST /api/sessions/:id/handoff-order with the whole handoff_ladder; an older wrapper instead saves the machine default and says to restart the terminal |
| End | the session is active | POST /api/sessions/:id/end: stops the agent, ends the session |
| Remove | the session is not active | DELETE /api/sessions/:id: safely prunes the session record, worktree, and merged branch only when the worktree is clean and the branch is already on its base; otherwise it leaves them and explains why |
| Remove record | the session is not active and has an own worktree | DELETE /api/sessions/:id?force=1&keep_worktree=1: removes only Leg's record and keeps the worktree, branch, unmerged commits, and dirty files |
End, Remove and Remove record confirm first: the button row is replaced in place
by one sentence and its buttons, focus moves to Cancel, and Escape cancels. End
offers a second verb, End, and keep going as a card, when the terminal is in
a repository and the reader may use cards.
There is no modal and no browser confirm(). Settings has the same ladder
editor for the default copied by new terminals.
The expansion
Details, or a click on the prompt, expands a region in flow directly under
that panel; the page grows and the panel stays where it is. Nothing slides in
from the right, and the page keeps one scroll container
(docs/screenshots/board-details-open.png). Sections, top to bottom:
- A header with the agent, the id tail, Pause updates and Close.
- Now: the status word, the turn count and the last activity time, the last
thing the agent said, then
path,branch,cut from,transcriptandheadin full, never shortened.usage unknown (<why>); the limit still hands offsits here when the endpoint had no numbers for this login: no login in that config directory, a 404, a 429, a body that is not JSON, or a shape Leg does not recognise. - Task: the prompt in full.
- Conversation: the last 8 turns, newest first, headed
showing 8 of 34 turns; show 40 more raises the cap. - Files: every file changed, with
uncommitted,+<adds>,-<dels>, orneworcommittedwhen there are no counts againstHEAD. A row expands to that file's diff, capped at 400 lines. - Timeline: the last 40 events, newest first.
- What happens next:
now: claude / opus, then claude / fable, then claude / sonnet, then codex, then agy, which rung is preferred and which is eligible now, the lineUsed after a usage limit or Hand off now. A normal exit ends this terminal., the Hand off now to picker, the Change the ladder editor, the current bundle id, and whether.leg/RESUME.mdstill describes the repository (recomputed from git on every poll). - Harness (only when the portable harness is on): the
source client, when it was captured and synced, the policy, one line for
the leg now running (
codex harness partial · 8/8 components, 3 dropped · 1 file(s) written), a row per component with its state andcarried / total, Needs you for a hand-edited file or an unreadable config, Dropped with a reason per item (excluded by policywhen the drop was yours), and the last eight entries of the harness trail. Every word comes from what the session recorded when the leg started, never from a guess.
It refetches every 3 seconds while it is open, and stops on Pause updates, when the tab is in the background, or when it is closed. The rest of the page stays live, so the panel's own buttons keep working. Messages and diffs are scrubbed for secrets on the way out, and the whole region is refused for a terminal that belongs to someone else. Close or the Escape key closes it.
The Hand off now to picker lists one row per rung of this terminal's ladder, each of them three fields separated by a middot:
claude / opus · same terminal, keeps the conversation · ready
claude / sonnet · new agent, from the bundle · ready
codex · new agent, from the bundle · at its usage limit until Sat 10:11 PM
claude / fable · new agent, from the bundle · ready, spends usage credits
agy · new agent, from the bundle · only when every rung above it is walled
The rung first, then what taking it does to the conversation, then what it costs
you right now. The state word is ready for a rung that is simply open; a rung
that cannot be taken carries the server's own reason, verbatim, and is
disabled, with until <clock> when there is a reset to name. A rung that is
open but worth a warning keeps its reason as plain text and stays clickable:
past your 10% reserve is the one you will see, because a human pressing this
button ignores the reserve and a floor you cannot see is a floor you swear at.
The refusal that reads shares the window that is out, buys nothing is a
same-login rung under an account-wide wall: every model shares that window, so
switching model would buy nothing. The rung Leg would take on its own is not in
the list twice; the terminal's current rung is not in it at all. Picking one
replaces the row's button grid with a sentence naming the destination and
whether the conversation survives, and the hand-off carries the model. The
choice survives the expansion's 3-second refetch: it is held as a rung, so the
row it names is still selected after the list is rebuilt, or reordered, under
it, and the poll stands down entirely while the select has focus.
Change the ladder edits this terminal's own copy. Each rung is a numbered
row: the agent and model with the login's dot, a when select (always,
below N% with a number from 0 to 100, which is the range the server itself
accepts, or walled only), the cost word, and Up, Down, Remove.
Clearing the number box puts the rung back to always rather than inventing a
percentage; a stored below:0 or below:100 is shown as the number that is
stored, with a line saying what it does (below 0% is never true: this rung is never taken), and is never quietly rewritten. + Add a rung takes an agent and
one of its models, or default for the model the CLI picks itself; a rung the
ladder already carries is refused in the status line by name and position
(claude / opus is already rung 2.), never silently. There is no drag: a
list this short is faster with two buttons and a drag has no keyboard. Saving
posts the whole ladder; a rung the server will not accept comes back as its own
sentence (claude has no model "gpt-5") in the status line, and nothing is
saved. The draft line under the list previews what this terminal would try
next, with the rung it is on skipped.
Settings carries the same editor for the machine default, under
New terminal handoff ladder, plus the three rules that are machine-wide:
- Let an automatic hand-off spend, with the sentence
A rung that spends usage credits or metered balance may be taken by an automatic hand-off.Off by default. While it is off, acreditsormeteredrung is skipped by an unattended hand-off and the ledger says which and why; a human pressing Hand off can still take it. When the claude login reports credits off, the panel addsUsage credits are off, so there is nothing to spend through the wall.and offers no button to turn them on, because that cannot be done from here. - Climbing back, two radios:
Leg climbs back to the top rung at the next hand-off.(the default) orStay on the lower rung until you press Back to fable.Under them, the rule that is not a choice:Leg never interrupts a running turn to climb. - Keep N% of
<login>for your own terminals, one number per login on the ladder. 0 keeps nothing back. An automatic hand-off skips a rung whose login is past the floor; a hand-off you press yourself still takes it, and the picker printspast your 10% reserveon that row rather than hiding it.
Saving Settings writes handoff_ladder, climb_back, may_spend and
reserve in one PATCH /api/settings, and the status line reports the
handoff_order the ladder derives, which is what older terminals still read.
The ledger
Below the terminals, four counts sitting on the ground with no panel, because a raised surface here would compete with the terminals that are live. Each is a heading, a line of detail and a button that opens the detail below the row.
- N finished, the terminals that have ended or been lost,
4 lost, 2 ended, in leg, costclaw, declick. View opens them as full rows. On a real board after a day's work this is most of the list, which is exactly why it is a count and not the list. - N landed, what has landed on trunk across every repo the board can see,
newest 38 minutes ago, on leg@main, recruiting-tool@main. View N commits opens them grouped by repo, newest first: the subject and, for a commit a Land put there,3 hours ago, by claude, read from~/.leg/landings.jsonl, which outlives the session; any other commit shows its git author. Shown in full it was eighteen rows ofgit logat the same visual weight as the live terminals, so the loudest thing on the page was a commit from eleven days ago. With nothing landed it readsNothing landed yetand explains what Land does. - N conversations, every coding-agent conversation on this machine,
799 claude, 198 codex, 92 agy, 14 grok, 5 copilot; looked 2m ago: the sessions Leg started and the ones the agents keep in their own stores, read from there and never moved (history.md). Browse N opens a drawer with filters (agent, search, repository, only what Leg started), a page of fifty rows newest first and Show 50 more. A row is the title as a button, then the agent chip,legorexternal(, livewhen a process is still on it),repo on branch, the prompt count and the short id. The title opens the conversation in place: agent, who started it, folder, repository, worktree, times, prompts, transcript path, id, theleg history continue <id>command with Copy the continue command where the agent can resume by id (or the reason it cannot), Copy folder path, and the last eight messages newest first, each redacted the way the terminal drawer's are. Below the rows, Checkouts: every worktree Leg can see with its repo and branch, owner (the checkout itself, a Leg session, a Leg card, or not Leg's), how many conversations point at it, andmissing,orphaned,stale,cleanorN uncommitted. Nothing here is pushed over the live stream; the count re-reads itself once a minute while the drawer is closed. On a shared board a guest seesThe owner of this machine sees them.and nothing else. - N finished cards,
3 finished cards, 2 done, 1 failed, last 11:02 PM, with View opening them newest first. Live cards are not here: they are rows in the Background panel under Terminals, with the one-line entry under it. With none finished it readsNo finished cards.
A shared board (more than one human)
With leg share on (off by default), the Terminals region head carries a chip
reading you are wes, 2 on this board, with , a guest after the name for
anyone who is not the owner, and every row carries an owner word in its
register: wes, you on yours, wes on someone else's. There is no colour
difference between the two.
A row that belongs to someone else carries exactly one sentence, read-only: wes owns this terminal. Its prompt reads prompt hidden; it has no file names, no
path beyond the repo and branch, no bundle and no event log; and its only button
is Request handoff. A guest's login panels say Usage for this login is not shared with guests and draw no instrument. The owner sees sam asked to take this terminal at 11:04 PM on their own row, with Approve sam and Dismiss
sam.
A guest's board has no background tasks cell, no New card button and no Floor link: that side belongs to the owner of the machine, and the API answers 403.
Background tasks: page layout
Everything from here down is the card runtime. Each card runs separately from
the interactive terminal conversations, in its own git worktree. Liveness, not
kind, decides where a card appears: a live card (backlog, queued,
running, handing_off, needs_approval, waiting_human, paused) is a row
in the Background panel directly under Terminals, in the terminal row's own
shape; a finished one (done, failed, killed) collapses into the ledger
cell, 10 finished cards, 5 done, 5 failed, last 8:15 PM, whose View opens
them newest first. Ten finished cards are one row, not ten.
The masthead (src/board/index.html) has the Leg wordmark on the left and, on
the right, the connection word (connecting / live / reconnecting…) with its
dot, the scheduler status (scheduler running, 2 max), and the Floor link.
New card is in the ledger cell, not the masthead, and Settings is the
last region of the page, in flow. Nothing on the board is sticky: the verdict is
what you came for and it is at the top, so there is nothing to pin.
With no live cards the Background panel's rows are gone and only its entry line
is left; with live cards the region head prints the same counts a terminals
head does, 1 running, 1 waiting on you. Under the rows sits the entry: a
single field, Run in the background:, a Start button, and an inferred
sentence whose nouns are buttons, in leg on main, with claude/fable then claude/opus then codex, build only, plus More settings for the full form.
Start with an empty task is disabled and says why.
The sentence describes exactly what Start posts, noun by noun. The branch is
the repo's own default (the server reads origin/HEAD, then main, master
and trunk, then the branch the checkout is on) and it is sent with the card,
so a repo whose default is master or develop starts one instead of being
refused by a trunk that does not exist. The ladder clause is one leg per rung
carrying that rung's model: claude/fable then claude/opus then codex posts
three legs, the first two on one login and two different models, which is the
hand-off the ladder exists for. Two rungs that name the same agent AND the same
model are one leg, not two, so the sentence never lists a hand-off to a leg's
own twin. Pressing the ladder noun opens two selects: the rung the chain starts
at, and the model that first leg runs on, taken from that provider's catalog
(GET /api/models). The model picked there
applies to the leg that starts and nothing else; the fallbacks under it keep the
models the ladder gave them, and the saved ladder is not touched. More
settings below the line opens the full form with the same rows in it.
A ladder Leg cannot read is never silently empty. An install whose
preferences.json predates the ladder carries only handoff_order, and the
line derives one rung per agent from it; a board that never got an answer from
/api/settings at all falls back to the adapters that are installed. The
sentence says no agent is configured only when there is genuinely nothing
installed, and says every agent here bills by the token, and spending is off
when there is, but the spend rule is holding it back.
Settings holds the API token field (only needed when the server is bound
off loopback; see configuration.md), the
New terminal handoff ladder editor used by terminals started after you save
(the rungs, the spend rule, the climb-back policy and the per-login reserve),
Notifications, and the board's own facts: version and bind address, who you are signed in as,
whether share is on, the scheduler, and the board home. Notifications holds
two toggles, both about a terminal that is waiting on you: Terminal toast when
a terminal waits on you (on by default, Claude Code only: an OSC 9 sequence
returned by the Notification hook, which is the toast Windows Terminal renders
in the window the agent is already running in) and Browser notification when a
terminal waits on you (off by default; the browser asks for permission the
first time you turn it on, and it fires on the transition into waiting, never on
every refresh). The board toggle reads window.isSecureContext when it renders;
where that is false it is disabled and says This page is not a secure context. Open the board at http://localhost:<port> to turn toasts on. The tab title
badge has no toggle: it needs no permission, so it is always on. Its region head
says
No API token set, requests reach 127.0.0.1:4747 unauthenticated, or API token set, sent to 127.0.0.1:4747 as a bearer token once a token is stored.
Order
There are no columns. Cards are rows in one list, ordered by status (ROW_RANK
in src/board/board.js), then by start time within a rank:
| rank | statuses |
|---|---|
| 0 | needs_approval, waiting_human |
| 1 | failed, killed |
| 2 | running, handing_off |
| 3 | paused |
| 4 | queued |
| 5 | backlog |
| 6 | done |
Rows are re-appended only when that order actually changes, so a row does not move out from under the cursor.
Card row
Each card row reads in the same four registers as a terminal panel:
- Who: an adapter chip in that agent's colour (or
no agent), the station name when it is not-, and the status word. - What: the title (the card's
title, or the first 60 characters of the task) as a button that opens the expansion, and one sentence under it. - Where:
<repo> on <branch>, where the branch is the one that checkout is actually on:leg/<card-id>for a worktree the card cut, and the terminal's own branch for one it adopted from "End, and keep going as a card", which is not a name anyone can derive from the card id. It is shortened to 32 characters on the row with the whole line in the tooltip, so what you copy intogit checkoutis a branch git has. A card with no checkout yet prints the trunk it will branch from. Then the chain rail, and the lease chips, one per claimed lease or**(the whole repo) when none were set, plusblocked by leasewhen the scheduler says so. - When: the elapsed clock, then the buttons.
mm:ss(orhh:mm:ss) counts the leg that is running now, exactly as a terminal row's clock does. A live card between runs has no leg to time, so it printsidle 12m, the time since its last state change, labelled for what it is: the card's own age in that column would read24:05:00for a card that sat in the backlog for a day. A card that has never run prints--:--.
The log is not on the row. It is in the expansion.
The one sentence
cardSentence in src/board/board.js, one sentence, the highest-ranked thing
true about the card:
- A bounced land, at
queued,running,handing_offorneeds_approval:Land bounced on attempt 2: rebase-conflict. The worktree still holds every commit; nothing was lost.The reason is printed in full. - Queued and blocked: the scheduler's own
blocked_bymessage. failedorkilled:failed at station build after 2 runs: <last event>.waiting_humanorneeds_approval:waiting on you at station review since 11:04 PM.done:done after 3 runs, <last event>.- Otherwise the last ledger event,
<type>: <summary>, truncated to 90 characters, with its time.
Status words
| status | word | notes |
|---|---|---|
backlog |
backlog | |
queued |
queued | |
running |
running, or landing | "landing" when the current station is a land station |
handing_off |
handing off | |
waiting_human |
waiting human, or PR open | "PR open" when the card carries a pr_url (land mode pr) |
needs_approval |
needs approval | |
paused |
paused | |
done |
done | |
failed |
failed | |
killed |
killed |
Chain rail
One pair of tokens per chain entry at the current station: the adapter name in
its agent colour, then that leg's state as a word, pending, running, done,
handed off or failed (stateGlyph in src/board/board.js). There are no
glyphs: the subset fonts carry no check, cross or arrow. A gated approve: true
entry is not marked on the rail; Approve appears in the row's buttons when
the card reaches needs_approval.
Card expansion
Click a card's title to expand a region in flow under that row. Sections, top to bottom:
- Title.
- Task: the full task text, headed
card <id>. - Where:
repo,trunk,worktree,leasesand the bundle path, full paths never shortened, with a Copy worktree path button. - Pipeline: one row per station, its name and its kind, the current one
marked
, this station; the heading saysstation 2 of 5. - Runs: one row per run: run number, adapter, outcome or status, signal, exit code.
- Timeline: the ledger, newest first.
- Log: the tail of the active or most recent run's combined log, up to 2000 lines.
Close or the Escape key collapses it, and focus returns to the title.
New card form
The dialog opened by New card (src/board/index.html,
docs/screenshots/new-card-dialog.png):
Two columns on a screen 900px or wider, one on a phone: what the work is on the left, who runs it on the right. It opens prefilled with the repo, branch and ladder the entry line above it would have used, so the dialog and that line say the same thing until you change one.
| field | notes |
|---|---|
| Task | required, and takes the caret when the dialog opens; the prompt every leg gets |
| Repository | a picker of every repo this board has seen, plus Another path, typed below |
| Path | required; an absolute path to a git repository root. The picker fills it; you can type any path over it |
| Branch to cut from | the repo's own default branch, read from the sessions payload, not a hardcoded main |
| Run now | checked by default; unchecking saves the card as a draft in backlog |
| Who runs it | the ordered list below; at least one row is required |
| Save as my default ladder | writes the rows back to preferences.handoff_ladder through PATCH /api/settings, so the entry line and every new terminal use them too |
| Workflow (Advanced) | Build only stops unmerged in the worktree; Build-land runs test then land; Factory runs plan, build, review, test, and land; custom JSON reveals a station-array textarea |
| Scripted first agent (Advanced) | explicitly test/demo only; it replaces the agent on the first row |
| File leases (Advanced) | comma-separated path globs to reserve so overlapping cards wait |
| Merge method (Advanced) | ff or pr; relevant to a workflow with a land station |
| Test command (Advanced) | overrides the land station's auto-detected command |
| Card title (Advanced) | optional; defaults to the task's first 80 characters |
Who runs it is one row per leg, in the order Leg tries them. The first row is the agent that starts; every row under it is a fallback, taken only when the row above it cannot continue. Each row carries:
| control | notes |
|---|---|
| provider | the installed adapters; a test/demo adapter is labelled as one |
| model | that provider's own catalog from GET /api/models, with the provider's default as the first option. codex lists gpt-6-astra, gpt-5.6-luna and the rest of its visible models; claude lists Fable, Opus, Sonnet and Haiku |
| permissions | the adapter's own allowed modes, its default preselected |
| ask before start | gates that leg behind an approval |
| max turns | a cap for that leg alone |
| test behavior | scripted outcome, on a test/demo row only |
| Up / Down / Remove | reorder or drop the row; the last remaining row cannot be removed |
Add a fallback appends a row for the first installed agent no row already
names. Every field is per row, so claude/fable then claude/opus are two real
legs with their own models, permissions and limits: the older form keyed those
by adapter and could not tell one claude row from another.
Cancel closes without creating a card; Create card posts it and closes on success (errors show inline above the form).
Buttons
Which buttons a card shows depends on its status (src/chain.mjs
availableActions, labels from src/board/board.js ACTION_LABELS). The order
is fixed: Approve, Run, Resume, Pause, Hand off now, Rerun, Reassign, Kill.
| button | shown when | what it does |
|---|---|---|
| Run | backlog |
queues the card (enqueue) |
| Pause | running |
stop after the current leg, child killed, bundle written |
| Hand off now | running, at an agent station that has a later chain entry |
end the current leg as if it were incomplete, write the bundle, start the next adapter |
| Resume | paused |
pick up where it stopped |
| Approve | needs_approval or waiting_human |
release a gated leg, or clear a human station |
| Kill | any non-terminal status | stop the running agent; card ends killed |
| Reassign | any non-terminal status, and the current station is an agent station |
pick a different adapter/mode for the current leg from a picker |
| Rerun | done, failed, killed |
start the station over from the last bundle |
| Remove | done, failed, killed |
delete the card record, its events and its runs; the worktree is kept (confirms first) |
| Take over | any non-terminal status, in the expansion | you take the card's checkout yourself |
Take over is the one place the board hands you a command, because a browser
tab cannot open a terminal: it prints leg claude --resume-card 3e1c, which
starts an interactive terminal in the card's own worktree, primed from the
card's bundle. Before it prints anything the card is moved to paused,
whatever it was doing: a running one has its child killed and its bundle
written, and a queued or handing_off one is taken out of the set the
scheduler starts from, so nothing launches a leg into the checkout you are
about to sit down in. The card's ledger gets one taken_over line with your
name on it. Resume puts it back to work when you are done. A card that is
done, failed or killed has nothing to take over and says so.
End, and keep going as a card is the same trade in the other direction: it
is the second verb on a terminal's End confirm row (shown for a terminal in a
git repository, to the owner or an operator). It writes the terminal's hand-off
bundle, makes a card whose task is the terminal's prompt plus Continue from the bundle at <path>, starts its ladder at the rung that terminal was on, and
then ends the terminal. Where the card works depends on what the terminal had:
a terminal with a worktree of its own hands it over, and the card waits in the
backlog until that terminal has really stopped, because End is a request its
runner reads on its own poll and two agents in one working tree is the thing
this whole feature exists to avoid. A terminal working in the repo itself gets
the card a checkout of its own, cut from the same branch, with the uncommitted
work carried into it: the tracked edits as a patch and every file git has not
seen copied across. If that work cannot be read or cannot be carried, nothing
is created and the terminal is left running with the reason.
What a state looks like
| state | row rank | status word | typical buttons |
|---|---|---|---|
| backlog | 5 | backlog | Run |
| queued | 4 | queued | Kill, Reassign* |
| blocked by lease | 4 | queued, with a blocked by lease chip |
Kill, Reassign* |
| running | 2 | running (or landing) | Pause, Hand off now†, Kill, Reassign* |
| handing off | 2 | handing off | Kill, Reassign* |
| needs approval | 0 | needs approval | Approve, Kill, Reassign* |
| waiting human | 0 | waiting human (or PR open) | Approve, Kill |
| paused | 3 | paused | Resume, Kill, Reassign* |
| done | not a row | done | none on the board |
| failed | not a row | failed | none on the board |
| killed | not a row | killed | none on the board |
* Reassign only shows when the current station is an agent station.
† Hand off now only shows when that station has a later chain entry.
The last three are not rows at all. A finished card is one line in the ledger
drawer (finishedLine in src/board/board.js), done after 3 runs, landed 7f3a2c1 or failed at station build after 2 runs: <last event>, with no
buttons beside it. availableActions in src/chain.mjs still returns rerun
for a terminal status, and Remove is its own DELETE /api/cards/:id, so both
verbs remain reachable through the API and bin/leg.mjs; the board simply
stops spending a row on work that has stopped.
Floor view
/floor (docs/screenshots/floor.png) is the scheduler-eye view across every
card, and it starts work as well as watching it. It polls /api/floor (the
scheduler's own view: leases, blockers, the repos it knows), /api/cards (the
rows), /api/trunk?since=1h and /api/sessions every 2 seconds, and refreshes
on the same server-sent events as the board.
Top to bottom:
The masthead. The Leg floor brand, the connection word and its 24 px rule, the scheduler status, the repos list and the Board link. The four counts that used to sit here are beside the station headings now, with the rows they count.
The capacity strip, the same one the board draws and from the same file (
src/board/strip.js): one token per login carrying the bucket that will actually stop the work. The four login panels are behind the sameCapacity and models >disclosure they are behind on the board, and whether it is open is remembered for both pages. On the floor they used to be the whole first screen, above every row the page exists to show.The entry row, the same one the board carries and from the same file (
src/board/entry.js):Run in the background: [task] [Start], with the sentence under it naming the repo, the branch, the ladder and the workflow, each a button that opens a select in place. Start posts exactly what the board's Start posts (POST /api/cards), the new card appears in Queued or Backlog within one poll with no reload, and a toast names it. More settings is the one thing the floor hands over: the New card dialog's markup exists once, on the board, so the link carries the sentence you typed in the hash (/#new-card=<task>) and the board opens the dialog with it already in the field.Five stations, each a question with its count in the heading and, under it, either the rows or one sentence saying what would put a row there:
station what is in it Running running,handing_offWaiting on you needs_approval,waiting_human,pausedQueued queued, each row with its position and what it waits forBacklog backlogDone today done,failed,killed, today only, collapsed behind ViewA row is the board's own card row (
.row: state, station, repo on branch, agent/model; the title; the one sentence; the run clock oridle 12m; the short id; and at most four of its available actions in the fixed order). The title is a link to that card on the board (/#card=<id>), which is where the expansion, the log tail and Take over live: one copy of each. Reassign opens the board's own picker in place of the row's buttons, an adapter and a mode with Apply and Cancel, because the API refuses a reassign with no adapter. Every other button posts its action straight away. A queued row adds2 of 3 in the queueand, when the scheduler has said why, its own words for what holds it; with the scheduler stopped it saysthe scheduler is stoppedrather than blaming a slot that nothing is going to free. A row you have focus in is not rebuilt under your finger for 30 seconds, so a press, a picker or a selection survives the 2-second poll; every other row in that station keeps updating meanwhile.Leases and Trunk lane, the two tables the floor alone has:
section columns Leases Lease, Card, Station, Since Trunk lane Time, Card, Summary The Trunk lane lists commits landed in the last hour, newest first. The Leases table is
src/leases.mjsheld(): one row per lease currently claimed by a running or handing-off card./api/trunkis the map of the machine and stays with the owner (src/share.mjsmayUseMachine), so an operator sees this one table replaced by a line naming whose it is; the stations, the entry row and the leases are theirs as usual.
j and k move a ring down and up the rows, and Enter opens the ringed card on
the board. Enter belongs to whatever has focus: on a button, a link or the
disclosure it presses that control, and it only opens the ringed card when
focus is on the page itself. At 400 px the page is one column: the row's
actions move under its sentence and nothing scrolls sideways.
Keyboard and accessibility
Every interactive element (links, buttons, inputs, selects, textareas, summaries) gets a visible focus ring:
outline: 2px solid var(--focus); outline-offset: 2pxon:focus-visibleonly (src/board/board.css). There is no bare:focusrule, so a mouse click leaves no ring behind it.Escape closes the open expansion, the terminal's and the card's. If the keyboard map is open, Escape closes that first, then a confirm row if one is open. The floor has neither, so Escape does nothing there.
The keyboard map.
?opens and closes a small panel listing every binding. Every key CLICKS a button that is already on the row, so no key is a second way to do anything:key what it does jmove the ring to the next terminal kmove the ring to the previous terminal 1to9move the ring to that terminal hpress Hand off now on the terminal the ring is on lpress Land on the terminal the ring is on dpress Details on the terminal the ring is on epress End on the terminal the ring is on ?open and close the map Escapeclose the map, cancel a confirm row, or close an expansion The ring is a bar down the left edge of the row, and moving it also moves keyboard focus to that row's first button, which is its prompt, so a screen reader announces which terminal the ring landed on rather than a bare
Land. A disabled button is never clicked, soldoes nothing on a row that cannot land. While an input, a select or a textarea has focus, every one of these keys is text and nothing else.The ring belongs to a TERMINAL, not to a position: the grid re-sorts whenever a row starts waiting on you, and the ring stays on the terminal you put it on. If that terminal ends and moves to the ledger, the ring is cleared rather than inherited by whatever row took its place.
With no ring set,
h,l,dandeonly set the ring on the first row and paint it; it takes a second press to act, so no key ever acts on a terminal that was not marked on screen first. Every one of these keys stands down while a dialog is open, while the keyboard map is open (?and Escape still close it), and while a confirm row is waiting for its answer.Buttons carry descriptive
aria-labels (for example "Pause<card title>", "Reassign adapter for<card title>") so a screen reader announces which card an action applies to, not just the button label.A terminal's expansion is a
role="region"labelled "What this terminal is doing", and both expansions setaria-hiddenandhiddenon open and close.The 7 px status mark is an
aria-hiddendecoration; the status word beside it carries the meaning. The chain rail prints each leg's state as a word, so nothing on it is hidden from a screen reader either.No state is carried by colour alone: a panel that needs you also says
waiting on youand gains a 2 px bar, and every usage tier prints its word beside the rail.@media (prefers-reduced-motion: reduce)is respected insrc/board/board.css: the status mark's annunciation animation and every transition on the page are disabled for readers who ask for it.
See also
- concepts.md: what each status and outcome means underneath the board.
- configuration.md:
LEG_BIND/LEG_TOKEN(the Settings token field),LEG_PORT.
Leg is commercial, source-available software by Wes Sander. The source you run ships in the npm package. Questions or a refund: legcli@practicalsystems.io.