Skip to content

recipe authoring

Mike Crowe edited this page Jul 5, 2026 · 10 revisions

Authoring recipes

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

What a recipe is

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 layerskills / commands (and agents/hooks/rules via 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 recipe.yaml schema

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 secrets

Notes:

  • transport is explicit (design RESEARCH Pitfall B). A stdio server (with command) 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; see catalog/recipes/omp/recipe.yaml for extensions.
  • hooks is a TYPED field (GAP 2): {EventName: [{command, matcher?}]}, merged by the assembler into the profile's settings.json using Claude Code's native hook shape. command runs INSIDE Claude Code's own hook runner every time the event fires (not a launcher-side, once-per-project mechanism — that's init:, see above). See catalog/recipes/caveman/recipe.yaml for a first-run SessionStart reminder 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.

Persistence (persist:)

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 axis

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 axis

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 .gitignore action.
  • vcs: ignored — the item stays local, not committed. harnessed idempotently appends <name> to .gitignore at launch. No-op when not in a git repo.

Valid combinations

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.

Disambiguation: recipe persist: vs. stack state.session_state

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_state in a stack — where Claude's conversation history (projects/, history.jsonl) lands: host (default, shared with ~/.claude on the host) or volume (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.

Worked example

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: host

harnessed 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 --yes

One-time init (init:)

Some 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 container

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

Worked example

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

Because 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 0

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

Recipe conflicts (conflicts:)

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.

Worked example 1: the time recipe (stdio MCP + a standalone skill)

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 via uvx (the uv runner; see Supply-chain rules below). hatago spawns uvx mcp-server-time as a child and wraps its stdio into the single HTTP endpoint the harness talks to.
  • transport: stdio is explicit: the harness never runs uvx itself; it reaches hatago.
  • skills/time-helper is a standalone skill dir shipped by this recipe; it lands at .claude/skills/time-helper in 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) present

Worked example 2: the ping recipe (a service reference, no command)

catalog/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 resolves service: 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: http because 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.

Worked example 3: a Dockerfile recipe (run the project's own installer)

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.

recipe.yaml

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 standalone skills: / 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.

Dockerfile

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 \
    && ./setup

This is the core pattern trimmed for clarity. The real catalog/recipes/gstack/Dockerfile also hands ownership of the root-created ~/.bun cache back to harnessed before ./setup and sets a gstack config flag — both gstack-specific. The general lesson: run installers that write into ~ as harnessed, and fix up ownership of any caches an earlier USER root step created.

Rules for recipe Dockerfiles:

  • No FROM, no ARG HARNESS. The assembler prepends FROM harnessed-${HARNESS}:latest and re-declares ARG HARNESS after it, so ${HARNESS} is already available in your body. Adding your own FROM or ARG HARNESS produces a malformed concatenated Dockerfile.
  • USER root for system installs, then USER harnessed. apt and playwright install-deps need root; drop back to the unprivileged user before the body ends.
  • Pin every download. Explicit floating refs — @latest, --branch main/master/HEAD, a bare :latest tag — are rejected by the assembler's pin validation (PinValidationError) before any layer is built. Pin to a tag or commit SHA for reproducibility.

The principle: replicate the upstream installer

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 ./setup does). If one needs to know the target, pass it the ${HARNESS} build arg.

Build-and-test lifecycle

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 present

Worked example 4: a remote url MCP server (+ a local-overlay stack)

catalog/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: time is a stdio child hatago bakes + spawns; ping is a local sidecar resolved from service:; openbrain-example is a remote URL hatago proxies directly. _hatago_entry emits {url, type: http, headers?} for the network-native shapes.

Networking: localhost is the pod, not the host

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

Auth and secrets

_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_env is 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.

The local-overlay workflow (a stack that lives outside this repo)

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) connected

The committed claude_openbrain-example stack 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.

Transports

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

Supply-chain rules

Two hard rules, both enforced by the build (design §7):

  1. pnpm everywhere (no npm/npx). Every JavaScript install — global, per-recipe, hatago's bundled servers — uses pnpm; pnpm dlx replaces npx. A managed supply-chain config applies minimumReleaseAge cooldowns and lifecycle-script default-deny. Recipe validation (part of harnessed build, BLD-03) flags any raw npm/npx in a recipe's scripts/deps and points at the pnpm equivalent — the build fails fast until you fix it.
  2. uvx for Python MCP servers. Light Python servers (like mcp-server-time) run via uvx, the uv runner. Python dependencies declare deps.python (pyproject.tomluv venv + uv pip install -e ., or requirements.txtuv 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.

Verifying a new recipe — a passing pytest is not enough

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 present

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

Adding a recipe to a stack

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.

See also

Clone this wiki locally