Docs / Getting started
Install the binaries, scaffold a bundle, and put a working tool on a harness. It takes ten minutes, and preflight tells you what the harness could not do.
Whippletree is two binaries and Go 1.22 or newer builds them. Install both, into the same directory:
$ go install whippletree.dev/cmd/whippletree@latest $ go install whippletree.dev/cmd/whippletree-hook@latest
whippletree is the CLI you run. whippletree-hook is the
dispatcher the harness runs: it sits inside your bundle and turns a harness's hook
invocation into a call to your handler. build provisions a bundle's
bin/whippletree-hook by copying the one next to the running
whippletree, which is why the two have to travel together.
The signed release archives contain both, for linux, darwin and windows on amd64 and arm64. Checksums are signed keylessly with cosign, and the archives carry build attestations:
$ gh attestation verify <archive> --repo larstonder/whippletree
Check what you got, and which target definitions are compiled into it:
$ whippletree version whippletree v0.1.1 commit: 4e5a5f6d5ae890c1e52bf2bd3aefc27b5e5e71ec built: 2026-08-22T00:03:29Z go: go1.26.5 darwin/arm64 contract: 1.1.0 targets (4): claude-code schema 1.0.0 tested >=2.1.0 codex schema 1.0.0 tested >=0.144.0 copilot schema 1.0.0 tested >=1.0.80 opencode schema 1.0.0 tested >=1.18.10
--target codex, never --target=codex. And --help
works on the bare whippletree only: give it to a subcommand and you get an
error, that subcommand's usage line on stderr, and exit 1.A bundle is a directory with a plugin.json and some handlers.
init writes one:
$ whippletree init my-tool --yes whippletree: scaffolded my-tool in /home/you/my-tool
Drop --yes and, on a terminal, you get a short wizard asking for the name,
the requirement kinds, and then, for blocking-gate and
executable-path only, whether each is hard-required. Passing any of
--name, --kinds or --hard skips it too. The name
must match ^[a-z0-9-]+$, and defaults to the directory's own name.
init refuses to overwrite any file it would write, and it checks all of
them before writing the first, so a refusal leaves the directory untouched.
What landed:
my-tool/ plugin.json the contract handlers/lifecycle-signal.sh your code .claude-plugin/marketplace.json .gitignore README.md
That is the whole authored surface. Everything else in the bundle is generated, and
init writes a .gitignore naming those paths. Commit the five
files above, and let build write the rest.
{
"name": "my-tool",
"version": "0.1.0",
"description": "my-tool whippletree bundle",
"extensions": {
"dev.whippletree.v1": {
"contractVersion": "1.0.0",
"requires": [
{
"id": "lifecycle-signal",
"kind": "lifecycle-signal",
"event": "session-start",
"minTier": "T2",
"hardRequired": false,
"handler": "./handlers/lifecycle-signal.sh"
}
]
}
}
}
One requirement: run handlers/lifecycle-signal.sh when a session starts,
accept anything at T2 or better, and do not refuse to install if it falls short.
Concepts explains each field.
Ask for more than the default with --kinds. The set is closed:
$ whippletree init acme-tool --hard blocking-gate \
--kinds skill,lifecycle-signal,observation-signal,blocking-gate,executable-path
| --kinds value | scaffolds | at |
|---|---|---|
| skill | skills/<name>/SKILL.md | T1 |
| lifecycle-signal | handlers/lifecycle-signal.sh, on session-start | T2 |
| observation-signal | handlers/observation-signal.sh, on file-read | T4 |
| blocking-gate | handlers/blocking-gate.sh, on turn-end | T1 |
| executable-path | bin/<name> | T1 |
--hard takes a subset of --kinds, and only
blocking-gate and executable-path honour it: the other three
cannot refuse. Asking for skill alongside
blocking-gate wires them together, setting
fallbackSkill on the gate and relaxing its minTier to T3, so on
a harness with no blocking stop event the compiler moves the step into instructions
rather than refusing.
The scaffolded handler is a no-op that documents its own wire format:
#!/usr/bin/env bash
set -euo pipefail
# Runs on session-start. Payload JSON is on stdin; common fields are
# already in the environment: ADAPTER_EVENT, ADAPTER_PRIMITIVE,
# ADAPTER_TARGET, ADAPTER_CWD.
exit 0
The payload arrives on stdin as JSON, and the common fields are already in the
environment, so a handler that needs only those never has to parse anything. Exit 0 to
carry on. A blocking-gate exits 2 with a reason on stderr to block, and must
check ADAPTER_STOP_ACTIVE first. It is true when the gate has
already blocked once this turn, and ignoring it loops the harness forever.
You do not need a harness to test this. Call the handler the way the dispatcher will:
$ echo '{}' | ADAPTER_EVENT=session-start ADAPTER_PRIMITIVE=session-start \
ADAPTER_TARGET=claude-code ADAPTER_CWD="$PWD" ./handlers/lifecycle-signal.sh
$ echo $?
0
Stay in that loop while the handler is still taking shape, instead of reinstalling and restarting a harness after each change.
build compiles the contract into per-target artifacts:
$ whippletree build my-tool target claude-code: 1 satisfy, 0 degrade, 0 refuse, 0 absent target codex: 1 satisfy, 0 degrade, 0 refuse, 0 absent target copilot: 1 satisfy, 0 degrade, 0 refuse, 0 absent target opencode: 1 satisfy, 0 degrade, 0 refuse, 0 absent
It writes a hooks file and a manifest per target, a normalised copy of your contract, a snapshot of the target definitions it used, and the dispatcher:
my-tool/ hooks/claude-code.json codex.json copilot.json opencode.ts .claude-plugin/plugin.json claude-code .codex-plugin/plugin.json codex .plugin/plugin.json copilot .whippletree/contract.json .whippletree/targets/*.yaml bin/whippletree-hook
If whippletree-hook is not next to the whippletree you ran,
build stops and prints the go build line that would produce it.
--allow-missing-dispatcher turns that into a warning, which is what you want
in a job that only inspects the compiled output.
One target refusing a hard requirement makes build exit 1. It prints a line
per refusing target and requirement before it exits. --allow-refuse
keeps going, for a bundle that is not meant for every harness.
preflight is the step the tool exists for. It probes the harness on your
machine and tells you the tier each requirement reaches there, before you install
anything:
$ whippletree preflight my-tool --target claude-code whippletree preflight · target claude-code (probed 2.1.239) lifecycle-signal want ≥T2 got T1 SATISFY native SessionStart Plan: 1 satisfy, 0 degrade, 0 refuse.
The four target names are claude-code, codex,
copilot and opencode. Run it against each one you care about;
the answers differ, and the last column says why. A bundle
using all five kinds against opencode:
$ whippletree preflight acme-tool --target opencode
whippletree preflight · target opencode (probed 1.18.10)
skill want ≥T1 got T1 SATISFY placed via copy-dir skill channel
lifecycle-signal want ≥T2 got T1 SATISFY native event:session.created
observation-signal want ≥T4 got T1 SATISFY native matcher read
blocking-gate want ≥T3 got T3 SATISFY compiled to instructions
best-effort, no harness-level enforcement on this target: the model is
instructed to run the step and usually will, but can skip it under pressure
executable-path want ≥T1 got T1 SATISFY installer-resolved absolute path
Plan: 5 satisfy, 0 degrade, 0 refuse.
The gate reached T3 rather than T1, because opencode has no blocking stop event. It
satisfies only because the scaffold set minTier to T3 alongside
fallbackSkill, and the compiler moved the step into the skill's
instructions. Declare that gate hard at T1 instead and the answer changes:
$ whippletree preflight gate-tool --target opencode whippletree preflight · target opencode (probed 1.18.10) blocking-gate want ≥T1 got — REFUSE no native mapping for turn-end on this target Plan: 0 satisfy, 0 degrade, 1 refuse.
Exit 1, and nothing installed. A gate that becomes advice with no warning is worse than one that refused, because you will believe it is running.
Without a harness on the machine, --assume-version skips the probe. Probe a
version outside the range a target definition was tested against and you get a warning at
the top of the report, never a refusal:
! probed 1.0.0 is below the tested range >=2.1.0 ! the verdicts below were not verified against this version
install runs the same check, and what it does next depends on the
target:
$ whippletree install my-tool --target claude-code whippletree preflight · target claude-code (probed 2.1.239) lifecycle-signal want ≥T2 got T1 SATISFY native SessionStart Plan: 1 satisfy, 0 degrade, 0 refuse. install for claude-code is the harness's own plugin mechanism: claude plugin marketplace add my-tool claude plugin install my-tool@my-tool-mkt
For claude-code, codex and copilot, Whippletree places nothing. Those harnesses have
their own plugin marketplaces, and the compiled bundle is already in the shape they
expect, so Whippletree hands you the two commands instead of writing into their config.
The marketplace name is your bundle's name with -mkt
appended, which is what init put in
.claude-plugin/marketplace.json.
Codex spells the second verb add; the other two use install.
opencode has no marketplace, so Whippletree writes the shim itself, into the project
directory --project names, or the working directory if you omit it:
$ whippletree install my-tool --target opencode --project .
That writes .opencode/plugin/whippletree-my-tool.ts with the absolute path
to your dispatcher baked in, and copies any skills to
.opencode/skills/. Neither step overwrites anything Whippletree did not
generate, so a hand-written plugin of the same name is safe.
A successful preflight or install also drops
.whippletree/install-state.json in the bundle, recording the harness, its
version, and the tier each requirement reached, leaving out the ones the target cannot
provide at all. It is gitignored.