crab-ganglion-harness
The ganglion is the agent runtime this project wrote for itself, and the harness this book teaches. This page describes it as a component: what runs inside an agent container, what that program is allowed to do, and how the repository is organised.
What it is
A harness is the program that runs inside a member’s agent container. It holds
the conversation, calls the model, runs whatever tools the model asks for, and
writes the transcript to disk. crab-shell-proxy starts one such container per
member and speaks to it; what happens inside is the harness’s business.
crab-ganglion-harness is that program, written in Go. Its go.mod declares
the module, a Go version, and nothing else: the harness has no third-party
dependencies at all, and the whole thing compiles to one static binary. It
serves HTTP with Server-Sent Events natively, so the proxy’s runner for it needs
no protocol translation — internal/ganglion/turn.go in the proxy notes that
this is why it has no analogue of the 500-millisecond guess that picoclaw’s
runner needs to tell when a turn has ended.
The other harness is picoclaw, a third-party project this stack started on. It is still fully supported and it is on its way out: an agent that declares no
harness:key now gets this one, not that one. Harnesses covers the comparison; writeharness: "ganglion"explicitly in every agent you create rather than leaning on the default.
What it is responsible for
Serving one turn per request. The HTTP surface is two routes and no more:
POST /v1/chat/completions runs a turn, and GET /health answers {"status":"ok"}
for the proxy’s health check. The turn ends when the handler returns.
Streaming the work while it happens. As the agent uses its tools, narration and reasoning arrive on a progress channel. The answer is different: a frame is buffered and classified once it is complete, then sent in one piece. The reason is written down in the specification, because it looks like a regression if you do not know it. Whether an iteration’s text is narration or the final answer depends on how the frame ends, and that is not knowable before the stream finishes — the proxy measured seven turns out of a hundred and twelve delivering a whole reply in the same frame that carried a trailing tool call. Emitting optimistically and correcting afterwards would make the reply visibly rewrite itself.
Bounding a turn. The only limit on a single turn is how many times it may go
back for another tool. A turn that reaches the cap stops and says so rather than
failing silently. Two places set it and the first wins:
agents.defaults.max_tool_iterations in the mounted config.json, which is the
surface an administrator actually has, and GANGLION_MAX_ITERATIONS on the
container, which defaults to 12. The harness prints the number in force and
where it came from at boot, because 12 looks identical whether an operator set
it, the file set it, or nothing did.
Running tools, confined. The tool that matters is shell. Before running a
command, the binary re-executes itself in a sandbox mode, applies a Landlock
domain, and only then executes the command. The workspace is read-write; /usr,
/bin and /sbin are read-execute; everything else, /proc included, is
denied — which is what stops a command from reading /proc/1/environ and with
it the harness’s own provider key. If the kernel cannot provide a Landlock
domain, the harness refuses to boot rather than serving turns unconfined.
Other tools are registered only when the configuration supports them:
load_image and set_reasoning_depth are always present, web_search and
web_fetch appear when a search provider is configured, generate_image when
an image model is, and subagents and research when sub-turns are enabled.
Anything an operator mounted over MCP is added on top. Each boot log line says
which of these were switched on.
Persisting the conversation. The transcript is append-only JSONL under
workspace/sessions, and the context window is a separate derived artifact. A
conversation that has a transcript and no window — the state every conversation
migrated from picoclaw arrives in — has its window rebuilt from the transcript,
so the member does not see their history on screen while the agent answers as
though the conversation had just begun.
Keeping the layout the stack expects. Everything lives under the workspace
segment, because that is where the proxy looks from outside the container. A
project’s files go in workspace-<id>, a sibling of workspace/, never a child
of it; the harness migrates a subtree left at the old path on boot. See
Agents, workspaces and projects.
What it is not responsible for
It does not start, stop or provision anything, including itself. Container lifecycle, volumes and their ownership, secrets materialization, mycelium identity and authorization, the model registry, the admin API, projects and scheduled tasks all stay in crab-shell-proxy. The harness specification lists this as permanently out of scope.
It does not decide who may approve a gated action. The harness has the
Approver port and the mid-turn suspension behind it, and its first adapter
calls back to the proxy, which owns member identity — the harness only asks. The
other half of that round trip is not built: there is no approval endpoint on the
proxy, and with GANGLION_APPROVAL_ENDPOINT unset the loop installs an
allow-all approver. Treat the approval flow as a seam that exists rather than a
feature you can switch on.
It has no .secrets/ directory. Credentials reach this harness as
environment variables. A value that must not sit in plaintext can be encrypted
first — crab-ganglion encrypt reads the plaintext on stdin, never as an
argument, and prints the enc:// value to paste into the deployment’s
environment.
How it is built and tested
This is the only one of the four repositories with a pull-request gate that runs
the test suite. .github/workflows/ci.yml runs, on every pull request and every
push to main:
gofmt -l . | tee /dev/stderr | (! read)
go vet ./...
go test -race ./...
go build ./...
-race is deliberate rather than habitual: the SSE writer has two writers by
construction, the turn and the heartbeat, and interleaved output would corrupt a
frame the client is in the middle of parsing.
The same tests run a second time inside the Dockerfile, before the binary is
linked, so the image cannot be built without them passing. release.yml builds
with the layer cache switched off for exactly that reason — replaying the tests
from a cache would make a green run stop being evidence that they ran for these
bytes.
The runtime image is Alpine rather than distroless, and the Dockerfile explains
that this was found by running it: distroless has no /bin/sh, so the shell
tool could never succeed. The container runs as uid 1000, which matches what the
proxy chowns each member’s volume to, and listens on port 18800.
Every published image is addressed by the commit that produced it. There is no
:latestand no:main;release.ymlpublishesghcr.io/lepistabioinformatics/crab-ganglion:sha-<commit>and nothing that is ever rebuilt. That is this stack’s own scar: the proxy’sEnsureImagereturns early when a name resolves locally, and the harness image is not a compose service, so a rebuilt tag can leave a host running old bytes indefinitely with nothing in the logs. It happened once, for three weeks.
The consequence for a deployment is that the image reference is a decision
somebody makes. docker-compose.prod.yaml sets no CRAB_GANGLION_IMAGE and
supplies no default, so a production deployment has to name a digest or a
sha- tag explicitly before a ganglion agent can start. The development compose
file takes the other route: a build-only ganglion-image service builds the
harness from the submodule and tags it zombie-crab/crab-ganglion:dev, and the
proxy waits for it. Because that build runs the harness’s own test suite, a
failing test stops the development stack from coming up. See
Deployment.
How the code is laid out
The repository is hexagonal, and the shape is enforced by tests rather than by convention.
cmd/crab-ganglion/main.go the composition root: the only place adapters meet
internal/domain/ entities, the ports, pure policy — standard library only
internal/runtime/ the turn loop, compaction, sub-agents
internal/config/ environment and the mounted config.json
internal/adapter/ one directory per adapter
internal/secret/ the enc:// resolver
internal/skillfile/ reading skill files
internal/domain/arch_test.go holds two rules. The first fails if the domain
package imports anything outside the standard library, because the moment the
domain imports an HTTP client or a provider SDK the loop stops being testable
without them. The second fails if one adapter imports another, which is what keeps a second provider or a second ingress a new file
rather than a refactor.
The adapters are grouped by what they drive: httpsse is the way in;
provider/openai and provider/router are the way out to a model;
store/jsonl and store/window are the transcript and the derived context;
tool/* is one directory per tool; approver/proxy, telemetry/otlp,
mcp, skills and evolution are the rest. Adding a tool is a file plus a
registration line in main.go — not a change to the loop, the port or the
registry.
The specification for all of this lives in the product repository, at
.specs/features/crab-ganglion-harness/, along with the design notes and the
requirement identifiers that the code comments cite.
Where to go next
Harnesses compares the two runtimes and explains what the proxy does when a feature is not available on one of them. Configuration covers the environment this container reads. Working on the stack has the commands.