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.
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.
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 |
| 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:
kubectl -n metaprompt port-forward svc/metaprompt-mcp 3333:3333
bash scripts/session-up.sh # or: session-up.sh 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 pvc. Sessions use the same workspace volume rules. 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) |
stub | …/stub:latest | none — Jobs / smoke only |
Local k3s has Bedrock off. run.create with claude-code + bedrock-sonnet 409s there. Use opencode + a free Zen model, session, or stub.
Each harness lists disallowedTools: Task, Agent, subagent. Parallelism is job.spawn.
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.
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 short-lived creds via vcs.cred.mint. Do not embed remotes or PATs 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 IRSA; those can coexist.
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.
git clone https://github.com/e-jerk/metaprompt.git
cd metaprompt
bun install && bun test
make k3s-up
make cluster-smoke
kubectl --context metaprompt -n metaprompt port-forward svc/metaprompt-mcp 3333:3333
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.