Skip to content

harnessed design

Mike Crowe edited this page Aug 15, 2026 · 4 revisions

harnessed — design rationale

Why harnessed is built the way it is. ARCHITECTURE.md is the authoritative description of what lives where and what the words mean; BACKENDS.md covers where a composed stack runs. This document covers why, and does not restate either.

1. The problem

A single shared host config namespace cannot hold every experiment at once. Put a curated set of skills, plugins and MCP servers into ~/.claude and the tools collide: two memory systems both want to be the memory system, per-runtime settingSources drift apart, and vendored dependencies accumulate in $HOME. Every new experiment raises the odds of breaking a setup that worked yesterday, and nothing tells you which of the thirty installed things caused it.

The fix is to stop merging into one namespace. Compose per stack instead, so each combination is assembled in isolation and the collision disappears by construction. You pick what goes in; the result is a clean room containing exactly that and nothing inherited.

2. Composition is the product

harnessed's durable value is the composition layer: recipes compose into stacks, and a stack is a reusable agent system. Where that composed stack runs — host-native, container, and later sandboxed-host or devcontainer — is a pluggable execution backend, not the product.

harnessed = one composition model targeting N execution backends.

Isolation is therefore a backend capability, not an intrinsic property. This separation is the differentiator: comparable tools hard-wire a single container backend, so their composition model and their isolation model cannot move independently. Ours can. BACKENDS.md carries the contract.

3. Compose at runtime, not with FROM

FROM gives linear inheritance. Multiple FROMs make a multi-stage build whose last stage is the image, with COPY --from= pulling artifacts forward. There is no "union these two images" operator, and there never will be — so two sibling systems cannot be combined by inheritance.

Systems therefore combine at runtime: the agent container plus any service sidecars in one pod. Build time is reserved for what genuinely is linear lineage — a shared base image, per-agent images on top of it, and one standalone image per heavy service.

The corollary is that a recipe contributes a Dockerfile body, never a FROM line. The assembler concatenates those bodies under a header it emits itself. A recipe that declared its own base would be asserting lineage it cannot know, since it does not know which agent it will be composed onto.

4. Recipes are harness-independent

A recipe declares a capability bundle. It does not declare which harnesses it supports, and carries no harnesses: field. Any harness-specific step branches on the ${HARNESS} build arg inside the recipe's own Dockerfile.

The reason is combinatorial. A compatibility list is a claim that must be maintained against every harness, and it goes stale silently — the recipe still builds, it just refuses a combination that would have worked, or permits one that no longer does. Branching inside the Dockerfile puts the harness-specific step next to the thing it configures, where an author editing that step can see it.

The same logic makes a stack harness-free. The harness is a run-time argument, so one stack runs on any harness and materializes into per-harness images and profiles. A stack that named its harness would double the number of stacks anyone has to author.

5. One MCP hub per stack

All of a stack's MCP servers sit behind hatago, a single hub the harness reaches at one endpoint. The harness's own MCP config therefore has exactly one entry regardless of how many servers the stack composes.

Hatago runs in-process inside the agent container rather than as its own container. A separate hub container buys isolation that nothing needs — the hub and the agent share a lifecycle, a stack, and a trust boundary — while costing an extra image, an extra start, and a network hop on every tool call.

Light stdio servers run as hatago's children; heavy stateful systems run as their own service sidecars and are proxied. The split is about lifecycle, not weight: a thing that owns a database and outlives any single launch is a service, and a thing that starts in milliseconds and dies with the session is a child.

6. Claude format is canonical

Skills, commands, rules and hooks are authored once, in Claude Code's format. Every other harness adapts out of that one profile.

Picking a canonical format matters more than which format wins. With N harnesses and no canon, a recipe author writes N versions of every skill and keeps them in sync forever; the second version is where drift starts. Claude's format is the canon because it is the most expressive of the formats in play and the one the other harnesses can most readily consume — some read it natively, and the rest are wired through MCP against the same hub.

7. Credentials are referenced, never replicated

harnessed never copies, seeds, or snapshots a harness's credential store into a per-stack home.

The reason is structural rather than stylistic: a harness rewrites its own credential store on token refresh. Any copy rots the moment a token refreshes, and the next launch restores the stale one. The user experiences this as "it keeps asking me to log in", and the cause is invisible from where they are standing. "Copy it back if it is newer" does not fix it — that moves the race rather than removing it.

Two mechanisms are sanctioned:

  1. Reference the live store — a mount or symlink at the real path. Always current by construction.
  2. Reference a token or broker URL — an env token, or a broker URL plus token. No file at all.

A symlink is a reference only while the harness writes in place. Establish this per harness before designing, because it decides whether mechanism 1 is even available. A harness that rewrites a file by replacement silently converts the symlink into a regular file, and the refreshed credential never reaches the shared store. A harness that writes in place — SQLite is the worked example, resolving the link so its sidecar files land beside the target — keeps the link intact.

