-
Notifications
You must be signed in to change notification settings - Fork 0
recipe authoring
A recipe is a hand-authored integration definition for one capability bundle (an MCP server, a set of skills, a vendored plugin, …). A stack composes a harness plus a chosen set of recipes (stacks guide). Recipes are assembled ahead of time into a committed, version- controlled profile — nothing is resolved at container start (design §5, §11).
For the why (why recipes exist, why Claude-canonical is the single format, why pnpm), read
docs/harnessed-design.md §5 & §11. This guide shows the how with worked
examples from this repo's catalog/recipes/.
A recipe lives at catalog/recipes/<name>/recipe.yaml. It can contribute to three things:
-
MCP layer — server entries (under
mcp.servers) merged into the stack's hatago config. -
File-extension layer —
skills/commands(andagents/hooks/rulesvia plugins) in Claude-canonical form, fanned into harness-native profile paths. -
Dockerfile body — installation steps appended to the derived stack image; the primary way to
install tooling, frameworks, or CLIs into the stack. The assembler concatenates Dockerfile bodies
in recipe order to build the derived
harnessed-<stack>image.
A recipe may have any combination of these, or (like catalog/recipes/omp, catalog/recipes/opencode, catalog/recipes/gemini,
catalog/recipes/antigravity, and catalog/recipes/codex) none — it can exist only to declare a runtime contract. Only the fields the recipe exercises are required; the assembler parses the rest
forward.
The typed model lives in src/harnessed/schema.py (Recipe,
McpServer, FileExt). Key fields:
name: <recipe-name> # required
description: <one-liner> # optional
expect: # optional — capabilities your Dockerfile delivers that the assembler
skills: [skill-name] # cannot see; the capability test probes each in the running container
commands: [cmd-name] # (skills → ~/.claude/skills, commands → ~/.claude/commands,
plugins: [plugin-name] # plugins → ~/.claude/plugins, mcp → connected through hatago).
mcp: [server-name]
# --- MCP layer (optional) ---
mcp:
servers:
- name: <server> # required
command: <cmd> # stdio servers only — hatago spawns this as a child (stdio→HTTP)
args: [<arg>, ...] # optional
transport: stdio # stdio (default) | http
# network-native (transport: http) — instead of `command`, reference a URL or a service:
url: <http-url> # optional, direct URL
service: <service-name> # optional, references catalog/services/<name>/service.yaml (resolved to a URL)
url_env: <ENV> # optional, env injected into the instance
env: {<k>: <v>} # optional
headers: {<k>: <v>} # optional
# --- File-extension layer (optional) ---
skills: # standalone skill dirs shipped by this recipe
- path: skills/<skill-name> # relative to the recipe dir; leaf name = harness-native target
commands: # same shape as skills
- path: commands/<cmd-name>
# --- Persistence layer (optional) ---
persist: # list of entries; each declares scope + location for one data item
- name: <dir-or-file> # for location: host — target name at container home (~/<name>)
scope: workspace # workspace | project | global
location: host # host | in_repo
- name: <item> # for location: in_repo — name inside the mounted workspace
scope: workspace
location: in_repo
vcs: ignored # tracked | ignored (ignored → harnessed adds .gitignore entry)
- path: /abs/path/on/host # for scope: global — real host path (must be in persist-allowlist)
scope: global
# --- One-time init (optional) ---
init: # run once per project, gated on marker existence
marker:
scope: workspace # workspace | project (NOT global)
location: host # host | in_repo — same axes as persist:
name: <dir-name> # for host: the persist dir name; for in_repo: workspace-relative path
file: <subpath> # OPTIONAL — specific file/dir under name to check
run: <shell-command> # required — runs in a transient podman run --rm, no secretsNotes:
-
transportis explicit (design RESEARCH Pitfall B). Astdioserver (withcommand) is run by hatago as a child and must be available inside the hatago image; the harness never speaks to this command directly. A network-native server (transport: http) is proxied by hatago by URL. - The assembler fans each skill/command dir into the harness-native profile path
(
.claude/skills/<leaf>,.claude/commands/<leaf>) and fails fast on name collision (design §7). - Forward-parsed fields (
plugins,deps,extensions) are accepted but only exercised where relevant; seecatalog/recipes/omp/recipe.yamlforextensions. -
hooksis a TYPED field (GAP 2):{EventName: [{command, matcher?}]}, merged by the assembler into the profile'ssettings.jsonusing Claude Code's native hook shape.commandruns INSIDE Claude Code's own hook runner every time the event fires (not a launcher-side, once-per-project mechanism — that'sinit:, see above). Seecatalog/recipes/caveman/recipe.yamlfor a first-runSessionStartreminder example.
If a recipe needs to install tooling into the stack image, it ships a Dockerfile alongside
recipe.yaml. The assembler concatenates the Dockerfile bodies of all recipes in the stack's recipe
order, prepends FROM harnessed-${HARNESS}:latest, and builds the derived harnessed-<stack>
image from the result. See "Worked example 3" for the full pattern.
A recipe can declare persistent storage — directories or files that survive --fresh relaunches and
accumulate data across sessions. Each entry is a two-axis declaration: scope (what identifies the
owning context) and location (where the data lives).
scope |
Keyed by | Survives? |
|---|---|---|
workspace |
Resolved launch path | Per worktree — different dir for every checkout |
project |
git-common-dir (git rev-parse --git-common-dir) |
Shared across all worktrees of one checkout; does NOT survive an independent clone |
global |
A real host path (from path:) |
Shared across all projects; must be pre-registered in ~/.config/harnessed/persist-allowlist
|
workspace is the right default for most recipes. Use project when the data is logically
per-repository and should be consistent whether you're on main, a feature branch, or a hotfix
worktree. Use global only for tools that maintain one shared knowledge base across all your
projects (e.g., a personal-memory or brain tool).
location |
Where the data lives |
|---|---|
host |
harnessed-managed directory at $XDG_DATA_HOME/harnessed/persist/<recipe>/<hash>/<name>/. Bind-mounted into the container at ~/<name>. No repo involvement. |
in_repo |
Inside the workspace already mounted by the launcher. No extra bind-mount; the container reads/writes through the existing workspace mount. Requires vcs:. |
When location: in_repo, the vcs: field is required:
-
vcs: tracked— the item is (or will be) committed. harnessed takes no.gitignoreaction. -
vcs: ignored— the item stays local, not committed. harnessed idempotently appends<name>to.gitignoreat launch. No-op when not in a git repo.
scope |
location |
Effect |
|---|---|---|
workspace |
host |
harnessed-managed dir, keyed by workspace path. Mounted at ~/<name>. Most common.
|
project |
host |
harnessed-managed dir, keyed by git-common-dir. Shared across all worktrees. |
global |
(set path: instead of name:) |
Real allowlisted host path; path-preserving mount. |
workspace or project
|
in_repo |
Item inside the workspace; no extra mount. vcs: required. |
scope: repo and location: external are reserved for a future release — the schema rejects
them with a clear error today.
These are different fields that control different things.
-
persist:in a recipe — tool-specific data (a CBM index, beads config, context notes). Recipe-level; described here. -
state.session_statein a stack — where Claude's conversation history (projects/,history.jsonl) lands:host(default, shared with~/.claudeon the host) orvolume(throwaway, per-instance). See the stacks guide.
A recipe's persist: entries do not affect session history. A stack's session_state: volume does
not affect recipe persist data. They are orthogonal.
catalog/recipes/context-mode/recipe.yaml is the
reference implementation. It ships a skill that writes notes to ~/.context-mode, and declares that
directory as workspace + host so notes survive --fresh relaunches:
persist:
- name: .context-mode
scope: workspace
location: hostharnessed maps this to $XDG_DATA_HOME/harnessed/persist/context-mode/<workspace_hash>/.context-mode/
and bind-mounts it at ~/.context-mode read-write. The <workspace_hash> is sha1(project_path)[:8]
— different for /home/user/proj-a and /home/user/proj-b, so the two projects never share notes.
To inspect or prune persist data:
harnessed-tools persist-list
harnessed-tools persist-prune --recipe context-mode --project /path/to/proj --yes
# For project-scope entries (shared across worktrees of one checkout):
harnessed-tools persist-prune --recipe beads --project /path/to/proj --scope project --yesSome recipes need a one-time per-project setup that can't happen at image build time — because
the project isn't mounted during podman build. bd init, for example, creates .beads/ inside
the mounted project dir; there is no project to initialize at build time.
The init: block wires this into the launcher lifecycle. The launcher checks a host-side marker
before every harnessed launch (and you can run it explicitly with harnessed init <stack>). If
the marker path doesn't exist, it runs the init command in a transient one-shot container with the
same project + persist bind-mounts as a normal launch, then exits. Subsequent launches see the
marker and skip the step — idempotent by construction.
init:
marker:
scope: project # workspace | project — same scope axis as persist:
location: host # host | in_repo — same location axis as persist:
name: .beads # for location: host — the persist dir name (resolves to ~/<name> in container)
# for location: in_repo — a path relative to the workspace root
file: config # OPTIONAL — a specific file/dir under 'name' to check; absent = check name itself
run: bd init --quiet --stealth # required — the shell command to run inside a transient containerScope/location reuse: init.marker uses the same vocabulary as persist:, but scope: global
is explicitly rejected (a global path can't mark per-project initialization). The marker path
resolves via the same paths.persist_project_dir / paths.persist_workspace_dir helpers that
_persist_mounts uses, so the marker always points at the same directory the persist entry mounts.
No secrets: harnessed init uses a transient podman run --rm with no pod, no hatago, and
zero secrets/varlock involvement — same principle as harnessed build (both must always work
without credentials).
Hard failure: a non-zero exit from run is a hard error that aborts with a clear message.
An agent working against a half-initialized tool is worse than an explicit failure.
catalog/recipes/beads/recipe.yaml is the reference
implementation. It host-persists .beads/ under scope: project (shared across all worktrees)
and runs bd init --quiet --stealth once to create it:
persist:
- name: .beads
scope: project
location: host
init:
marker:
scope: project
location: host
name: .beads
run: bd init --quiet --stealthBecause persist: mounts $XDG_DATA_HOME/harnessed/persist/beads/<project-hash>/.beads/ at
~/.beads, and the Dockerfile bakes ENV BEADS_DIR=/home/harnessed/.beads, the init command
runs against the right location — always, regardless of which directory the agent is working in.
After harnessed build claude_beads:
harnessed init claude_beads # explicit one-time init (or just: harnessed claude_beads)
harnessed claude_beads # auto-checks init; skips if .beads/ already exists
harnessed init claude_beads # idempotent — prints "already initialized", exits 0WHY this exists and not an agent-driven alternative: "have the agent run bd init on first
use" is nondeterministic — the agent might not, especially in a new project with no context. A
Dockerfile RUN bd init is impossible (no project mounted at build time). The init: mechanism
gives recipe authors a deterministic, harness-independent hook that fires before the agent ever
starts, so every project begins in a known state.
Some recipes are mutually exclusive — most commonly, two recipes that each claim to be the agent's
sole cross-session memory store (each ships a rules:/instruction entry telling the agent to
use it exclusively). Combining them in one stack doesn't fail to build, but silently gives the
agent two contradictory sets of workflow instructions.
conflicts: [<other-recipe-name>]load_stack_with_recipes checks every recipe's conflicts: list against the full set of recipes
in the stack and raises a SchemaError (at harnessed build/test time, before any container is
touched) if two conflicting recipes are both present. The check is symmetric — either recipe
declaring the other is enough; you don't need to add conflicts: to both sides, though doing so
makes the incompatibility discoverable from either recipe's file.
Worked example: catalog/recipes/beads/recipe.yaml and
catalog/recipes/agent-carnet/recipe.yaml each
declare conflicts: [agent-carnet] / conflicts: [beads] — both are persistent-memory tools that
instruct the agent to treat themselves as the sole memory store.
catalog/recipes/time/recipe.yaml is the tracer bullet — exactly one
light stdio MCP server and one standalone skill:
name: time
description: Time and timezone queries via the network-free uvx mcp-server-time stdio MCP server.
mcp:
servers:
- name: time
command: uvx
args: [mcp-server-time]
transport: stdio
skills:
- path: skills/time-helper-
command: uvx,args: [mcp-server-time]— a light Python MCP server run viauvx(the uv runner; see Supply-chain rules below). hatago spawnsuvx mcp-server-timeas a child and wraps its stdio into the single HTTP endpoint the harness talks to. -
transport: stdiois explicit: the harness never runsuvxitself; it reaches hatago. -
skills/time-helperis a standalone skill dir shipped by this recipe; it lands at.claude/skills/time-helperin the assembled profile.
A stack that references it (catalog/stacks/claude_time) builds + runs it via:
harnessed build claude_time && harnessed claude_time
harnessed test claude_time # capability report: ✓ time (mcp) connected, ✓ time-helper (skill) presentcatalog/recipes/ping/recipe.yaml is the other MCP shape — a
network-native server referenced by service, with no command:
name: ping
description: Tracer shared service — a network-native ping MCP server.
mcp:
servers:
- name: ping
service: ping
transport: http- No
command: this is a service reference, not a stdio child. The assembler resolvesservice: ping→ a hatago URL-proxy entry pointing at the running sidecar (http://ping:8080/mcp). hatago proxies it; the service runs as its own container on the shared network (design §3, §9). -
transport: httpbecause the server is already network-native (Streamable HTTP). - The sidecar itself is authored under
catalog/services/ping/— see the service-authoring guide.
Contrast: time (stdio child hatago must bake + spawn) vs ping (HTTP sidecar hatago proxies by
URL). Use stdio for light, dependency-free servers you want baked in; use a service for stateful or
shared systems that outlive any instance.
catalog/recipes/gstack/ installs a third-party skill suite (Garry
Tan's gstack) by baking it into the agent image with a
Dockerfile body — no MCP server, no standalone skill dir.
The whole trick: do what the project's install docs tell you to do. gstack's README says "clone
the repo and run ./setup", so that is exactly what the recipe Dockerfile runs — the same commands
you'd run on the host. You don't hand-copy files or reverse-engineer the layout; you replicate the
upstream installer.
name: gstack
description: Garry Tan's gstack skill suite installed via its upstream ./setup.
expect:
skills: [gstack, office-hours, qa, plan-ceo-review, review]-
expect:declares what the Dockerfile installs. The assembler fans standaloneskills:/commands:directories into the profile, but it can't see what a Dockerfile RUN step drops into~/.claude/. So you list the skills/commands/plugins it bakes and the capability test probes for them in the running container. gstack installs ~50 skills into~/.claude/skills; a stable handful is enough to prove the install worked. -
Recipes are harness-independent. A recipe never lists which harnesses it supports — every
harness consumes the same Claude-canonical profile. If a step genuinely differs per harness,
branch on the
${HARNESS}build arg inside the Dockerfile; never exclude harnesses at the recipe level.
USER root
# gstack's Chromium (via Playwright) needs OS libraries its ./setup doesn't install.
RUN bunx playwright install-deps chromium
USER harnessed
# Run gstack's own documented install — clone + ./setup, exactly as on the host. Upstream publishes
# no release tags, so pin to an exact commit SHA (fetch-by-SHA) — a bare clone of the default branch
# is a floating ref and fails pin validation.
ARG GSTACK_REF=11de390be1be6849eb9a15f91ff4922dd16c589a
RUN git init -q ~/.claude/skills/gstack && cd ~/.claude/skills/gstack \
&& git remote add origin https://github.com/garrytan/gstack.git \
&& git fetch --depth 1 origin ${GSTACK_REF} && git checkout -q FETCH_HEAD \
&& ./setupThis is the core pattern trimmed for clarity. The real
catalog/recipes/gstack/Dockerfilealso hands ownership of the root-created~/.buncache back toharnessedbefore./setupand sets a gstack config flag — both gstack-specific. The general lesson: run installers that write into~asharnessed, and fix up ownership of any caches an earlierUSER rootstep created.
Rules for recipe Dockerfiles:
-
No
FROM, noARG HARNESS. The assembler prependsFROM harnessed-${HARNESS}:latestand re-declaresARG HARNESSafter it, so${HARNESS}is already available in your body. Adding your ownFROMorARG HARNESSproduces a malformed concatenated Dockerfile. -
USER rootfor system installs, thenUSER harnessed. apt andplaywright install-depsneed root; drop back to the unprivileged user before the body ends. -
Pin every download. Explicit floating refs —
@latest,--branch main/master/HEAD, a bare:latesttag — are rejected by the assembler's pin validation (PinValidationError) before any layer is built. Pin to a tag or commit SHA for reproducibility.
A recipe Dockerfile doesn't hand-copy files or reconstruct what a project's installer already does — it runs the project's published install steps. Look at the upstream install docs and replicate them, whatever shape they take:
| Upstream install docs say… | Recipe Dockerfile runs… |
|---|---|
"clone the repo and run ./setup" |
RUN git clone … && cd … && ./setup (gstack) |
"pnpm dlx <pkg>@x.y.z" |
RUN pnpm dlx <pkg>@x.y.z |
"uv tool install <pkg>==x.y.z" |
RUN uv tool install <pkg>==x.y.z |
"apt install <foo>" |
RUN apt-get install -y <foo> (under USER root) |
Two things to watch for:
-
Missing system deps. An installer may pull an application but not its OS libraries — gstack
downloads Chromium but not Chromium's shared libs, so the recipe adds
playwright install-deps. -
Harness targeting. Most installers are harness-agnostic or auto-detect the agent (gstack's
./setupdoes). If one needs to know the target, pass it the${HARNESS}build arg.
harnessed build claude_gstack_ping_time_greet # assemble + build the derived image (supply-chain gate)
harnessed claude_gstack_ping_time_greet # launch the pod (harness + hatago)
harnessed test claude_gstack_ping_time_greet # capability report: ✓ declared gstack skills presentcatalog/recipes/openbrain-example/recipe.yaml
is the third MCP shape — a network-native server referenced by a direct URL, with no
command (it is not a stdio child) and no service (it is not a local sidecar). hatago proxies the
remote server by URL; the harness only ever sees hatago's single endpoint.
name: openbrain-example
description: Template — a remote, url-based Streamable-HTTP MCP server (modelled on OB1/OpenBrain).
mcp:
servers:
- name: openbrain-example
url: https://YOUR-PROJECT.supabase.co/functions/v1/open-brain-mcp?key=YOUR_OB1_KEY
transport: http-
url:+transport: http— a remote Streamable-HTTP server, used as-is. Use it for any MCP server that already runs somewhere reachable: a hosted function, a SaaS endpoint, your own box. -
Three shapes, recap:
timeis a stdio child hatago bakes + spawns;pingis a local sidecar resolved fromservice:;openbrain-exampleis a remote URL hatago proxies directly._hatago_entryemits{url, type: http, headers?}for the network-native shapes.
hatago runs inside the pod. A remote https://… URL needs nothing special. But a server on the
host (say http://localhost:8787/mcp) is not reachable as localhost from the pod — rewrite
it to http://host.containers.internal:8787/mcp, the same host-gateway address the service:
resolver emits (assemble.py).
_hatago_entry writes the url: (and any headers:) verbatim into the generated
hatago.config.json. A server like OB1 authenticates with a ?key= query parameter, so the key
rides in the URL. That file is emitted under $XDG_DATA_HOME/harnessed/profiles/<stack>/ — host-local,
never an image layer, never committed — but it is on disk in plaintext. So:
- Never commit a real key. The repo recipe above is a template with a placeholder; a recipe carrying your real key belongs only in your user-overlay catalog (below).
-
url_envis accepted by the schema but not yet wired into emission — there is no built-in env-substitution for a url server's URL today, so the key goes in the URL.
You don't have to add a private stack to this repo at all. The user-overlay catalog
~/.config/harnessed/catalog is searched first and wins on name clash (paths.catalog_roots),
so author the real recipe + stack there and build/run/test them by name:
~/.config/harnessed/catalog/recipes/openbrain/recipe.yaml # your real URL + key
~/.config/harnessed/catalog/stacks/claude_openbrain/stack.yaml
# ~/.config/harnessed/catalog/recipes/openbrain/recipe.yaml
name: openbrain
description: OB1 (OpenBrain) personal-memory MCP server over Streamable HTTP.
mcp:
servers:
- name: openbrain
url: https://YOUR-PROJECT.supabase.co/functions/v1/open-brain-mcp?key=XXXX
transport: http# ~/.config/harnessed/catalog/stacks/claude_openbrain/stack.yaml
name: claude_openbrain
harness: claude
recipes: [openbrain]harnessed build claude_openbrain
harnessed claude_openbrain
harnessed test claude_openbrain # ✓ openbrain (mcp) connectedThe committed
claude_openbrain-examplestack documents this shape. Its URL is a placeholder, so it assembles (and the fast assembly test covers it) but is excluded from the live capability sweep — there is no real endpoint to connect to.
| Transport | When | Notes |
|---|---|---|
| stdio | light server hatago runs as a child | hatago wraps stdio→HTTP; bake the server into the hatago image via pnpm dlx (Node) / uvx (Python). The harness only sees hatago's HTTP endpoint. |
| streamable-http | a network-native server (your own service, or a remote) | One endpoint, POST + optional GET/SSE stream. Reference by url: or service:. |
| deprecated | SSE is deprecated in the current MCP spec (2025-06-18) and in Claude Code. Use Streamable HTTP for new servers. |
See the "What NOT to Use" table in CLAUDE.md.
Two hard rules, both enforced by the build (design §7):
-
pnpm everywhere (no
npm/npx). Every JavaScript install — global, per-recipe, hatago's bundled servers — uses pnpm;pnpm dlxreplacesnpx. A managed supply-chain config appliesminimumReleaseAgecooldowns and lifecycle-script default-deny. Recipe validation (part ofharnessed build, BLD-03) flags any rawnpm/npxin a recipe's scripts/deps and points at the pnpm equivalent — the build fails fast until you fix it. -
uvxfor Python MCP servers. Light Python servers (likemcp-server-time) run viauvx, the uv runner. Python dependencies declaredeps.python(pyproject.toml→uv venv+uv pip install -e ., orrequirements.txt→uv pip install -r).
The derived image's final layer then runs an advisory in-image scan over what your recipe
installed — snyk (token-gated) plus credential-free osv-scanner + pip-audit. It reports a severity
summary and writes scan-report.json; it does not fail the build. See the
troubleshooting guide for reading the scan report.
harnessed build never needs secrets, ever — this is load-bearing, not incidental. Even if
~/.config/harnessed/.env.schema declares SNYK_TOKEN, the build never invokes varlock or touches
it; snyk just warn-skips without a token while osv-scanner/pip-audit still run. A real, credentialed
scan is a deliberately separate step (harnessed rescan, run explicitly, secrets resolved by you
if you want them) — see the secrets guide.
Building and verifying a recipe must never require 1Password, a service-account token, or any other
credential to be available or authorized.
The fast unit/assembly test suite (uv run pytest) checks schema validity, pin format, and
assembly logic. It does not fetch any real artifact or build a real image — so it cannot catch
an upstream pin that's drifted (a pinned version/tag that no longer exists), a wrong asset-naming
assumption, or an install-path assumption that doesn't match how a package manager actually lays
out a global install. Recipes have shipped with exactly these bugs and passed the fast suite
cleanly; only a real harnessed build surfaced them.
Before considering a new (or changed) recipe done:
harnessed build <stack-using-the-recipe> # a REAL build — fetches real artifacts, runs the real Dockerfile
harnessed test <stack-using-the-recipe> # capability report: every declared skill/command/rule/MCP presentBoth must succeed. harnessed build never requires secrets (see above), so there's no reason to
skip this step even in a sandboxed or non-interactive environment. If the build fails on something
external (a 404 on a pinned download, a wrong extraction path, an unexpected package layout),
that's the real bug to fix — not something the fast test suite would ever have told you about.
Author catalog/recipes/<name>/recipe.yaml, then reference it from a stack's recipes: list. See the
stacks guide for composition, scaffolding (harnessed new), and the full build → run →
test lifecycle.
- docs/harnessed-design.md §5 & §11 — the why (composition unit, recipe schema, dependency model).
- Stacks guide — compose recipes into a stack.
-
Service-authoring guide — author the
ping-style sidecar a service-ref recipe points at. -
src/harnessed/schema.py— the typedRecipe/McpServer/FileExtmodels.
Start Here
Guides
- Recipe authoring
- Service authoring
- Stacks
- Extending stacks (proposed)
- Recipe catalog
- System prompt & rules (proposed)
- Secrets
- AWS SSO
- Pulumi (host login forwarding)
- Egress & exposing services
- Container filesystem
- Git hooks
- Troubleshooting
- Pin management (harnessed update)
Codebase Map
Planning & Roadmap
- open work: GitHub Issues
Research & Prompts
- research/ (home-folder requirements per harness, browse in-repo)
- prompts/ (reusable prompt templates, browse in-repo)