Swarmforge: The foundation for forging robust systems and dependable tools
Swarmforge is a builder-focused environment for designing, refining, and reusing processes and the tools they produce. It emphasizes robustness, constraint-driven design, and interoperability over ad-hoc interaction or one-off execution.
- Add the shell helper alias:
bash ./install.shThis appends an oc alias to your shell rc file that runs make run_opencode PROJECT_DIR=$(pwd) against the repo's Makefile.
Run it with bash (it uses Bash arrays) even if your login shell is Zsh.
On macOS it prefers ~/.zshrc and falls back to ~/.bash_profile, so you don't need to create ~/.bashrc manually.
Override the target file with OC_RC_FILE=/path/to/rc bash ./install.sh.
- Build one or both container images:
make build_opencode
make build_claude
To pin OpenCode to a specific release instead of latest:
make build_opencode OPENCODE_VERSION=1.4.14
make update_opencode OPENCODE_VERSION=1.4.14Both images share the same Debian base and toolchain (Node.js + Python; see anvil/Dockerfile).
Build targets pass AGENT=opencode|claude so only the requested agent install step runs.
- Run from your project directory:
- OpenCode:
oc - Claude Code:
make run_claude PROJECT_DIR=$(pwd) - Pass OpenCode overrides as arguments (
oc PROFILE=work DATA_DIR=...) or env vars (PROFILE=work oc). - Override the container timezone per run (affects git commit timestamps):
oc TIMEZONE=America/New_York.
make run_opencode and make run_claude load a repo-local env file from .swarmforge/env if it exists; override with ENV_FILE=/path/to/env.
Both also accept TIMEZONE=<Region/City> (default Etc/UTC), passed into the container as TZ.
Define multiple aliases that point at the same Swarmforge checkout but use different storage roots and git identities (for example: work keys vs personal keys):
alias ocd='make -C PATH_TO_SWARMFORGE run_opencode PROJECT_DIR=$(pwd) DATA_DIR=$HOME/.local/share/opencode-work GITCONFIG_FILE=$HOME/.gitconfig-agent'
alias ccd='make -C PATH_TO_SWARMFORGE run_claude PROJECT_DIR=$(pwd) CLAUDE_DATA_DIR=$HOME/.local/share/claude-work GITCONFIG_FILE=$HOME/.gitconfig-agent'GITCONFIG_FILEpoints at an agent-specific git config instead of~/.gitconfig.- For Claude Code, use separate
CLAUDE_DATA_DIRroots to isolate work/personal logins and session state.CLAUDE_HOME_DIRdefaults to$(CLAUDE_DATA_DIR)/home. - Config layering uses
SWARMFORGE_USER_CONFIG_DIR,SWARMFORGE_ORG_CONFIG_DIR, andSWARMFORGE_REPO_CONFIG_DIR(their defaults differ per harness — see OpenCode layering under Skills and Claude config layering). SetSWARMFORGE_ORG_CONFIG_ROOT=/path/to/org-repoto resolve org defaults to.opencode(OpenCode) and.claude(Claude) under that root.
SWARMFORGE_REPO_CONFIG_DIR refers to the Swarmforge checkout (the harness repo), not the working project mounted at /workspace.
By default it is $(SWARMFORGE_DIR)/opencode for run_opencode and $(SWARMFORGE_DIR)/claude (if present) for run_claude.
Project-local config in the working repo (for example .opencode/) is still handled by the agent tools themselves.
make run_opencode and make run_claude auto-detect the git root from PROJECT_DIR and mount it at /workspace.
For a linked git worktree they also mount the shared git common directory so git operations keep working inside the container.
This means oc works from repo roots, subdirectories, and linked worktrees without extra flags.
.git/config and .git/hooks are mounted read-only wherever the git dir is visible in the container.
Both execute on the host — hooks run on your next commit or checkout, and config carries core.hooksPath, core.pager, core.sshCommand and aliases — so the agent gets no write access to them.
swarmforge/gitguard.py builds those mounts, covering every git dir reachable from the workspace: the repo's own, a linked worktree's shared common dir, and the git dirs of initialized submodules — each with their own submodules and worktrees, including a submodule initialized inside a worktree, whose git dir git keeps under that worktree rather than the repository.
remotes/ and branches/, the pre-config way to define a remote, are read-only for the same reason as config.
It also guards the pointers that say where config and hooks live (commondir, a .git that is a gitdir: file, and config.worktree where extensions.worktreeConfig is on), and binds every directory on the way down onto itself, since a plain directory containing a read-only mount can still be renamed aside and recreated writable.
A guarded path that is absent is created on the host first so there is no gap to slip through — a repo with no config works fine, which makes its absence room to write one rather than a sign there is nothing to guard.
The placeholders are inert, though a repo that gains a commondir starts answering git rev-parse --git-common-dir with an absolute path instead of .git.
Only git dirs that exist when the session starts are covered — a repo the agent clones or git inits inside the workspace, or an unrelated checkout vendored there, is not.
A .git written into an existing subdirectory is worth knowing about specifically: it shadows the guarded repo for anything run from inside that directory, git status at the root neither reports it nor executes it, and a git-aware shell prompt or editor entering the directory is enough to run what its config says. safe.directory, git's gate for this, keys on ownership, and the container runs as your own uid.
The rest of the git dir stays writable, so committing, branching, fetching, and git worktree add work as usual.
Commands that write config do not, by design: git config --local, git remote add, git submodule update --init, and git sparse-checkout fail with could not write config file ...: Device or resource busy, and hook installers like pre-commit install or husky fail on the read-only .git/hooks.
Branch tracking is the sharp edge: git push -u and git switch <remote-branch> exit 0 and still report "set up to track", but the tracking config is silently not recorded — git treats that write failing as non-fatal. Use git push origin HEAD:<branch> and git switch -c <name> --no-track origin/<branch>, and set a repo up on the host when it needs to stick.
This narrows the git-specific surface; it does not make the workspace a trust boundary. Hooks that config already points outside the git dir (core.hooksPath = .githooks, husky) and attribute-driven filter commands live in the workspace, as do package.json scripts and Makefiles — anything you run on the host from a directory an agent could write is still yours to trust.
Run LLMs locally. make run_ollama starts an Ollama container on the shared network (make stop_ollama / make clean to tear down).
The run_* model targets (for example make run_gpt-oss-20b) exec into it to pull and run a model; make gpu_stat wraps nvidia-smi.
A coding-agent harness that exposes a standard set of code-editing tools to the LLM.
make run_claude starts a Claude Code container with the same workspace and git-worktree mounting as make run_opencode.
Claude state persists by mounting $(CLAUDE_HOME_DIR) to /home/opencode, keeping account/session files like ~/.claude/ and ~/.claude.json.
The repo is mounted at a stable path derived from the git remote slug (with /workspace still mounted for compatibility), which groups sessions consistently across worktrees without host-specific absolute paths.
- To reuse existing host-native Claude sessions directly, run with
CLAUDE_HOME_DIR=$HOME. - Remote slugs map deterministically, e.g.
git@github.com:crypticswarm/Swarmforge.git->/repos/crypticswarm/Swarmforge. Override withCLAUDE_REPO_SLUG=crypticswarm/SwarmforgeandCLAUDE_REMOTE_NAME=<remote>.
Both harnesses mount this repo's skills/ and commands/ into the container, exported as SWARMFORGE_SKILLS_DIR and SWARMFORGE_COMMAND_DIR.
The entrypoint copies them into each harness's native location — ~/.claude/skills/ and ~/.claude/commands/ for Claude, the merged config dir (~/.config/opencode/skills/, ~/.config/opencode/command/) for OpenCode.
For Claude these dirs (plus ~/.claude/agents/) are container-private tmpfs mounts that mask the persistent home and are repopulated each run, so per-repo assets never accumulate in CLAUDE_HOME_DIR or leak into other repos' sessions.
Skills, commands, and agents come from four layers, lowest to highest precedence — later layers override same-named entries wholesale (never file-merged):
- user —
~/.agents/{skills,commands}and~/.swarmforge/agents/ - org —
$(SWARMFORGE_ORG_CONFIG_ROOT)/.agents/{skills,commands}and.../.swarmforge/agents/ - repo — this checkout's
skills/,commands/, andagents/ - workspace —
<workspace>/.agents/{skills,commands}and<workspace>/.swarmforge/agents/
Skills and commands follow the harness-neutral .agents/{skills,commands} convention and are copied as-is; agents use the unified format (see Agents) and are translated per harness.
Harness-native dirs (<layer>/.opencode/skills/, <layer>/.claude/skills/) are not consumed for skills/commands.
Override the .agents roots with SWARMFORGE_USER_DOTAGENTS_DIR / SWARMFORGE_ORG_DOTAGENTS_DIR.
Three sources merge into ~/.claude at startup (lowest to highest precedence):
SWARMFORGE_USER_CONFIG_DIR(default~/.claude)SWARMFORGE_ORG_CONFIG_DIR(optional; defaults to$(SWARMFORGE_ORG_CONFIG_ROOT)/.claudewhen that root is set)SWARMFORGE_REPO_CONFIG_DIR(defaultclaude/, if present)
Skills, commands, and agents/ are excluded from this merge — they travel through the asset pipeline above.
make build_claude bakes anvil/statusline.sh into the image at /usr/local/bin/swarmforge-statusline and the entrypoint turns it on, so a container shows the model, directory, turn count, context percentage, and session token/cost totals with no host setup. It reads the session JSON on stdin and the transcript.
Claude has no settings layer below ~/.claude/settings.json, so the entrypoint seeds the default into that file (anvil/seed_claude_settings.py) after the config layers have merged, and only when no layer set statusLine. To use your own, set one in any config layer:
{
"statusLine": { "type": "command", "command": "~/.claude/my-statusline.sh" }
}Subagent definitions live under agents/ in a single unified format and are rewritten to each harness's native dialect by the container entrypoint (swarmforge/agents/translate.py).
A unified agent is a markdown file whose body is the system prompt and whose YAML frontmatter is a superset of the OpenCode agent schema. The filename is the agent's identity (reviewer.md -> agent reviewer):
---
description: Reviews code and suggests improvements.
mode: subagent
temperature: 0.1
model: anthropic/claude-sonnet-4-6
tools:
write: false
edit: false
bash: false
claude:
maxTurns: 12
---
You are the reviewer agent...Field handling per harness:
descriptionand the prompt body pass through everywhere.toolsuses OpenCode's lowercase tool ids mapped to booleans. For Claude Code, disabled tools becomedisallowedTools(write: false->disallowedTools: Write); ids with no Claude equivalent are dropped.modelaccepts a provider-qualified id (anthropic/claude-sonnet-4-6, passed through to OpenCode and stripped to the bare id for Claude — non-Anthropic providers dropped) or a Claude alias (sonnet,haiku, Claude-only and dropped for OpenCode).mode,temperature, and other OpenCode-only fields are dropped for Claude Code.claude:/opencode:blocks merge verbatim into that harness's output frontmatter.disable: truepasses through to OpenCode and skips the agent for Claude Code.
Unified agents live in harness-neutral .swarmforge/agents/ directories across the same four layers as shared assets (lowest to highest precedence):
- user —
~/.swarmforge/agents/(override the.swarmforgeroot withSWARMFORGE_USER_ASSETS_DIR) - org —
$(SWARMFORGE_ORG_CONFIG_ROOT)/.swarmforge/agents/(override withSWARMFORGE_ORG_ASSETS_DIR) - repo —
agents/in the checkout (override withSWARMFORGE_REPO_AGENTS_DIR, which points directly at an agents dir so the rest of the checkout is never mounted) - workspace —
<workspace>/.swarmforge/agents/
Layers mount read-only under /tmp/swarmforge-assets/{user,org} and /tmp/swarmforge-assets/repo/agents (the in-container SWARMFORGE_ASSETS_{USER,ORG,REPO}_DIR env vars point at the layer roots); the entrypoint translates the stacked sources into each harness's native location (~/.config/opencode/agents/ for OpenCode, the container-private ~/.claude/agents/ for Claude). Later layers override earlier ones by filename.
Claude-native repo-local definitions (for example <workspace>/.claude/agents/) are still discovered by Claude directly, outside this pipeline.
The translator is covered by the unit suite; run it with make test.
Slash commands live under commands/ (and optionally .opencode/command/ for repo-local commands).
Start your prompt with the command name to inject it (for example /commit injects commands/commit.md).
Command files can include ! shell-expansion blocks, for example:
!`git status --short`
The harness runs these and injects their output into the prompt context, so the agent sees live repo state without copy/pasting.
Skills live under skills/ (harness-neutral, shared by every harness).
OpenCode auto-discovers them using only the YAML frontmatter (name + description); the full SKILL.md body loads on demand when a skill is invoked, keeping the default context small.
make run_opencode merges config into /home/opencode/.config/opencode from three sources (lowest to highest precedence):
SWARMFORGE_USER_CONFIG_DIR(default~/.config/opencode)SWARMFORGE_ORG_CONFIG_DIR(optional; defaults to$(SWARMFORGE_ORG_CONFIG_ROOT)/.opencodewhen that root is set)SWARMFORGE_REPO_CONFIG_DIR(default repo-localopencode/)
opencode.json is merged by key (not file overwrite), so org-level MCP servers survive even when the repo layer also defines opencode.json.
Skills and commands are excluded from this merge and travel through the asset pipeline described under Claude Code.
You can also define MCP servers in a project-local .opencode/opencode.json — often the cleanest place to attach them to a specific repo:
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"org-server": {
"type": "remote",
"url": "https://mcp.example.com",
"enabled": true
}
}
}make run_opencode mounts your host ~/.gitconfig into the container if it exists, so agents inherit your user.name and user.email.
Point at an alternative with GITCONFIG_FILE=/path/to/gitconfig.
Note: opencode/opencode.json also supports an instructions array for global instruction files, which load in full — avoid listing full SKILL.md files there unless you want them always in context.
A tong is a Swarmforge-managed sidecar container started alongside the anvil (the harness container you work in). The name captures the primary use case: holding something hot — usually credentials — so the agent never touches it directly. A credential-holding tong runs as a sibling container exposing an MCP server the agent calls over the session network; the secret material lives only in the tong's process space. Tongs can also be plain network services (a throwaway Postgres, a fixture server), volume providers, or background side-effect processes.
Tongs are YAML files discovered across the same four layers as agents.
The host-side launcher (swarmforge/anvil/, run through bin/run-anvil) discovers, approves, starts, and tears them down; make run_opencode / make run_claude already delegate to it.
- (Only if the tong needs secrets) configure the secret-provider table (see Secret providers).
- Drop a tong definition into a layer directory, e.g.
~/.swarmforge/tongs/<name>.yaml(personal) or<workspace>/.swarmforge/tongs/<name>.yaml(project). - Run the anvil as usual (
oc, ormake run_claude PROJECT_DIR=$(pwd)). A workspace-sourced tong prints a privilege summary and asks for approval on first run (see First-run approval). The launcher resolves secrets (which may prompt your provider CLI to unlock), starts the tong, waits for readiness, injects reachability into the anvil, then runs the anvil in the foreground. - On exit (including Ctrl-C),
sessiontongs and the per-session network are torn down;sharedtongs are left running.
One YAML file per tong under .swarmforge/tongs/, merged by name (filename = identity) lowest to highest precedence:
- user —
~/.swarmforge/tongs/(override the root withSWARMFORGE_USER_ASSETS_DIR) - org —
$(SWARMFORGE_ORG_CONFIG_ROOT)/.swarmforge/tongs/(override withSWARMFORGE_ORG_ASSETS_DIR) - repo —
tongs/in the checkout (override withSWARMFORGE_REPO_TONGS_DIR, which points directly at the directory) - workspace —
<workspace>/.swarmforge/tongs/
A higher layer replaces a same-named tong wholesale; disable: true switches off an inherited tong.
The user/org/repo layers are trusted; the workspace layer (any repo you happened to clone) is gated by first-run approval.
# ~/.swarmforge/tongs/github-creds.yaml
description: Holds GitHub credentials, exposes push/PR operations as MCP
lifecycle: session # session | shared (required)
image: ghcr.io/example/github-tong@sha256:... # required; pinned digest recommended
env:
GITHUB_TOKEN: ${secret:op:op://Work/github/token} # resolved on the host launcher
LOG_LEVEL: info # plain values pass through as ordinary -e env
interface: # required; how (or whether) the anvil reaches the tong
kind: mcp # mcp | port | volume | none
transport: http # http only in v1
port: 8080 # the port the server listens on inside the container
name: github # canonical MCP server name the agent sees
# aliases: [gh, git.example] # optional extra DNS names the tong also answers to
mounts: # opt-in magic words only, never raw host paths
- workspace:ro # or workspace:/code:ro to bind it somewhere else
resources:
memory: 512m # string or number
networks: # optional extra pre-existing networks to also join
- some-existing-net
# entrypoint: [...] # optional argv override of the image ENTRYPOINT
# command: [...] # optional argv override of the image CMDRequired fields: lifecycle, image, and interface (with a valid kind). Unknown keys are tolerated for forward compatibility.
The interface: block drives what gets injected into the anvil, how readiness is checked, and what plumbing is wired up:
mcp— an HTTP MCP server (the common case). Requiresportandname;transportdefaults tohttp. Injects per-harness MCP config pointing athttp://<name>:<port>/mcpon the session network (interface.pathoverrides the/mcpsuffix). TCP readiness probe by default.port— a non-MCP network service. Requiresport; optionalprotocolis informational. InjectsSWARMFORGE_TONG_<NAME>_HOST(the canonical alias) andSWARMFORGE_TONG_<NAME>_PORTso the anvil composes its own connection string. TCP readiness probe by default.volume— a shared named volume, no network. Requiresvolumeandmountpoint; readiness must be declared. The schema accepts it, but the launcher does not wire it up yet and refuses to start such a tong with a clear message.none— a background side-effect with no anvil-facing surface. Injects nothing. Readiness must be declared.
<NAME> is the filename uppercased with hyphens turned into underscores (github-creds → SWARMFORGE_TONG_GITHUB_CREDS_*).
The MCP server name and the port alias are docker network aliases (not container names), so generated config is identical regardless of where the workspace is mounted.
A network-facing tong (mcp or port) may declare additional DNS names it answers to on the session network:
interface:
kind: port
port: 3000
aliases: [api, console, local.example.test]Use this when something dialing the tong hardcodes a hostname of its own — a vhost another container expects, or the CN on a TLS certificate a client must match. Each entry must be a valid DNS name (letters, digits, hyphens and dots) and is registered as a further --network-alias; the canonical alias is unaffected and stays the name injected into the anvil (SWARMFORGE_TONG_<NAME>_HOST, the MCP URL). Extra aliases participate in the same collision check as canonical ones — two tongs on the session network may not claim the same name, whether canonical or extra. volume and none tongs have no listener and reject the field.
readiness:
mode: healthcheck # tcp | healthcheck | none
command: ["test", "-S", "/run/agent.sock"] # for mode: healthcheck (docker exec)
timeout: 30s # 30s / 500ms / 2m, or a bare number of seconds (default 30s)tcp is the implicit default for mcp and port. volume and none have no port to probe, so mode is required for them; use mode: none to deliberately skip the gate.
Mounts are opt-in magic words, never raw host paths. Only two are recognized, spelled <word>[:/target][:mode] with the access mode (ro/rw) last:
workspace[:/target][:mode]— bind-mounts the session workspace, at/workspaceunless an absolutetargetsays otherwise (e.g.workspace:ro,workspace:/code,workspace:/code:ro). A custom target lets an image that expects its sources elsewhere be used unmodified (it does not set the working directory — the process still starts in the image's ownWORKDIR). A target is refused unless it is an absolute path free of whitespace, and refused if it resolves to/, overlaps another of the tong's mounts, or overlaps a path the tong's own wiring occupies — the secret FIFO at/run/swarmforge/secret-envand the/bin/shits wrapper execs (for a tong with secret references), or the docker socket (for a tong that mounts it). The workspace bind is paired with the same git-dir mounts the anvil gets fromswarmforge/gitguard.py: read-only guards over the config and hooks the host's git obeys, and — when the workspace is a linked worktree or another checkout whose git dir lives outside it — that git dir at its own absolute path, which is where the checkout's.gitpointer file says to look (without it, git inside the tong fails with "not a git repository"). When everyworkspacemount isro, the ride-along git-dir mounts are forced read-only too.docker-socket[:mode]— bind-mounts the host docker socket onto the same path inside the container (so it takes no target). This is full host docker control and is always called out explicitly in the workspace approval prompt; it is the grant a broker tong needs.
session— started with the anvil, torn down when it exits. Per-session isolation; the default for credential tongs.shared— long-lived, survives across anvil sessions (ollama-style). Started on first use, connected to each session's network via a network alias, and left running on teardown (no refcounting). A runningsharedcontainer whose config-hash docker label still matches the current definition is reused untouched; a missing, stopped, or stale one is recreated automatically. A rotated secret behind an unchanged reference does not churn it — force a restart withdocker rm -f <container>. Asharedtong may not mount theworkspace(it would leak one session's workspace into the next); use asessiontong for per-workspace mounts.
Secret references are resolved on the host by shelling out to a provider CLI — Swarmforge knows nothing about any individual secret manager.
Declare your providers once in the user layer at ~/.swarmforge/secret-providers.yaml (override the root with SWARMFORGE_USER_ASSETS_DIR):
# ~/.swarmforge/secret-providers.yaml
providers:
op: ["op", "read", "{ref}"]
pass: ["pass", "show", "{ref}"]
doppler: ["doppler", "secrets", "get", "{ref}", "--plain"]
aws: ["aws", "secretsmanager", "get-secret-value", "--secret-id", "{ref}",
"--query", "SecretString", "--output", "text"]Each value is an argv template; the literal token {ref} in any element is replaced with the reference. Command templates must be single-line flow lists.
A missing file means no providers are configured, so any secret reference fails loudly rather than resolving to an empty value.
Reference a secret from a tong's env: as ${secret:<provider>:<ref>}, for example ${secret:op:op://Work/github/token}.
Because the launcher runs in your terminal before the anvil starts, interactive unlocks (op signin, biometric prompts) work for free.
Per-secret overrides. A provider value may instead be a structured entry with a default command and per-secret overrides, so a shared tong (say in the org layer) can reference ${secret:<provider>:<ref>} while each developer's personal table decides how each individual secret is fetched. One developer resolves a ref through pass, another through 1Password, without touching the shared tong:
# ~/.swarmforge/secret-providers.yaml
providers:
shared:
default: ["pass", "show", "{ref}"] # used for any ref not overridden
overrides:
ci-token: ["doppler", "secrets", "get", "CI_TOKEN", "--plain"]Resolving ${secret:shared:<ref>} uses the argv in overrides for that ref, falling back to default. A ref with neither stops the launch with a clear message. default is optional (use overrides alone to require every ref be listed), and {ref} substitution still applies to whichever command is chosen. Because default and overrides are separate keys, a secret literally named default is just an entry under overrides — distinct from the fallback — and any other provider-level key is flagged as a typo at load.
Delivery is leak-resistant by design. A resolved secret is never passed as a docker -e value, a command-line argument, or a file on disk (anything holding the docker socket could read those back). Instead the launcher streams the secret env to the tong over a host FIFO and wraps the tong's entrypoint with a /bin/sh prologue that reads the FIFO, exports the values, then execs the image's real entrypoint — so an unmodified off-the-shelf server that reads its credentials from process.env works as-is. A tong with secret env therefore needs /bin/sh in its image; a tong without secrets runs its image entrypoint unchanged. Plain (non-secret) env: values still flow through -e.
The user, org, and repo layers are installed deliberately and are trusted; they skip the gate. A workspace-sourced tong (from a repo you cloned) could otherwise request your secrets, host mounts, or the docker socket simply by being present, so the launcher gates it:
- Before starting, it prints exactly what the tong requests — image, secret references, mounts, networks, and docker-socket access — and asks you to approve.
- Approval is keyed by workspace path + tong name + a hash of the merged definition, stored in
~/.swarmforge/approvals.json. Any change to the definition re-prompts. - The gate defaults to No, and a non-interactive stdin reads as No. A scripted
--no-promptrun fails closed rather than auto-approving. - Approving
image: foo:latestapproves a moving target; pinned digests are the recommended convention for workspace tongs.
A broker is a tong that holds the docker socket and spawns its own short-lived worker containers on demand, so the anvil can compile, run tests, or do other sandboxed work without ever getting socket access itself.
tongs/docker-broker/ ships a reference broker: an HTTP MCP server whose verbs are defined by a declarative config, not hand-written per project. Each command in broker.config.yaml describes the worker container to spawn — reusing the tong definition shape (image, mounts, command, env, resources, networks) — with an MCP surface (name, description, typed params) on top:
allowed_images:
- node:24-alpine # the entire image allowlist; nothing else can run
commands:
- name: test # the MCP tool the agent calls
description: Run the project's test suite.
image: node:24-alpine
mounts: [workspace:/work:ro]
workdir: /work
command: [npm, test, --]
params:
- name: suite # exposed as a constrained MCP input
type: enum # boolean | enum
values: [unit, integration, e2e]
append_value: true # the chosen value is appended as one command tokenThe config is the broker's allowlist. There is no verb that runs an arbitrary image or mounts an arbitrary host path: a worker may only mount the session workspace, and a parameter can only toggle a fixed effect (boolean) or pick a value from a fixed set (enum) — values are passed as whole argv words to a worker spawned without a shell, so nothing a caller sends can become a flag, path, or shell metacharacter. The launcher hands the broker the workspace's host path as SWARMFORGE_WORKSPACE_HOST_PATH so it can mount the workspace into the workers it spawns.
To enable it:
make build_broker— builds theswarmforge-docker-brokerimage.- Copy the example definition into a layer:
cp tongs/docker-broker/docker-broker.tong.yaml ~/.swarmforge/tongs/docker-broker.yaml.
The example definition is not auto-discovered from the checkout (it lives a directory below the layer root, and discovery reads only top-level *.yaml), so the broker stays off until you opt in. Because it requests the docker socket, a workspace-sourced copy is always called out in the approval prompt.
The launcher, the tongs layer, and the container-side translators are covered by stdlib unittest tests in tests/test_*.py. A test module is named for the source module it covers — tests/test_tongs_<module>.py for swarmforge/tongs/<module>.py, tests/test_anvil_<module>.py for swarmforge/anvil/<module>.py — so the file that covers a change is the one named after it. Two modules have no namesake file because they have nothing to assert on their own: swarmforge/anvil/readiness.py is exercised through run_with_tongs, and swarmforge/anvil/errors.py holds one exception class. Fixtures that more than one test module needs live in tests/tongs_fixtures.py and tests/anvil_fixtures.py, which the discovery glob skips.
Two files assert on the shape of the repo rather than on any one module. tests/test_image_layout.py holds the Dockerfile and the entrypoint to the same import root, and tests/test_package_layering.py keeps the package's imports acyclic and keeps loading a module from a file path out of everything but the bin/ shims. Both fail the way a build should — before anything reaches a container.
- Run them:
make test
The target is python3 -m unittest discover -s tests -p 'test_*.py' with the repo root on PYTHONPATH, and CI runs the same discovery. Nothing names test modules by hand, so a new tests/test_*.py file runs the moment it lands. It needs only a host python — no Docker, no network, no model.
- Run it:
make lint
ruff check over every Python file in the repo, configured in pyproject.toml — including the extensionless commands in bin/, which ruff would otherwise skip. The rule set is ruff's default — the pycodestyle checks that catch mistakes plus all of pyflakes — and stops there on purpose: line length, import order, and whitespace are left to the author, so turning the linter on does not reflow files a change never touched. Only ruff check is ever run; ruff format is not part of this repo. Install ruff with pipx install ruff (CI pins the version), or point the target at another copy with make lint RUFF=<path>.
Ruff is a contributor tool, not a dependency: the harness image installs no third-party Python, and every module under swarmforge/ stays stdlib-only.
A lightweight skill test harness runs scenario prompts against a chosen model and verifies expected behavior. It drives a real model inside the OpenCode image, which is why it is a separate target from the unit suite.
- Run all skill tests:
make test-skills MODEL=<provider/model> - Run a single skill's tests:
make test-skills MODEL=<provider/model> TEST_SKILL=<skill-name> - Optional judge mode:
make test-skills MODEL=<student> TEST_ENABLE_JUDGE=1 EVAL_MODEL=<judge> - Timeout override:
make test-skills MODEL=<provider/model> TEST_TIMEOUT_S=<seconds>
Tests live in skills/<skill-name>/tests/*.json; the runner is scripts/skill_eval.py.
Assertions can be:
- Output patterns:
expect.must_matchandexpect.must_not_match(regex against formatted output) - Tool calls:
expect.must_toolandexpect.must_not_tool(extracted fromopencode run --format jsonevents)