Skip to content

recipe authoring

mcrowe edited this page Jul 13, 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:                           # Model A — no host-side marker
  run: <shell-command>          # required — sourced inline in the attach shell on EVERY launch;
                                # must self-gate (e.g. `bd list >/dev/null 2>&1 || bd init`)

# --- Egress + tools (optional) — see egress.md ---
egress:                         # extra outbound hosts this recipe needs; appended to the container
  - api.pulumi.com              # egress firewall ONLY when this recipe is in the stack. Bare
                                # hostnames only (no scheme/path/port).
tools:                          # pinned mise tools installed as a `mise use -g` layer (no Dockerfile
  - pulumi@3.140.0              # needed). MUST be pinned — `@latest`/bare name is rejected.

# --- Manual-setup note (optional) ---
setup:                          # a plain-English "do this once" note, harness-agnostic
  summary: <one-line summary>   # required — rendered into the ## Setup section of each identity file
  reference: <url>              # required — upstream setup docs
  condition: <shell-command>    # OPTIONAL — exits 0 while the step is STILL needed (e.g. `! bd list`);
                                # when set, the assembler synthesizes a self-gating Claude
                                # SessionStart hook and suppresses the static Claude bullet

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.
  • egress opens the container's default-DROP egress firewall for the listed hosts, and tools installs pinned CLIs with no Dockerfile — together they let a recipe expose a cloud service (e.g. Pulumi) as pure YAML. Full guide: egress & exposing services.
  • 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 (distinct from init:, which the launcher sources in the attach shell before the harness starts — see below). 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 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.

Model A — no host-side marker. The init.run command is sourced inline in the attach shell (the same process that then execs the harness), before the agent starts, on every attach (re-attach, a second terminal). Any env it exports (e.g. beads' BEADS_DIR) flows straight into the agent — no profile.d, no transient podman run --rm, no harnessed init sub-command, no marker files. Because it runs every time, run must self-gate cheaply and be idempotent:

init:
  run: bd list >/dev/null 2>&1 || bd init --quiet --stealth   # self-gating: no-op once initialized

Contract env. The attach shell exports these before any init.run runs:

Variable Value
PROJECT_DIR The resolved launch path
MAIN_REPO_DIR git-common-dir (the bare repo dir in a bare + linked-worktree layout)
CONTAINER_WORKSPACE_DIR / HOST_WORKSPACE_DIR The workspace mount (path-mirrored, so both are the same)
HOST_HOME The host's $HOME — the pod's is /home/harnessed. Needed to reach a scope: global persist dir, which is mounted path-preserving (e.g. export PULUMI_HOME="$HOST_HOME/.pulumi").

Socket-backed project-scoped services additionally export their container-side socket path.

No exit: because run is sourced, a bash exit would kill the attach shell before the harness starts — the assembler rejects an init.run containing exit at parse time.

Hard failure: a non-zero exit from run aborts the attach with a clear message. An agent working against a half-initialized tool is worse than an explicit failure.

Manual-setup note (setup:)

When a first-time step is too footprint-heavy to auto-run on every attach (e.g. bd setup writes .claude/settings.json + CLAUDE.md into the project), declare a harness-agnostic setup: note instead of init:. It renders one plain-English bullet into the ## Setup section that every harness's identity/rules file already carries (CLAUDE.md, .codex/AGENTS.md, opencode persona, omp APPEND_SYSTEM.md, GEMINI.md) — a note to the user, not a command harnessed runs.

setup:
  summary: Run `bd init --server ...` then `bd setup <harness> --project`, then restart the agent.
  reference: https://github.com/gastownhall/beads
  condition: '! bd list >/dev/null 2>&1'   # OPTIONAL

condition (optional): a shell command already present in the built image that exits 0 while the step is still needed. When set, the assembler synthesizes a self-gating Claude SessionStart hook from it (prints the summary until the condition stops firing) and suppresses Claude's static ## Setup bullet for this recipe — the hook is strictly better there (silent once configured). Other harnesses have no live per-session check, so they keep the static bullet unconditionally.

Worked example

catalog/recipes/beads-team/recipe.yaml is the reference. beads' first-time setup writes into the project and can't be made reliably idempotent/footprint-free across every git layout, so it uses setup: (self-gated on Claude by setup.condition), not init::

setup:
  summary: >-
    Run `bd init --server --quiet --non-interactive --role maintainer && bd config set
    dolt.auto-commit on` then `bd setup <harness> --project` once per workspace, then restart
    the agent.
  reference: https://github.com/gastownhall/beads
  condition: '! bd list >/dev/null 2>&1'

persist:
  - name: .beads
    scope: workspace
    location: in_repo
    vcs: tracked

WHY setup: 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). setup: gives the author a deterministic, harness-independent note (self-gating on Claude), and init: gives a deterministic in-shell hook for the lighter-weight, safely-idempotent cases.

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