A Kubernetes control plane for swappable CLI coding harnesses. The control surface is MCP. A parent is whoever called run.create (or session.create). Every child is a Kubernetes Job — never an in-container Task / Agent / subagent. Prompt it for swarms and adversarial agents.
Source: e-jerk/metaprompt · Images: ghcr.io/e-jerk/metaprompt/* · Runtime: Bun, not Node.
Agents: fetch implement.md (do not invent steps). Same document in markdown: how-it-works.md. Index: llms.txt. Install: setup.md.
You do not prompt Metaprompt as a chatbot API. You start a parent (session or Job), then that parent calls plane MCP. Independent workers are one job.spawn. Adversaries are more workers in the same party with opposite instructions. They share coord.* and artifacts. They never use vendor Task / Agent / subagent tools.
Pick harnesses your cluster can run. Local k3s: opencode (free Zen models) or stub. EKS with Bedrock: claude-code. AgentCore enabled: agentcore (Job-only). Mix them in one spawn. Compilers are in-pod sidecars (needs / sidecars, then tool.exec) or tool Jobs (bash / bun / go / node / python / rust / zig), never Shell and never baked into the agent image.
Laptop, after port-forward. Local token shown; on EKS use a GitHub OIDC JWT.
curl -sS http://127.0.0.1:3333/mcp \
-H 'Authorization: Bearer alice-token' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"session.create","arguments":{"repo":"app"}}}'
# attach: bash scripts/session-up.sh
Or a one-shot root Job: run.create { "harness": "opencode", "repo": "app", "prompt": "…" }. Children still spawn from inside that Job (it has a run token). You cannot call job.spawn with only alice-token.
The parent already has system text that says to use job.spawn. Your prompt should name roles, say they are parallel Jobs, and forbid in-process agents.
Ship a login rate limiter on repo app.
Spawn a swarm with job.spawn (one call, several agents). Do not use Task, Agent, or subagent tools.
Roles:
- implementer: write the smallest correct change (jj, no git remotes).
- adversary: try to bypass the limiter; write findings only, do not "help" the implementer.
- tester: add tests that fail on the bypass, then pass on the fix.
They share this party. Each one coord.artifact.put their result (name: impl / adv / test).
Do not wait by pasting logs. Use job.progress, then job.wait.
After all three finish, spawn judge (after: [impl, adv, test]) to keep only what survives the adversary. coord.artifact.put name=verdict.
Then summarize from run.get summaryTree.
One job.spawn. Distinct ids. No after means they start together (up to maxParallelPerParent, default 8). The plane opens a nested party; the parent is an observer.
{
"name": "job.spawn",
"arguments": {
"agents": [
{
"id": "auth",
"harness": "opencode",
"prompt": "Implement /login rate limiting. Put the patch summary in coord.artifact.put name=auth. Use jj. When done, self.exit with a short summary."
},
{
"id": "docs",
"harness": "opencode",
"prompt": "Write the rate-limit docs from the repo as it is now. coord.artifact.put name=docs. Do not wait for auth."
},
{
"id": "bench",
"harness": "opencode",
"prompt": "Add a small bench or load script for /login. coord.artifact.put name=bench."
}
]
}
}
Then job.progress (heartbeat, not a log dump) and job.wait. run.get with summaryTree: true rolls child summaries up.
Same party, opposite goals. They do not merge in-process. The implementer writes; the adversary attacks; a later Job (after) is the only one allowed to reconcile. That is how they “work together” — shared party, artifacts, and a judge — not a group chat inside one pod.
{
"name": "job.spawn",
"arguments": {
"agents": [
{
"id": "impl",
"harness": "opencode",
"prompt": "You are the implementer. Add login rate limiting. Do not read the adversary. coord.artifact.put name=impl when the change compiles. jj only."
},
{
"id": "adv",
"harness": "claude-code",
"model": "bedrock-sonnet",
"prompt": "You are the adversary. Do not implement the feature. Find bypasses (header spoof, IPv6, burst, bypass paths). coord.artifact.put name=adv a repro list. Be hostile to the design, not to the party."
},
{
"id": "test",
"harness": "codex",
"prompt": "You are the tester. Write tests that would catch the bypasses an attacker would try. coord.artifact.put name=test. Do not soften failing tests to make impl look good."
},
{
"id": "judge",
"harness": "agentcore",
"after": ["impl", "adv", "test"],
"prompt": "You are the judge. coord.artifact.get impl, adv, test. job.progress on those runs. Keep only changes that survive the adversary. If adv still wins, spawn nothing else; self.exit with fail and a punch-list. If impl holds, note residual risk. coord.artifact.put name=verdict."
}
]
}
}
claude-code + bedrock-sonnet needs bedrock.enabled. agentcore needs AgentCore configured and is Job-only. If those are off, use opencode for every role (still four Jobs). Do not collapse them into one prompt.
Optional after spawn: children coord.post status; a coordinator coord.barrier before the judge reads. coord.handoff starts another run in the same party when you want a human-reviewable next step instead of another spawn edge.
Use after: [id] when B must see A’s result. That is a DAG of Jobs, not parallel work.
{
"name": "job.spawn",
"arguments": {
"agents": [
{ "id": "schema", "harness": "opencode", "prompt": "Add the migration. coord.artifact.put name=schema." },
{ "id": "api", "harness": "opencode", "after": ["schema"], "prompt": "coord.artifact.get schema. Implement the API. coord.artifact.put name=api." },
{ "id": "review", "harness": "opencode", "after": ["api"], "prompt": "Review api vs schema. Do not rewrite unless you find a break. coord.artifact.put name=review." }
]
}
}
job.spawn for the whole graph. Do not spawn in a loop if the work is independent.job.progress / run.logs, not by stuffing descendant logs into the next prompt.cascade.clanker to a Job. Never put tokens on the CR.Metaprompt does not run agents inside the plane process. The plane is an MCP server in the cluster (deploy/metaprompt-mcp). It authenticates callers, enforces ACL, writes catalog and party state, and creates Kubernetes objects. The coding work happens in harness pods.
session.create (interactive root) or run.create (one-shot Job).HarnessJob CR (metaprompt.io/v1). Mode session → long-running Pod. Mode job → batch/v1 Job.job.spawn. Each spawn is another Job, nested in the same party, with a parent pointer and a depth increment. Summaries roll up the tree. The parent tails descendants with run.logs / job.progress.Independent work: one job.spawn with several agents (parallel Jobs). Dependent work: after: [id] or job.wait. Never vendor Task tools.
Callers talk to http://<plane>:3333/mcp with JSON-RPC tools/call and Authorization: Bearer …. In-cluster Jobs use Service http://metaprompt-mcp:3333. On a laptop you port-forward that Service.
curl -sS http://127.0.0.1:3333/mcp \
-H 'Authorization: Bearer alice-token' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"harness.list","arguments":{}}}'
Ready check: GET /healthz → {"ok":true}.
Tools are fixed names. Do not invent verbs. Groups:
| Group | Tools |
|---|---|
| Discover | harness.list harness.get model.list skill.list mcp.list repo.list asset.list environment.list environment.get environment.plan |
| Worker images | environment.create environment.wait environment.delete — create/delete are parent-only; confirm:true only after the user approves a plan |
| Party / coord | party.create party.get party.list party.add party.close coord.post coord.inbox coord.wait coord.signal coord.barrier coord.handoff coord.members coord.artifact.put coord.artifact.get |
| Runs | run.create run.instruct run.wait run.kill run.cancel run.logs run.result run.list run.get run.share run.unshare |
| Jobs | job.run job.spawn job.wait job.kill job.logs job.progress |
| Self (from a Job) | self.suspend self.exit |
| Sessions | session.create session.list session.get session.attach session.delete |
| Cron | cron.create cron.list cron.get cron.delete cron.enable |
| jj / VCS | jj.status jj.diff jj.log jj.new jj.describe jj.squash jj.rebase jj.bookmark jj.git.fetch jj.git.push vcs.cred.mint |
| Memory | memory.put memory.search memory.get memory.delete |
Always-on MCP names written into every Job prefix: plane, party, jj. clanker is parent-only (PARENT_ONLY_MCPS). run.create / job.spawn reject it even if an operator adds it to their catalog. Never put Clanker on a Job.
| Session | Job | |
|---|---|---|
| Create | session.create | run.create or job.spawn |
| Kubernetes | Long-running Pod, mode: session | batch/v1 Job, mode: job |
| Default harness | session (fat image: OpenCode, Claude, Codex, Cursor). Stub is rejected. | Catalog harness you pass (opencode, stub, …) |
| How you attach | kubectl exec → /workspace/.mp/attach | Logs via run.logs; process exits and POSTs complete |
| Children | Still Jobs via job.spawn | Still Jobs via job.spawn |
Interactive root:
metaprompt forward
metaprompt session # or: metaprompt session opencode|cursor|claude-code|codex
# kubectl --context metaprompt -n metaprompt exec -it mp-run-… -c harness -- /workspace/.mp/attach
Inside the pod: /workspace/.mp/attach, or opencode / cursor / claude / codex / shell.
Storage on a Job is tmpfs (emptyDir Memory) or durable (pvc in the API). Durable /workspace is hostPath on k3s/k3d (~/.metaprompt/workspaces), EFS on EKS (metaprompt workspace mount), or a per-run PVC elsewhere. metaprompt session prints the laptop path. Local smoke prefers tmpfs.
The CR is the launch record. It carries run id, owner, harness, image, repo, sha, model, party, parent/root, depth, skills, MCP list, assets, storage, and plane URL. It does not carry the run token, DATABASE_URL, or VCS PATs.
The run token lives in Secret mp-token-<run>, mounted into the pod as METAPROMPT_RUN_TOKEN. The harness uses that bearer against the plane (and the in-pod child MCP proxies with it). Jobs never get the memory database URL; they call memory.* on the plane.
Helm release, namespace, and labels stay metaprompt. Do not put repos in Helm config without currentSha — live run.create 409s.
Image CMD is bun /app/packages/runner/src/main.ts (Jobs) or session.ts (sessions). Order:
127.0.0.1:3334. Prefix: party → /mcp, jj → /jj. Proxies party / coord / job / self / jj.git.fetch / jj.git.push / vcs.cred.mint to the plane with the run token. Local jj.* runs in the pod (or stubs if jj is missing). Memory stays on the plane..metaprompt-pin with lowerdir. When git-sync is on, Jobs mount host /var/lib/metaprompt/repos/<name>/current at /repos and set METAPROMPT_LOWERDIR=/repos/<repo>/current. Local k3s leaves git-sync off so smoke does not need the public repo clone.prefixHash so identical stubs can share cache.commandFor (model flags + prompt). On exit the Job runner POSTs /internal/runs/:id/complete.Git credential helpers are emptied in the child env. Fetch/push goes through minted creds, not embedded PATs.
The catalog lives in packages/shared (defaults) and can be overlaid from Helm config.json for assets, git-sync, Bedrock, and auth — not a second control plane.
| Harness | Image | Default model |
|---|---|---|
session | …/session:latest | none — fat root CLI image |
opencode | …/opencode:latest | mimo-v2.5-free (also deepseek-v4-flash-free, big-pickle) |
claude-code | …/claude-code:latest | bedrock-sonnet (needs bedrock.enabled) |
codex | …/codex:latest | gpt-5 |
cursor | …/cursor:latest | auto (also composer-2.5) |
agentcore | …/runner:latest | agentcore — Bedrock AgentCore Harness or Runtime (Job-only) |
stub | …/stub:latest | none — Jobs / smoke only |
bash | …/bash:latest | none — tool Job (bash -lc) |
go / node / python / rust / bun / zig | …/<name>:latest | none — tool Jobs or loopback sidecars |
Tool Jobs: bash, bun, go, node, python, rust, zig. On an agent Job, needs: ["bun", "zig"] or sidecars adds those containers; the agent drives them on 127.0.0.1 with tool.exec. Cache: reuse / wipe / clean. Rust versions: { "tool": "rust", "version": "1.84.0" }. needs: ["go"] alone is still a go tool Job. Custom durable tool images: environment.plan FROM a tool env, user confirms, parent-only environment.create.
Local k3s has Bedrock and AgentCore off. run.create with claude-code + bedrock-sonnet or harness: "agentcore" 409s there. Use opencode + a free Zen model, session, or stub. AgentCore is Job-only (run.create / job.spawn): the Job is the SigV4 bridge; the agent runs in the AgentCore microVM with actorId = owner, a stable session id, plane MCP at planeExternalUrl, and optional Memory / Gateway / Browser / Code Interpreter / skills. Catalog MCP agentcore-gateway appears when a Gateway URL is set. Children still use job.spawn.
Each harness lists disallowedTools: Task, Agent, subagent, Shell. Parallelism is job.spawn. Compilers run as sidecars or tool Jobs.
Skills are named catalog entries (or inline / URL) hashed into the prefix. Paths the runner looks at: skills, .cursor/skills, .grok/skills.
Assets — after asset.list, run.create / session.create may pass assets: ["eval-set"]. The plane checks readers, then mounts read-only. Volume kinds: pvc:…, hostPath:…, configMap:…, secret:…, emptyDir / emptyDir:Memory. Local fixture: hostPath:/var/lib/metaprompt/assets/eval-set.
Repos have readers/writers, currentSha, and currentGeneration. The default local repo is app with a fixture SHA so Jobs can start without git-sync.
A party is the shared room for a root and its descendants. Members get coord inbox, artifacts, and barriers. coord.handoff creates another run in that party. job.spawn can open a nested party for a parallel group. Concrete swarm and adversary prompts: How to use it.
When children finish, the plane rolls summaries up the tree (capped bytes). run.get with summaryTree shows the rollup. Parents should poll job.progress instead of stuffing descendant logs into the next prompt.
Kill can cascade. Share/unshare is ACL on the run (local smoke: bob is denied until run.share).
When memory.enabled, the plane talks to Postgres + pgvector. Embedding default on k3s is hash (no API key). Scopes: user, party, repo. Jobs put/search through the plane MCP; they do not mount the database.
Version control is jj-first. jj.git.fetch / jj.git.push mint via vcs.cred.mint. GitHub issues and PRs use gh.issue.* / gh.pr.*. On EKS the plane mints GitHub and other app tokens from a GitHub App or the mcp ServiceAccount OIDC token (app.cred.mint). Long-lived PATs stay on the plane and are never minted to Jobs. Do not embed remotes or PEMs in Job specs.
Local k3s static users (not for EKS):
Bearer local-dev-token — adminBearer alice-tokenBearer bob-tokenEKS / GKE — GitHub Enterprise OIDC JWT. User is JWT actor (or sub). Org/repo/team claims map to groups. This is not EKS IRSA and not Bedrock / AgentCore IRSA; those can coexist on the job service account.
A Job’s identity is the run token, scoped to that run. Plane secrets stay off the CR.
Defaults in the catalog (overridable in Helm limits):
| Limit | Default |
|---|---|
| Max spawn depth | 4 |
| Max running per user | 16 |
| Max parallel per parent | 8 |
| Max attempts | 8 |
| Suspend TTL | 86400s |
| Max skill / memory bytes | 32768 |
| Max summary / rollup | 2048 / 8192 |
| Log tail | 50 default, 500 max |
Pick a playbook from setup.md. Clanker Cloud is optional — local Apple container k8s, k3d, existing EKS, and existing GKE do not need it.
brew tap e-jerk/metaprompt https://github.com/e-jerk/metaprompt
brew install --HEAD metaprompt
metaprompt up # install a local Kubernetes cluster + Helm
# metaprompt install eks # BYO cluster
metaprompt smoke
metaprompt forward
Images: Apple Silicon container build + container k8s load-image; CI docker buildx bake. See setup/build.md.
Repo layout: packages/shared (types, ACL, catalog, prefix, spawn, rollup), packages/mcp (plane), packages/runner (pod), adapters/* (images), deploy/chart (Helm), skills/ (playbooks synced onto this site).
clanker k8s deploy (naive Deployment + LoadBalancer). The chart owns the release.DATABASE_URL, PATs, or run tokens on the CR.