-
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>Notes:
-
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,hooks,extensions) are accepted but only exercised where relevant; seecatalog/recipes/omp/recipe.yamlforextensions.
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.
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 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.
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)