Skip to content
Mike Crowe edited this page Jul 31, 2026 · 6 revisions

Composing stacks

A stack is a manifest (catalog/stacks/<name>/stack.yaml) that composes a chosen set of recipes (and optional shared services). It is harness-free: the harness is a required positional chosen at build/run time (harnessed <stack> <harness>), so the same stack runs on claude, omp, or any other harness, materialized into per-harness images and pods. A running stack is a podman pod — harness container + hatago + any declared services — composed at runtime, not baked at build time (FROM can't union sibling systems; design §3, §6).

For the why (the runtime-pod model, and why the harness is not a stack property), read docs/harnessed-design.md §2 & §12. This guide shows the how with worked examples from this repo's catalog/stacks/.

What a stack is

catalog/stacks/<name>/stack.yaml        # the manifest (you author or scaffold)
  ↓ harnessed build <stack>     # assemble (emit-only) + scan + host podman build
$XDG_DATA_HOME/harnessed/profiles/<name>/   # GENERATED (never committed); mounted into the harness container
  .claude/{skills,commands,...} # the assembled, version-controlled profile
  hatago.config.json

Recipes are resolved ahead of time into a committed profile plus pinned images; the host runs podman build, and nothing is assembled at container start (design §15).

The stack.yaml schema

The typed model lives in src/harnessed/schema.py (Stack). Key fields:

name: <stack>                     # required
harnesses: [claude]               # optional — harnesses to build when no explicit harness is given
recipes: [a, b, c]                # list of catalog/recipes/ to compose
services: [ping]                  # optional — shared sidecars to attach (auto-started on launch)
permissions: yolo                 # optional — prompt | auto (default) | yolo (writes skip-permission config)
instructions: |                   # optional — identity text emitted to .claude/CLAUDE.md at assemble time
  You are a <role> agent.
state:                            # optional
  persist: true                   # default; `--fresh` overrides at runtime
  session_state: host             # host (default — sessions persist, inspectable) | volume
extends: <stack>                  # optional — inherit from a named base stack; see the extending-stacks guide

Notes:

  • The harness is a run-time argument, not a stack property: harnessed <stack> <harness>. The optional harnesses: list is a build-time convenience — harnessed build <stack> with no explicit harness builds every harness listed there; harnessed build (no stack) includes these pairs in its reconciliation sweep. Note: harness: (singular) is rejected by the schema validator with a suggestion to use harnesses:.
  • instructions: is the stack's identity text, emitted into the assembled profile at build time. For the claude harness it lands in .claude/CLAUDE.md (Claude Code's project instructions file); for omp it is emitted as an identity block in APPEND_SYSTEM.md; for codex it becomes ~/.codex/AGENTS.md. A stack without instructions: leaves the harness's built-in defaults untouched. instructions: is a single text block, not concatenated with recipe rules: files (which fan into .claude/rules/ separately).
  • extends: names a base stack to inherit from. Union-merged fields: recipes, services, harnesses, ssh_keys (parent's entries first, then the child's, de-duped). Everything else is a child-wins override or inherited when omitted. See the extending-stacks guide for full merge semantics.
  • claude mounts the profile natively; omp consumes the same Claude-canonical profile via claude-hooks-bridge — no re-authoring needed for either harness.
  • session_state: volume isolates a stack's history instead of sharing it with the host — usually not what you want. The governing design principle across harnessed is to containerize the configuration (skills, MCP servers, rules), not the storage: your conversations, usage, and stats should persist to the host and stay coherent regardless of which stack launched a session, so switching between different tool configurations for the same project never fragments your history. session_state: host (the default) is what delivers that. volume does the opposite — it's a genuine, sometimes-useful escape hatch for a deliberately throwaway, non-continuous stack, but it's easy to pick up by accident (e.g. copied from an example) and silently split your history per-stack instead. Leave it on host unless you specifically want isolation.
  • Only the fields you exercise are required; the assembler parses the rest forward.

Worked example 1: gsd-core_repowise (harness-agnostic, two recipes)

catalog/stacks/gsd-core_repowise/stack.yaml is the smallest general-purpose shipped example — two recipes, no services, no harness pin:

name: gsd-core_repowise
recipes: [repowise, gsd-core]
services: []

The full lifecycle (harness chosen at run time):

harnessed build gsd-core_repowise claude  # assemble → scan → build hatago → image scan
harnessed gsd-core_repowise claude        # launch the pod (harness + hatago), attach
harnessed test gsd-core_repowise claude   # capability report for this harness

harnessed build emits the $XDG_DATA_HOME/harnessed/profiles/gsd-core_repowise/claude/ tree (assembled from catalog/recipes/repowise and catalog/recipes/gsd-core) and builds the hatago image. harnessed gsd-core_repowise claude composes the pod and attaches; harnessed test brings the instance up --fresh headless and asserts the manifest's declared capabilities are live (design §18). Running an unbuilt stack errors and tells you to harnessed build first.

Worked example 2: ping_time (a stack with a shared service)

An illustrative ping_time stack (scaffold it with harnessed new, below) composes a stdio recipe (time) with a service-ref recipe (ping) and attaches a shared sidecar:

name: ping_time
recipes: [time, ping]
services: [ping]

The manifest names no harness — harnessed ping_time claude and harnessed ping_time omp run this same stack. A stack whose name collides with a harness name is rejected outright, which is why this example is ping_time and not claude_ping_time.

  • recipes: [time, ping] — the assembler composes two recipes into one profile: the time stdio server (hatago child) and the ping network-native server (hatago URL-proxy). The capability test asserts both.
  • services: [ping] — the launcher auto-starts the ping sidecar on launch (ensure_service_up) if it isn't already running. The service is a standalone container (own image + volume), not a pod member; its lifecycle is independent of any instance.

Authoring the sidecar itself is covered in the service-authoring guide.

catalog/stacks/gsd-core_repowise composes two recipes (repowise, gsd-core) with no harness pin — harnessed gsd-core_repowise claude and harnessed gsd-core_repowise omp both build from the same manifest, proving that one canonical profile runs on either harness.

Scaffolding a new stack

harnessed new writes a manifest for you, refusing to overwrite an existing stack:

harnessed new my-stack --recipes time,greet
# → writes catalog/stacks/my-stack/stack.yaml:
#   name: my-stack
#   recipes:
#     - time
#     - greet
#   services: []

There is no --harness flag: the harness is a run-time positional, not a stack property. What the command does reject is a stack named after a harness (harnessed new claude fails), since that name would be ambiguous on the command line.

--recipes is optional; omitting it scaffolds an empty recipes: [] you fill in by hand.

Build + run lifecycle

Step Command Notes
Build harnessed build <stack> Assemble (emit-only) + scoped source scan + host hatago build + image scan. Fails on HIGH.
Run harnessed <stack> [path] Compose the pod (harness + hatago), attach. Auto-builds missing images.
Clean-room run harnessed <stack> --fresh Tear down any existing pod/instance first; reseed state from the profile.
Capability test harnessed test <stack> Launch --fresh headless + assert declared capabilities (markdown report).
List harnessed list Authored stacks + running instances.
Stop / remove harnessed stop | rm <stack> Stop or remove every instance of a stack (across projects).
Install harnessed install <stack> Write a ~/.local/bin/<stack> launcher shim (launch by name from any cwd).

State persists by default: an instance writes projects/ + history.jsonl to a harnessed-owned host dir with a legible project slug (STA-02). --fresh is the throwaway path. See the troubleshooting guide for the state-dir layout and --fresh semantics.

See also

Clone this wiki locally