Agentic workflows as state machines, off your laptop.

Rich Snapp

Rich Snapp

principal software engineer
design system @ MasterControl
open source contributor

Salt Lake City, Utah · writes about AI, devops, and agent workflows

web
richsnapp.com
github
snapwich
x
@snapwich
bluesky
@richsnapp.com
linkedin
in/richsnapp

jr: the graph was already there, in bash

richsnapp.com — Automating agents with the next iteration of Ralph

issues

approved

approved

changes

Tasks done

Architect

Rework

Human

Closed

fig. 1 — a segment of jr's control flow

a deterministic loop on purpose; it worked, but it lived as many lines of bash and was hard to visualize and change

control flow in markdown

@DavidKPiano: Calling it now: the trend of putting control flow in markdown for agents (skills, prompts, etc) is going to look *really* silly in a couple months
x.com/DavidKPiano · 2026-09-20
  • hard to follow the graph is prose, spread across files
  • hard to test nothing to run but the agent itself
  • still non-deterministic MUST and NEVER are requests, not edges
/commandSKILL.mdmcp resource
## 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.

different problems, different workflows

a defined feature

none left

changes

approved

Plan

Next task

Human

Coder

Reviewer

break down, build up
an open idea

iterate

keep

Idea

Prototype

Human

Spec

Agents build

prototype, then build
a big refactor

redo

approved

Do one

Human

Fan out

Agent

Agent

Agent

Review

once, then everywhere

jr drew one of these. I did not want to create more bash loops.

a workflow you can read

jr.tsxstate v5
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" },
  },
},
authored in xstate — one state, abridged

approved

request_changes

approve

review limit

dismiss

chain done

changes

blocked · review limit

resume

working

request_review

changes

approved

claimTask

coding

reviewing

closingTask

architectReview

openingPr

humanReview

settled

escalated

fig. 2 — jr as a Machine: the whole feature

Claude wrote it. I review the graph, not the transcript.

a filesystem sandbox is not isolation

one laptop · jr

wt-aagent
wt-bagent
wt-cagent
:3000flock~/.configpids

one filesystem, one set of ports, one set of credentials

one pod per Workspace · jr2

Harnessthe Agent and its tools
Custodianevery credential the Agent uses
User Containeryour image, your dev env

× 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 host is one host

one laptop · jr

agent
build
agent
cpu100%

one build starves the rest

a cluster · jr2

jr2Consoleyour app

node-1

Orchestratorpod

node-2

podpod

node-3

pod

a 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

headless: every UI can be a client

jr2run, send, statusships
Consolethe graph, runs, open Gatesships
GitHuba PR review answers a Gatebuild it
your appa bespoke UI, a bot, a CI stepbuild it

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.

bring your own cluster, or use kind

terminal
$ kind create cluster          # or any kube context
$ jr2 init agents && cd agents && npm i
$ jr2 up
$ jr2 run ping
jr2.config.tsa typical instance
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

  • typechecks the Instance before it builds
  • installs the operator and its CRDs
  • builds every image, then kind loads or pushes it
  • deploys the Orchestrator and its Secrets
  • probes a custom model endpoint in-cluster

you bring jr2 won't help here

  • a cluster; off kind, a container registry
  • Node 24, Docker, kubectl
  • a model: an API key or your own endpoint

Convention over configuration. One command, no runbook.

the whiteboard defense

@mitchellh: The "whiteboard defense:" I should be able to pull you aside at any moment and ask you to explain any customer-facing system you've shipped. You should be able to clearly explain how it works and defend the decisions you made. This is my benchmark for responsible AI usage.
x.com/mitchellh · 2026-09-16

every decision in jr2 is written down: the context, the choice, and what it costs

