This guide installs legwork, an autonomous project queue for Claude Code. By the end you will have the legwork repo on disk, the runner firing on a timer, and (optionally) the n8n review pipeline.
Read it in order. Steps 1 and 2 give you the manual loop: the queue, the slash commands and the dashboard, with nothing running on a timer. Steps 3 through 5 add the level 2 runner. Step 6 is the optional n8n review pipeline.
If you just want the manual loop — the slash commands and the tracker skill — the shortest path is the Claude Code plugin. From inside Claude Code:
/plugin marketplace add adamentwistle/legwork
/plugin install legwork@legwork
This installs core/ (the complete level-1 product) as a plugin: the six
commands (/add, /wrap, /pickup, /log, /shelve, /vision) and the
legwork-tracker skill, available in every repo. The plugin is this repo's
core/ directory — sourced as ./core by the marketplace manifest at the
repo root, so there is one editable source and nothing copied. Your queue
defaults to ~/legwork; set LEGWORK_DIR to move it. The plugin does not set
up the self-rebuilding dashboard or the level-2 runner — for those, clone the
repo and run the wizard below.
Most people should just run the wizard. From the cloned repo:
./install.sh
./install.sh is a thin wrapper around scripts/legwork_install.py, a
standard-library-only interactive installer. Its first question is the
install level, and the answer decides how much of this guide it walks:
- Level 1, the manual loop (the default on a fresh clone) covers steps 1,
2 and 4: it asks where the repo lives, writes
config, createsprojects/, offers to copy the slash commands and the legwork-tracker skill into user-level~/.claude, and offers the session hooks — with no webhook set, the SessionEnd hook rebuilds the dashboard after every session, so the queue page stays fresh without you running the builder. The timer step is never reached, so nothing runs in the background. - Level 2, autonomy walks the same ground as steps 1 through 5: every
config value (caps, review pipeline, tick interval),
.runner-logs/, the launchd agent (macOS) or crontab line (Linux), and the SessionStart/SessionEnd hooks in your Claudesettings.json.
Either way it asks before every action that touches anything outside the
repo, so you can decline any piece and do it by hand. The written config
records the level (LEGWORK_LEVEL), so graduating from level 1 is just
re-running ./install.sh in the same checkout and picking level 2.
Flags: --yes accepts every default without prompting, but the steps that
touch things outside the repo (the user-level command install, the
launchd/cron timer and the Claude hooks) are skipped unless you add
--with-commands, --with-launchd or --with-hooks, so a headless --yes
install never writes to ~/.claude, loads a launchd agent or edits your
settings.json behind your back. --lite pins level 1 without asking;
--with-launchd pins level 2 (and clashes with --lite); --with-hooks
works at either level, so --yes --lite --with-hooks is the headless
lite-with-hooks install. --no-color prints plainly. Re-running is safe: it reads your existing
config to pre-fill the prompts (including the level; a config from before
the level question reads as level 2), refreshes the command copies, and
never duplicates a launchd agent, crontab line or hook entry.
The rest of this guide is the manual path. Follow it if you skipped a piece of the wizard, want to understand exactly what it did, or are wiring the optional n8n review pipeline (step 6), which the wizard does not automate because it lives in your n8n instance, not on this machine.
python3. Version 3.9 or newer is fine. Everything incore/,suite/andscripts/is standard library only, no pip installs. On macOS the launchd agent runs the runner with/usr/bin/python3, the system interpreter, so it does not depend on a shell-managed Python.- The Claude Code CLI (
claude) on yourPATH. The runner shells out to it for each headless session. git. The runner pulls and commits the legwork repo, and it only fires a project when the target repo is a clean git tree.- A writable git remote for the legwork repo, only for level 2: the runner
pulls before every tick and pushes each claim, so it needs a remote it can
push to (a private fork, or even a local bare repo). The Telegram reply
path additionally needs that remote to be on GitHub, since it writes
decisions back through the GitHub Contents API. The manual loop, the
dashboard,
--dry-runand--doctorall work with no remote.
legwork is the legwork repo. The runner, the dashboard builder and the
config file all live inside it, alongside your project files and the
generated dashboard. The default location is $HOME/legwork. Put the legwork
checkout there, or put it anywhere and point LEGWORK_DIR at it.
git clone <your-legwork-remote> "$HOME/legwork"
cd "$HOME/legwork"
mkdir -p projects
The projects/ directory is the source of truth: one markdown file per
project (frontmatter, an optional ## Vision, a ## Next prompt fenced
block, an append-only ## Log). For the file format, see
examples/projects/. Those are invented sample projects that show the
frontmatter, Vision, prompt and Log layout. The full spec is in
core/skills/legwork-tracker/SKILL.md.
The stock .gitignore ignores /projects/, /dashboard/index.html and
/config, so a fresh clone can never leak your real queue into a public
fork by accident. That default is right for trying legwork out, and wrong
the moment this checkout becomes your actual tracker: the level 2 runner
commits and pushes project files on every claim and wrap, and the verbs
(/wrap, /vision) commit them too when they can. When you are ready:
- Point the checkout at a private remote you control
(
git remote set-url origin <your-private-remote>), or clone your private fork in the first place. - Delete the
/projects/and/dashboard/index.htmllines from.gitignore(the comment above them says the same). Leave/configignored; it can hold webhook URLs. - Commit and push, and your queue is versioned from here on.
Until you do this, project files still work; they just live untracked on this machine only, and the runner cannot fire.
If you put the checkout somewhere other than $HOME/legwork, set
LEGWORK_DIR to that path in your config (step 3) and also export it from
your shell profile (export LEGWORK_DIR=/path/to/your/clone): the config
file is read by the runner, while the slash commands resolve the queue
through the environment variable.
The manual loop is six slash commands (/add, /wrap, /pickup, /vision,
/log, /shelve) plus the legwork-tracker skill they share. Their source
lives in core/commands/ and core/skills/ (the repo's .claude/ entries
are symlinks to them), which means a fresh clone only has them inside the
checkout itself; a /wrap at the end of a session in one of your own repos
would find nothing. Install them user-level so they work from any repo:
mkdir -p ~/.claude/commands ~/.claude/skills
cp core/commands/*.md ~/.claude/commands/
cp -R core/skills/legwork-tracker ~/.claude/skills/
This is the same thing the wizard's command step does. The commands find the
queue via $LEGWORK_DIR (falling back to ~/legwork), which is why step 1
has you export it when the checkout lives elsewhere. Re-copy after pulling a
legwork update; the wizard refreshes the copies on re-run.
Copy the template and edit it:
cp config.example config
config is gitignored. suite/legwork_runner.py reads it at startup via
load_config(), so launchd (which does not read your shell profile), cron and
manual runs all share one source of truth. The file is KEY=VALUE lines;
# starts a comment; $HOME and ~ are expanded.
Real environment variables always win over the file. If a variable is already
set in the environment, the value in config is ignored. You can also point
the runner at a config elsewhere with LEGWORK_CONFIG=/path/to/config.
The defaults are sensible for a first run. LEGWORK_DIR defaults to
$HOME/legwork and LEGWORK_DAILY_CAP defaults to 8 fires per project per
day. The two webhook URLs are optional and commented out; leave them unset
for now. For every variable, see CONFIG.md.
Two Claude Code hooks bracket every session. They are useful at both levels:
with LEGWORK_WEBHOOK_URL set they feed the review pipeline; without it the
SessionEnd hook rebuilds the dashboard instead, so a level-1 install gets a
queue page that stays fresh on its own. Register them in the settings.json
of the Claude config the runner uses. If you set CLAUDE_CONFIG_DIR (or a
per-account CLAUDE_CONFIG_DIR_<NAME>) in your config, that is the config
dir whose settings.json needs them. If you left CLAUDE_CONFIG_DIR unset,
autonomous sessions inherit your default config, so register them in
~/.claude/settings.json.
core/session_start_hook.py(SessionStart): records the repo HEAD for the session, so the end hook can report only what this session changed.core/session_end_hook.py(SessionEnd): withLEGWORK_WEBHOOK_URLset, gathers session-scoped git evidence plus the project's tracker entry and POSTs them to the webhook; with it unset, runscore/build_dashboard.pysodashboard/index.htmlreflects what the session just wrapped.
Add this to the settings.json of that config dir, with $HOME/legwork
replaced by your LEGWORK_DIR if it differs, and /usr/bin/python3 by your
interpreter (on Windows, C:\\path\\to\\python.exe — there is no python3
there, only a stub that exits 9009).
Both hooks must be shell-form entries: one command string naming the
interpreter and the script. An exec-form args entry never fires, and one
sitting beside a valid hook silently voids the entire hooks block with no
error at all.
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "/usr/bin/python3 $HOME/legwork/core/session_start_hook.py"
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "/usr/bin/python3 $HOME/legwork/core/session_end_hook.py"
}
]
}
]
}
}Both outcomes are logged to $LEGWORK_DIR/hook.log, and the hook always
exits 0 — a failed rebuild or POST never blocks the session. Sessions that
end via clear or resume are skipped either way, since they are restarting,
not finishing.
The runner ticks on a timer. Each tick fires every eligible project as one
headless claude -p session, then wraps. Pick launchd on macOS or cron on
Linux.
suite/com.legwork.runner.plist is a template with two placeholders:
__LEGWORK_DIR__ (the absolute path to your legwork repo) and __PYTHON__
(the interpreter, normally /usr/bin/python3). Fill them in, drop the result
in ~/Library/LaunchAgents/, and load it:
sed -e 's#__LEGWORK_DIR__#'"$HOME"'/legwork#g' \
-e 's#__PYTHON__#/usr/bin/python3#g' \
suite/com.legwork.runner.plist > ~/Library/LaunchAgents/com.legwork.runner.plist
launchctl load ~/Library/LaunchAgents/com.legwork.runner.plist
The agent runs at load and then every StartInterval seconds. The template
ships with StartInterval set to 300 (every 5 minutes); edit that value in
the plist to change the tick interval. Runner output goes to
$LEGWORK_DIR/.runner-logs/launchd.log.
To stop it:
launchctl unload ~/Library/LaunchAgents/com.legwork.runner.plist
Add a crontab line that runs the runner every 5 minutes:
*/5 * * * * /usr/bin/python3 $HOME/legwork/suite/legwork_runner.py >> $HOME/legwork/.runner-logs/cron.log 2>&1
The cron schedule is the tick interval; change */5 to run more or less
often. Make sure .runner-logs/ exists (mkdir -p $HOME/legwork/.runner-logs)
and that claude is on the PATH cron uses.
Overlapping ticks are safe: a lock file makes a second tick exit quietly while one is in flight.
This step is optional. Everything above runs the queue, the runner and the
dashboard with no n8n. With neither LEGWORK_WEBHOOK_URL nor
LEGWORK_ALERT_URL set, the runner still fires sessions and they still wrap;
the review post and the Telegram alerts are simply skipped.
The pipeline is three importable n8n workflows. Import them into your own n8n instance:
suite/reviewer/n8n-review-workflow.json: the reviewer. Takes the session evidence and returns pass / revise / escalate.suite/reply-capture/n8n-reply-capture-workflow.json: the Telegram reply path. Reply to a review letter, or send slash commands, to drive the queue from your phone.suite/alerts/n8n-alerts-workflow.json: runner stall alerts and a daily heartbeat.
After import:
-
Fill the
REPLACE_WITH_placeholders in each workflow: n8n credential ids, your Telegram chat id, and the GitHubowner/repoof your legwork repo. The committed JSON never carries real secrets. -
Paste
suite/reviewer/n8n-build-node.jsinto the "Build review request" node. That file is the source of truth for the review rubric. The reviewer model comes fromREVIEWER_MODEL(defaultclaude-sonnet-4-6), applied in that node.suite/reviewer/rubric.mdis the readable mirror. -
Set the webhook URLs in your
config:LEGWORK_WEBHOOK_URL=https://your-n8n-host/webhook/legwork-review LEGWORK_ALERT_URL=https://your-n8n-host/webhook/legwork-alertLEGWORK_WEBHOOK_URLis the review post the SessionEnd hook and the runner send to.LEGWORK_ALERT_URLreceives stall alerts and the heartbeat.
For the Telegram side (the bot and its credential, the GitHub fine-grained
PAT, the Anthropic key, restricting the trigger to your own Telegram user
id, and activating), follow suite/reply-capture/SETUP.md. The write-back token
is a fine-grained, repo-scoped PAT held only as an n8n credential, never in
the repo.
Run these from your legwork repo to confirm the install:
python3 -m unittest discover -s tests
The full stdlib test suite should pass.
python3 core/build_dashboard.py
This regenerates dashboard/index.html from the top-level projects/*.md.
With an empty projects/ it builds an empty dashboard. The samples under
examples/projects/ are reference only and are not picked up by the builder;
copy one into projects/ if you want to see it on the dashboard. The html is
a build artifact and is gitignored.
python3 suite/legwork_runner.py --dry-run
This prints each project and why it is or is not eligible to fire, and changes
nothing. A fresh queue with no autonomy: loop projects will show every
project skipped, which is correct: autonomy is opt-in per project, granted by
a human via /vision or the Telegram /loop command. Drop --dry-run to run
one real tick by hand.
python3 suite/legwork_runner.py --doctoris the first stop: it checks the config, the repo layout, theclaudebinary, the git state and the review mode, and says what is wrong in plain lines.$LEGWORK_DIR/runner.logis the audit trail of every tick: what fired, what was skipped and why, per-fire cost, and review verdicts.$LEGWORK_DIR/hook.logrecords every SessionStart/SessionEnd hook firing and skip, including the webhook POST result (or, with no webhook set, the dashboard rebuild outcome).$LEGWORK_DIR/.runner-logs/holds the timer's own stdout (launchd.logorcron.log) and per-session transcripts.- The slash commands not found in your own repos? They only ship inside this
checkout; install them user-level (step 2). Commands finding no queue?
Export
LEGWORK_DIRin your shell profile (step 1). - The runner assessing a project eligible but never firing it? Check the
legwork repo has a writable remote and that
/projects/is no longer gitignored ("Make this repo your tracker", step 1). - Touch
$LEGWORK_DIR/.runner-pauseto stop all firing immediately without uninstalling anything; delete it to resume.
Everything legwork installs outside the repo is one timer, one settings entry and the copied commands; remove them and the checkout is just a directory you can delete.
# macOS: unload and remove the timer
launchctl unload ~/Library/LaunchAgents/com.legwork.runner.plist
rm ~/Library/LaunchAgents/com.legwork.runner.plist
# Linux: remove the marker-tagged crontab line
crontab -l | grep -v "# legwork runner" | crontab -
# the user-level commands and skill
rm ~/.claude/commands/{add,wrap,pickup,vision,log,shelve}.md
rm -r ~/.claude/skills/legwork-tracker
Then open the settings.json you registered the hooks in (~/.claude/ or
your dedicated CLAUDE_CONFIG_DIR) and remove the two entries whose
command ends in session_start_hook.py / session_end_hook.py. Finally
delete the checkout, which takes config, projects/ and every log with
it; if you made the repo your tracker, your queue also lives on your private
remote.