This SOP governs credentials only. Symlinking history, sessions, memory and usage state up to one shared location is deliberate design: a rolled-up view across every stack is the point, and those files are not subject to replace-on-refresh.

Where a harness stores auth and state together in one directory with no separately mountable credential file, the whole directory is referenced read-write. That is mechanism 1 at directory granularity — shared host state, deliberately not isolated. Do not "fix" it toward isolation by snapshotting the directory: a snapshot fragments auth, usage and sessions across every stack, resets usage on recreate, and still has to copy most of the directory to work at all.

8. Supply chain: pin everything, scan advisorily

Pin every source. A floating ref — @latest, --branch main, an unversioned package — is a validation error, refused before any build starts. A floating ref makes a build unreproducible and turns an upstream compromise into an automatic install.

Scan what actually landed, in-image. A host-side source scan sees nothing useful here: recipes vendor nothing, so every dependency arrives during the build. The scan therefore runs inside the image as its final layer, over the trees that have no lockfile a host could read.

The scan is advisory and always exits 0. This is the deliberate part. The recipe model runs arbitrary upstream installers, whose dependency trees essentially always carry open HIGH advisories at any moment — including ones in bundled tooling that no user can fix. A hard gate would make the recipe system unusable within a week, and an unusable gate gets disabled, which verifies nothing. Visibility is the contract; enforcement is not.

Scanner credentials follow the same rule as harness auth: referenced, never baked. A token reaches the build as a build secret, never a build-arg, so it cannot enter image history. Missing credentials degrade to a warn-skip rather than an interactive prompt — builds must stay non-interactive and reproducible.

9. pnpm everywhere

All JavaScript installs use pnpm; raw npm/npx is rejected by the lint. This is a supply-chain decision, not a taste one. The managed config enables a quarantine window on newly-published versions, so a compromised release is not installed the moment it lands; default-denied lifecycle scripts, so an unreviewed postinstall cannot run; and store-integrity verification over a content-addressed store.

10. State and lifecycle

Persistent by default, --fresh to wipe. Accumulation is the value of a memory system, so discarding it cannot be the default. --fresh exists for the clean-room comparison run.

Service volumes are service-scoped, not stack-scoped. A memory service attached to two different stacks is one memory, not two — which is the entire reason to run it as a shared service rather than bake it into each image. Scoping its volume per stack would silently fragment exactly the state the service exists to accumulate.

A shared service outlives any launch. It is owned by the service, started on demand if absent, and serves concurrent stacks. A service holding an exclusive lock over per-project data is the exception: it runs one container per project and is reached through a unix socket inside the data directory it already binds. A socket is a filesystem object, so it crosses containers through that bind mount with no port allocation and no network namespace to reconcile.

11. Secrets resolve on the host

Secret resolution runs host-side, and only the resolved values cross into the container.

This is forced by how 1Password authorizes its CLI: the desktop app binds the grant to the calling application — the user's terminal. A CLI running inside a throwaway container has no host app to bind to, so app-auth fails there no matter which socket is mounted. Mounting the SSH agent socket does not change this; that socket signs commits, and is not the app-auth transport.

Secrets are injected as env only — never written into the repo, a profile, or an image layer. The whole mechanism is opt-in: with no schema present, nothing is invoked and nothing is lost.

12. The capability test is the oracle

The public interface is the running stack, and the behavior worth asserting is "the stack exposes exactly the capabilities its manifest declares". So the manifest is the test oracle: the expected set is derived from the stack and its recipes, and the test diffs it against a live instance.

Each capability is probed where it actually lands — skills, commands and plugins in their directories, MCP servers through the hub. The primary probe is hatago's own machine-readable resource listing the connected servers: it is deterministic, and auth-free, so a green report needs no harness credentials. Harness-specific commands and an LLM probe exist only as backstops, never as the authority.

Assembler internals are deliberately not unit-tested against their own shape. Wire the wrong thing and the capability test fails — that is the coverage, and it survives refactors that a test coupled to assembler internals would not.

13. A host CLI driving podman directly

harnessed is a host Python CLI installed with pipx or uvx. It invokes podman directly as a subprocess. There is no tool container, no API socket, and no daemon-in-a-daemon.

Separating "generate the build and run inputs" from "execute the build and run" removes every cost of driving a container engine from inside a container:

  • No socket to mount and no CONTAINER_HOST to set. podman is invoked on the host.
  • No host-absolute-path footgun. The launcher runs on the host, so project and $HOME paths are host-native by construction — the classic bind-path mismatch cannot occur.
  • A native TTY for free. The launcher execs the harness, handing over the terminal with no tunneling.

The catalog ships inside the wheel, so an installed harnessed carries its own recipes, agents, services and stacks and needs no repository on disk. Two constraints follow directly and are non-negotiable: nothing host-local may live in the catalog, since it is a published artifact; and nothing may key off the current working directory, since an installed CLI runs from anywhere.

Clone this wiki locally