Leg

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

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 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.

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:

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:

  1. A header with the agent, the id tail, Pause updates and Close.
  2. Now: the status word, the turn count and the last activity time, the last thing the agent said, then path, branch, cut from, transcript and head in full, never shortened. usage unknown (<why>); the limit still hands off sits 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.
  3. Task: the prompt in full.
  4. Conversation: the last 8 turns, newest first, headed showing 8 of 34 turns; show 40 more raises the cap.
  5. Files: every file changed, with uncommitted, +<adds>, -<dels>, or new or committed when there are no counts against HEAD. A row expands to that file's diff, capped at 400 lines.
  6. Timeline: the last 40 events, newest first.
  7. 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 line Used 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.md still describes the repository (recomputed from git on every poll).
  8. 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 and carried / total, Needs you for a hand-edited file or an unreadable config, Dropped with a reason per item (excluded by policy when 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:

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.

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:

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:

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:

  1. Title.
  2. Task: the full task text, headed card <id>.
  3. Where: repo, trunk, worktree, leases and the bundle path, full paths never shortened, with a Copy worktree path button.
  4. Pipeline: one row per station, its name and its kind, the current one marked , this station; the heading says station 2 of 5.
  5. Runs: one row per run: run number, adapter, outcome or status, signal, exit code.
  6. Timeline: the ledger, newest first.
  7. 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:

  1. 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.

  2. 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 same Capacity 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.

  3. 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.

  4. 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_off
    Waiting on you needs_approval, waiting_human, paused
    Queued queued, each row with its position and what it waits for
    Backlog backlog
    Done today done, failed, killed, today only, collapsed behind View

    A row is the board's own card row (.row: state, station, repo on branch, agent/model; the title; the one sentence; the run clock or idle 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 adds 2 of 3 in the queue and, when the scheduler has said why, its own words for what holds it; with the scheduler stopped it says the scheduler is stopped rather 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.

  5. 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.mjs held(): one row per lease currently claimed by a running or handing-off card. /api/trunk is the map of the machine and stays with the owner (src/share.mjs mayUseMachine), 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

See also


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.