Skip to content

recipe authoring

Mike Crowe edited this page Jul 2, 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>

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, hooks, extensions) are accepted but only exercised where relevant; see catalog/recipes/omp/recipe.yaml for extensions.

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.

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 by a build secret) 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.

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