docs/adr/57 decisions
  1. 0001 Sandboxes are provisioned by a Kubernetes operator via a generic CRD
  2. 0002 The agent actor is a duplex channel; its control plane is Menu tool calls
  3. 0004 A Sandbox's workspace is a private local clone of a read-only cache, not a shared worktree
  4. 0005 The Sandbox pod's containers: Harness, Custodian, and an optional User Container
  5. 0006 The agent picks from a Machine-defined, schema-backed menu; it does not drive the workflow
  6. 0007 Durable Machine state: serializable context, live things constructed from input
  7. 0008 jr2 is a library + CLI; an Orchestrator instance hosts many workflows
  8. 0009 The jr2 CLI and instance interface
  9. 0010 BDD acceptance tests via Cucumber.js (black-box, scenario-isolated)
  10. 0011 Control events are workflow-defined and delivered by closure, not routed
  11. 0012 workspace() wraps a body Machine and owns only Sandbox lifecycle
  12. 0013 The Agent reaches its Machine through a container it cannot read
  13. 0014 Observation is open; run state and control need the Instance token
  14. 0015 Workflows are authored through jr2Setup; vocabulary and agent menus derive from the machine
  15. 0016 Agent-turn mechanics are jr2-internal: ambient coordinates, host-side offsets, absorbed retries, fresh sessions
  16. 0017 The run loop is a jr2 pool over a generalized source port
  17. 0018 The instance authors Agent definitions; jr2 assembles the Harness
  18. 0019 The operational surface is one converging command against the current kube context
  19. 0020 Private-CA trust is a named concept, not volume plumbing
  20. 0021 Workspace continuity is a lease that answers back
  21. 0022 Observation is a level-triggered feed, and an API before it is a page
  22. 0023 The Harness prints the conversation, and the reasoning is the part that prints
  23. 0024 An Agent's turn ends with the state that asked for it
  24. 0025 CANCEL ends a run; stopping one parks it
  25. 0026 A Turn that is over is never prompted, and only a Turn that is over answers 404
  26. 0027 The Harness is jr2's own server: flue retires, the wire stays
  27. 0028 What an Agent may do to the Workspace is part of its definition
  28. 0029 A Menu is fixed for a conversation, and a Turn is told its Allowed picks
  29. 0030 A snapshot names the Machine it was written under
  30. 0031 Menu-only Agents run on the Instance Harness
  31. 0032 The Console unlocks with the Instance token, and the bands do not move
  32. 0033 A Machine declares the input that starts it
  33. 0034 The Console renders with Preact, and the server erases the types
  34. 0035 A runaway turn is ended by the Harness, rerolled once, then a fault
  35. 0036 A turn compacts its own context mid-flight
  36. 0037 A Sandbox Image is built or brought; jr2 injects the Harness at pod time
  37. 0038 jr2 up builds every image it deploys, except a pinned one
  38. 0039 Image garbage collects by reachability
  39. 0041 A build the host already holds is not spent again
  40. 0042 Ready is not routable: the two calls a turn starts with re-ask a Service that has not answered
  41. 0043 The kit is tested as installed: a local registry stands in for npm
  42. 0044 Kit images live at a canonical home; a self-host mirrors it
  43. 0045 The platform joins the image address, and the cluster chooses it
  44. 0046 A converge failure carries its diagnosis
  45. 0047 The git ssh key source is the user's choice
  46. 0048 The Orchestrator boots without its repos
  47. 0049 A Machine carries its parts and composes by invoke
  48. 0050 What code names, config declares; what the CLI names, discovery finds
  49. 0051 A Repo is a slot on the workspace() and a cache on the node
  50. 0052 A Sandbox lands where an ordinary pod lands, and the cache follows it
  51. 0053 A fetch inside the pod asks the node cache, and the cache asks the remote
  52. 0054 The kit ships Machines, and a shipped Machine leaves its parts Open
  53. 0055 A release is a pushed tag, and the publish guard is the credential
  54. 0056 A global jr2 hands off to the Instance's own, and an Instance has one Kit version
  55. 0057 A Turn is its Frame, its Dials, and whether it continues
  56. 0058 A Harness answers the Orchestrator alone
  57. 0059 A Harness holds stand-ins; the Custodian holds the keys

Watch it run, then ask why.

live demo

watch it in the recording, at 26:15 ↗

one pod, three containers

Orchestrator runs the Machine
① admits a Turn
③ the pick, as a Gate event
Sandbox pod

Harness

your Sandbox Image + /opt/jr2

the conversation, the model calls, the Menu, the Working tools

the Agent's code runs here

Custodian

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

User Container optional

your image, your entrypoint

e.g. an sshd, a forwarded ssh agent

jr2 never builds, probes, or commands it

/work

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.

your image, byte for byte

images/default/Dockerfile
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 …
  1. the default images/default/Dockerfile jr2 up builds it and tags it by content
  2. beside a Machine workspace(body, { image: import.meta.resolve("./image") }) a package ships its image with its Machine
  3. brought 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.

what a Machine is made of

triage.tsabridged
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" },
  },
});
  1. input a schema for what starts a run. The Console draws its start form from it.
  2. events typed, with an audience: an Agent's picks and a human's answers never mix.
  3. agents model, instructions, workspace access. "none" can read and pick, nothing else.
  4. states invoke an Agent: the state's 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.

Machines compose

bind it
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" },
  },
});
an open part: jr2 up refuses and prints this line
nest it
const coding = customize(task, {
  repos, agents,
});

jr2Setup({ actors: { triager, coding } })
  .createMachine({
    states: {
      building: {
        invoke: {
          src: "coding",
          input: ({ context }) => ({
            prompt: context.summary,
            branch: `feat/${context.slug}`,
          }),
        },
      },
    },
  });
one run: its Gate lists under the parent
fan it out
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 },
);
ends drained, deadlocked, or waiting on a Gate

Loop engineering? How about state machine engineering?

any model, chosen per Agent

jr2.config.tsreach
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,
}],
  1. per Agent agent({ model: "anthropic/claude-sonnet-4-6" }) the definition: who the Agent is
  2. per Turn* input: { prompt, model: "local/qwen3.6-35b-a3b" } a state turns the dial: a cheap model for a small step
  3. per run 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.

a run outlives its Orchestrator

every stepthe snapshot and the Turn ledger are saved
kill -9the Orchestrator dies mid-Turn
restoreread the snapshot, then check the world
what restore finds

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.

the Custodian holds every key

before
Harness the model key was here, where the Agent's code runs
Adapter
  • held the Orchestrator credential
  • served the Menu over MCP
what it unlocks

share a Machineits tools come along; your keys stay home

always-on agentsOpenClaw- or Hermes-style, keys out of reach

→
now
Harness only stand-ins: worthless outside the pod
Custodian
  • holds the Orchestrator credential
  • forwards the Menu read and the pick
  • holds external keys: model, tools
  • a proxy for outbound calls: a key goes only to its host
model providerGitHubSlackyour APIs

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.

memory, messaging, and more

memory, as an aspect
recalla scribe Agent finds what applies and adds it to the prompt
→
the taskan Agent does one job and knows nothing of memory but what it was given
→
recorda scribe Agent saves what is worth keeping
↑ read
↓ write
knowledge base

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.

Agents that write runtime Machines

a dynamic workflow
const plan = await agent("plan it");
for (const step of plan.steps) {
  const r = await agent(`do ${step}`);
  if (!r.ok) break;
}
a script: the flow is only visible when it runs
a Machine
planning: {
  invoke: { src: "planner" },
  on: { planned: "working" },
},
working: {
  invoke: { src: "coder" },
  on: { done: "review", blocked: "escalated" },
},
more structure: typed, drawn as a graph, durable

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.

questions?

try it

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