Rich Snapp
principal software engineer
design system @ MasterControl
open source contributor
Salt Lake City, Utah · writes about AI, devops, and agent workflows
richsnapp.com — Automating agents with the next iteration of Ralph
a deterministic loop on purpose; it worked, but it lived as many lines of bash and was hard to visualize and change
## Workflow
1. Run the tests. If they fail, go to step 4.
2. IMPORTANT: ask the reviewer for a review.
3. If it asks for changes, go back to step 1.
You MUST loop until it approves.
4. Fix the failures, then return to step 1.
NEVER commit before the review passes.
ALWAYS follow these steps in order.
jr drew one of these. I did not want to create more bash loops.
reviewing: {
invoke: { src: "reviewer", input: reviewerPrompt },
on: {
review_verdict: [
{ guard: approved, target: "closingTask" },
{ guard: "underReviewCap", target: "coding" },
{ target: "#body.escalated" },
],
report_blocked: { target: "#body.escalated" },
},
},
Claude wrote it. I review the graph, not the transcript.
one laptop · jr
one filesystem, one set of ports, one set of credentials
one pod per Workspace · jr2
× N — one per feature, gone when the Workspace ends
leaked servers and portsthe pod ends with its Workspace
locks on a shared hostown ports, filesystem, processes
agent and user share credentialscredentials kept in the Custodian
one laptop · jr
one build starves the rest
a cluster · jr2
node-1
Orchestratorpodnode-2
podpodnode-3
poda Sandbox lands wherever an ordinary pod can
one agent starves the restmore nodes, more room
stops when the lid closeskeeps running with it closed
reachable from this laptopone address for every caller
Orchestrator · HTTP
POST /workflows/:name/runs
GET /runs/:id/events
POST /runs/:id/gates/:gate/events
GET /workflows/:name/machine
jr2 is infrastructure. The UI is whatever calls it.
$ kind create cluster # or any kube context
$ jr2 init agents && cd agents && npm i
$ jr2 up
$ jr2 run ping
export default defineConfig({
registry: process.env.JR2_REGISTRY, // unset on kind
secrets: [{ name: "ANTHROPIC_API_KEY",
value: process.env.ANTHROPIC_API_KEY }],
git: { credentials: [{ match: "*", token: "JR2_GIT_TOKEN" }] },
});
jr2 up converges safe to re-run
you bring jr2 won't help here
Convention over configuration. One command, no runbook.
every decision in jr2 is written down: the context, the choice, and what it costs
jr2 CLI and instance interfaceworkspace() wraps a body Machine and owns only Sandbox lifecyclejr2Setup; vocabulary and agent menus derive
from the machine
pool over a generalized source portjr2 up builds every image it deploys, except a pinned oneworkspace() and a cache on the nodejr2 hands off to the Instance's own, and an Instance has one Kit
version
jr2 CLI and instance interfaceworkspace() wraps a body Machine and owns only Sandbox lifecyclejr2Setup; vocabulary and agent menus derive
from the machine
pool over a generalized source portjr2 up builds every image it deploys, except a pinned oneworkspace() and a cache on the nodejr2 hands off to the Instance's own, and an Instance has one Kit
version
Watch it run, then ask why.
your Sandbox Image + /opt/jr2
the conversation, the model calls, the Menu, the Working tools
the Agent's code runs here
envoy, pinned
forwards the Harness's Menu read and pick; adds the model key toward its host
holds every credential, where the Agent cannot read it
your image, your entrypoint
e.g. an sshd, a forwarded ssh agent
jr2 never builds, probes, or commands it
the Workspace, set up by the Orchestrator: a clone off the node's repo cache, then a branch worktree. No git credential in the pod: a fetch asks the cache, and a push has none.
The Agent writes the code. It never holds a key.
FROM node:24-slim
# the floor: glibc and git
RUN apt-get update \
&& apt-get install -y git ca-certificates
# your agents' toolchain: compilers, CLIs,
# language servers, dotfiles
RUN …
images/default/Dockerfile
jr2 up builds it and tags it by content
workspace(body, { image: import.meta.resolve("./image") })
a package ships its image with its Machine
customize(task, { image: "ghcr.io/you/toolchain:1.4" })
an image you bake and host; jr2 never builds it
At pod start jr2 mounts its runtime at /opt/jr2: the Harness, built on the pi agent
toolkit. It adds no layer to your image, and your tools win on PATH.
You own the toolchain. jr2 brings the Harness.
const needsInfo = defineEvent({ 2
name: "needs_info", audience: "agent",
input: z.object({ question: z.string() }),
});
jr2Setup({
events: [accept, needsInfo, reject, answer],
actors: {
triager: agent({ 3
model: "local/qwen3.6-35b-a3b",
workspace: "none",
instructions: "You triage feature requests…",
}),
},
}).createMachine({
input: z.object({ request: z.string() }), 1
states: {
triaging: { 4
invoke: { src: "triager" },
on: { accept: "accepted", needs_info: "clarify", reject: "rejected" },
},
clarify: { invoke: { src: "gate" }, on: { answer: "triaging" } },
accepted: { type: "final" }, rejected: { type: "final" },
},
});
"none" can read and pick, nothing else.
on becomes its Menu, offered as tools. invoke a
gate: the run waits for a human.
Choose your own adventure: the Agent picks where it goes next, from defined states.
import { task } from "@jr2/machines";
export const machine = customize(task, {
repos: {
target: { url: "http://seed…/greet.git" },
},
agents: {
coder: { model: "local/qwen3.6-35b-a3b" },
},
});
jr2 up refuses and prints this lineconst coding = customize(task, {
repos, agents,
});
jr2Setup({ actors: { triager, coding } })
.createMachine({
states: {
building: {
invoke: {
src: "coding",
input: ({ context }) => ({
prompt: context.summary,
branch: `feat/${context.slug}`,
}),
},
},
},
});
export const machine = customize(
pool(task, {
source: source({
// the next ready ticket, or null
next: fromPromise(({ input }) =>
tracker.next(input.active)),
}),
itemId: (t) => t.id,
cap: 3,
itemInput: (t) => ({
prompt: t.body,
branch: `ticket/${t.id}`,
}),
}),
{ repos, agents },
);
Loop engineering? How about state machine engineering?
harness: {
// one OpenAI-compatible endpoint:
// llama.cpp, vLLM, Ollama, …
provider: {
id: "local",
api: "openai-completions",
baseUrl: process.env.JR2_PROVIDER_URL,
},
},
// keys for the built-in catalog, held
// by the Custodian: anthropic, openai, …
secrets: [{
name: "ANTHROPIC_API_KEY",
value: process.env.ANTHROPIC_API_KEY,
}],
agent({ model: "anthropic/claude-sonnet-4-6" })
the definition: who the Agent is
input: { prompt, model: "local/qwen3.6-35b-a3b" }
a state turns the dial: a cheap model for a small step
jr2 run task --input '{ "prompt": "…", "model": "…" }'
when the Machine takes it as input: task does
* a switch mid-conversation starts cold: the new model has no prompt cache
The kit never picks a vendor. You pick the model.
the Sandbox is alive
re-attach: the same pod, and the Turn resumes its stream
the Sandbox is gone
workspace.lost goes to the Machine; nothing is silently rebuilt
the Machine was edited
refused, kept, and announced: the snapshot names its Machine
the Harness died
agent.fault goes to the Machine; task parks at its Gate
Kill the Orchestrator mid-Turn. The run picks up where it was.
share a Machineits tools come along; your keys stay home
always-on agentsOpenClaw- or Hermes-style, keys out of reach
not a vault: it reads Kubernetes Secrets, so fill them from Vault with External Secrets Operator, or any store
the engine: upstream Envoy, pinned; jr2 writes only its config
Add external tools, and the Agent never holds a key.
one reusable Machine that wraps any Agent's work
actors messaging actorsxstate actors can send each other events; jr2 would adopt that for Agent
to Agent, or for a human who steers an Agent gone astray. The agent actor already takes xstate events
(today only CANCEL), so a message between Turns fits.
workflows start workflowsa Menu item that asks the Orchestrator to start another run
Agents are good at one small thing. Let the Machine do the rest.
const plan = await agent("plan it");
for (const step of plan.steps) {
const r = await agent(`do ${step}`);
if (!r.ok) break;
}
planning: {
invoke: { src: "planner" },
on: { planned: "working" },
},
working: {
invoke: { src: "coder" },
on: { done: "review", blocked: "escalated" },
},
maybea Machine fills part of its body with a Machine an Agent wrote at runtime
maybea prompt becomes a workflow: an Agent writes the Machine, then jr2 runs it
Agents are good at writing Machines. The types catch mistakes before the run.
npm i -g @jr2/cli
jr2 init my-agents
jr2 up
github.com/snapwich/jr2 · CONTEXT.md for the words · docs/adr for the whys