Whippletree

Docs / Getting started

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.

Install

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
Two conventions before you start. Flags take a separate argument: --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.

Scaffold a bundle

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.

The contract it wrote

{
  "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 valuescaffoldsat
skillskills/<name>/SKILL.mdT1
lifecycle-signalhandlers/lifecycle-signal.sh, on session-startT2
observation-signalhandlers/observation-signal.sh, on file-readT4
blocking-gatehandlers/blocking-gate.sh, on turn-endT1
executable-pathbin/<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.

Write the handler

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

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.

Read the preflight report

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 it

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.

Next