Skip to content

ADR-0009 Architecture decision record

The jr2 CLI and instance interface ​

ADR-0008 fixed that jr2 is a library + CLI and that an Orchestrator instance is a user-owned folder hosting many workflows. This ADR fixes the instance folder convention, the orchestrator HTTP API, and the jr2 CLI — one set of conventions both sides of the system speak. Operational mechanics (how jr2 up converges a cluster, addressing, secrets) live in ADR-0019.

Instance folder convention (filename discovery) ​

my-orchestrator/
  jr2.config.ts     # instance config: reach and credentials — harness, git.credentials, registry; nothing a Machine names (ADR-0050)
  workflows/       # filename-discovered: workflows/review.ts (contract: export const machine) → "review"
  images/default/  # the fallback docker context a `workspace()` with no `image` uses (ADR-0037); a path convention, not discovery
  manifests/       # user-supplied objects applied by `jr2 up` (e.g. SealedSecrets); optional
  .env             # local secrets + deployment-varying env; uncommitted
  .jr2/             # scratch; nothing durable lives on the host (state is in-cluster, ADR-0019)

workflows/<name>.ts registers a workflow named <name> via export const machine — the one-export module contract (ADR-0015; vocabulary rides the machine object, so there is no manifest export). Workflows are the one discovered kind because nothing in code names one: jr2 run and the API do. Agents, images, and Repos are named from Machines, so they ride the Machine — Agent slots, workspace() options, Repo Slots (ADR-0049, ADR-0051) — and nothing in config names them (ADR-0050).

The instance repo is a deployment assembly, not a sharing unit (ADR-0019): reusable workflows/agents are published as npm packages and re-exported here; jr2.config.ts holds only what is specific to this deployment's reach, credentials, and cluster — committed, because the instance repo is the GitOps unit (ADR-0008), with deployment-varying values resolved from env.

Minimum config, and the Repo cache ​

jr2.config.ts favors convention over configuration — an Instance whose Machines compose Sandboxes can be as small as:

ts
import { defineConfig } from "@jr2/orchestrator";

export default defineConfig({
  name: "my-orchestrator",
  git: { credentials: [{ match: "*", token: "JR2_GIT_TOKEN", sshKey: "jr2-git-ssh" }] }, // narrow before untrusted runs
});
  • name is the instance's identity; its namespace defaults to it (-n overrides — ADR-0019).
  • There is no repos list. A Repo is a slot on a workspace(), bound by url (ADR-0051); jr2.config.ts holds what reaches one — git.credentials, the prefix-matched list that is also the fence a per-run slot's url must pass. The scaffold writes one wildcard entry, commented, for the user to narrow (ADR-0051). The operator keeps a bare cache per node from the Repo resources the Orchestrator creates off its Machines (ADR-0004); Sandboxes clone --shared against it. There is no host-side directory — a Repo pods should see must be fetchable from the cluster.
  • A registered Machine that composes a Sandbox is the data-plane switch (ADR-0051), read off jr2 up's walk: with one, the instance gets the kubectl Sandbox backend; without one, the instance is workspace-less. There is no images config block: jr2 up builds every image it deploys and resolves each to a content-addressed tag (ADR-0038). Image composition is per Workspace, not per instance — a workspace() names its Sandbox Image statically (a file: docker context the Machine ships or a registry ref, ADR-0037, ADR-0049) and opts into a User Container the same way (ADR-0005); images/default is the one path convention jr2 up still checks. Every Harness pod gets a Custodian — an Agent without one cannot act (ADR-0013, ADR-0059) — and that is not configurable. Agent-runtime concerns live in harness (ADR-0018).
  • harness is the agent-runtime section (ADR-0018): custom provider (api, baseUrl), the gateways pi's catalog providers call (catalog) and any other env — never which model to use; each definition names its own (ADR-0018).
  • secrets is the Instance's one list of secrets (ADR-0068): the credentials the Agents use — an Anthropic key, read from process.env/.env, held by the Custodian and never by the Harness (ADR-0059) — and the ones an Effect names, which only its Effector holds. Where each goes is read, never declared.
  • registry (deployment-varying, resolve from env): absent → images are kind load-ed; present → pushed (ADR-0019). kitRegistry (also env) re-homes the published Kit image refs for self-hosted, air-gapped, or mirror-only clusters (ADR-0044).
  • The snapshot store is sqlite on a PVC in the instance's namespace (zero setup); no other store is owed (ADR-0065) — the single-table, single-writer snapshot fits sqlite, and replicas: 1 keeps it single-writer.
  • Git credentials for private repos: an HTTPS token from .env, or a jr2-git-ssh Secret holding the key jr2 up asked the user to choose — a generated in-cluster deploy keypair (the recommended default), a local key, or one pasted on stdin (ADR-0019, ADR-0047). Kube target: the current kubectl context (--context to override).

Machines ship as npm; instances compose or customize ​

The kit publishes to npm under @jr2/*; stock Agent definitions ship in @jr2/agents (coder, reviewer, …), and a package may ship whole Machines with their Agents and image inside (ADR-0049). Because a definition is plain data (ADR-0018), composition needs no API: a Machine carries agent(coder), a workflows file exports a packaged Machine as-is or through customize(machine, { agents: { coder: { model: "…" } } }). Adding tools/skills waits on the definition contract growing that seat (ADR-0018).

Orchestrator HTTP API (hono, run-addressed-by-id) ​

Push + control + observe only — the pull path needs no HTTP (the Orchestrator's Source pulls, ADR-0017). Auth bands per ADR-0014: structure + observation open; the Agent surface takes the Sandbox token; run state, control, and gates take the Instance token.

GET  /workflows                        # list registered workflows                       [open]
GET  /workflows/:name/machine  /viz/*  # machine structure + visualizer                  [open]
GET  /workflows/:name/runs[, /:runId/events]  # observation projection (ADR-0014)        [open]
GET  /workflows/:name/events           # SSE: the whole workflow, level-triggered (ADR-0022) [open]
POST /workflows/:name/runs             # start a run (push work); body = input → { runId } [instance]
GET  /runs   GET /runs/:runId          # live list; durable run status (read-through)    [instance]
GET  /runs/resolve?prefix=<p>          # run ids sharing a prefix, live + settled        [instance]
GET  /runs/:runId/events               # SSE: status replay + live deltas                [instance]
POST /runs/:runId/events               # run-level infra interrupt: CANCEL               [instance]
POST /runs/:runId/gates/:gate/events   # deliver a workflow event to an open gate        [instance]
GET  /repos                            # per-Repo node-cache sync state (ADR-0048)        [instance]
GET  /agents/:iid/surface   POST /agents/:iid/events   # the Menu, via the Custodian    [sandbox]
POST /sandboxes/:name/fetch                            # the ask (ADR-0053)             [sandbox]
GET  /healthz   GET /readyz

GET /repos is ADR-0048's observability half: a failed clone degrades the Repo, not the boot, so each Repo resource's status (per node: synced, or git's own error) is a thing to ask the instance for, read off the resources (ADR-0051). jr2 status with no run id renders it — the place ADR-0047's "register the key, the cache agent retries" points at. Instance band, not open: a row names a Repo and carries git's error text, exactly the class of author/deployment detail the open projections strip (ADR-0014).

Gates are the human/webhook/CI seam (ADR-0011): a gated state registers { gate, accepts, meta }; GET /runs/:runId lists the open gates (with schemas + meta), and the gate POST validates the body against the named event schema and delivers it into that state. Per-gate addressing exists because concurrent children park concurrently — a run-level events POST is ambiguous. CANCEL is the one reserved run-level event; everything else is workflow vocabulary. It ends the run — its Agents' turns end with it and it does not restore (ADR-0025).

The jr2 CLI ​

# lifecycle (instance)
jr2 init [dir] [--name <n>]        # scaffold the minimum runnable instance
jr2 check                          # the typecheck and the Machine walk of `up`, with no cluster (ADR-0050)
jr2 up [--yes] [--force] [--skip-provider-preflight]
                                   # converge the current context to this instance (ADR-0019)
jr2 down [--all] [--yes]           # remove the instance from the cluster (--all: operator too)
jr2 gc [--dry-run]                 # sweep unreachable labeled images — the same sweep up/down run (ADR-0039)

# kit (instance-less)
jr2 kit push <registry>            # mirror the published Kit images into a self-hosted registry (ADR-0044)

# what runs where
jr2 version [--local] [--json]     # this jr2, the global that handed off, the Instance's Kit, and what the cluster runs

# runs / workflows (wrap the HTTP API)
jr2 run <workflow> [--input <json>] [--detach]
jr2 runs   jr2 status [runId|abbrev]   jr2 logs <runId|abbrev> [-f]   # bare `status` = the instance's repos (ADR-0048)
jr2 send <runId|abbrev> --event CANCEL
jr2 send <runId|abbrev> --gate <gate> --event <name> [--input <json>]

# workspaces (kubectl-style, over the operator's Sandbox CRs) — decided, not yet in the binary
jr2 ls                             # list workspaces + run + status + endpoint
jr2 ssh <workspace>                # exec into the Sandbox's harness container (ADR-0037)
jr2 logs <workspace>   jr2 rm <workspace>

jr2 ssh is kubectl exec -c harness into the Sandbox Image (ADR-0037) — the inspect seat: the agent's own tools, worktrees, and filesystem. The optional User Container (ADR-0005) is reached by its own front door (its sshd, or kubectl exec -c user), not by this verb — its point is sessions and services the agent's container must not host.

The CLI is the everyday surface; HTTP is the machine-to-machine one. The workspace verbs make the orchestrator feel like kubectl for agents: the run↔workspace link rides on the CR's labels (jr2.dev/run, jr2.dev/workflow), so jr2 ls can group without the orchestrator being reachable. CLI and hono app sit on one shared API client.

A planned jr2 build (build + push + render manifests, no apply — the pure-GitOps CI verb) is deferred; jr2 up in CI covers the interim (ADR-0019).

Settled CLI behavior (v1) ​

Instance addressing. The CLI finds its instance by walking up from cwd to the directory containing jr2.config.ts — the root marker. The deployment is addressed by the current kube context + the instance's namespace: run-verbs port-forward the Orchestrator Service for the duration of the command and read the Instance token from its in-cluster Secret (kube RBAC is the gate). --url / JR2_URL (+ token env) overrides both — the ingress-exposed/remote-caller case — and skips the folder walk entirely. Every run-verb prints the context it targets on stderr, so ambient-context drift is visible (ADR-0019).

The command table. Each verb is an entry in packages/cli/src/command-table.ts: its arguments, its flags, the env vars it reads, what it prints, and its ADRs, as data. The verb's parseArgs options come from its entry, and so does the help, so a flag cannot be accepted and not documented, or documented and not read. jr2 --help prints the whole table. jr2 <verb> --help or -h prints that verb's entry, runs nothing, and exits 0. A usage error prints the verb's usage line. The CLI Reference of the docs is generated from the same table (ADR-0072).

Two output classes (amended 2026-09-20). The stream rule below — JSON result on stdout, activity on stderr — was written for jr2 run, whose reason is the pipe: jr2 run … | jq must yield the result alone. It generalises to every result verb — run, runs, status <runId>, logs, kit push — because their result is small and the stderr lines beside it are advisory. It does not generalise to a report verb — version, and status with no run id — whose output is a diagnosis a human reads whole: dual-printing there shows a JSON blob and a partial table, and the readable form exists nowhere. So a report verb prints its human table on stdout, and --json swaps the table for the one object. No isatty sniffing: output that changes with how it is invoked is a debugging trap of its own. init, check, up, down, gc and send are neither class — activity only, no result.

jr2 version (amended 2026-09-20). The report you paste into a bug: what is here and what is deployed, side by side, so the gap between the halves is the answer ("you edited the pin and never ran jr2 up"). The local half: cli (the copy that runs — version + real path, so the path says global or Instance), global (the copy that handed off, ADR-0056), kit (the @jr2/orchestrator the Instance resolves + the ADR-0056 check result, and package.json pins X, reinstall when the manifest disagrees with what is installed), instance (name, root, and mode: kit checkout or installed from which registry — the reader needs the mode to know whether deployed Kit tags are versions or content hashes), node. The deployed half, best-effort: orchestrator (what the pod answers on /healthz — version + hash, flagging a Deployment label that disagrees as an incomplete rollout), operator (its version label — never downgraded, so no skew verdict on it), harness/custodian and any Sandbox Image refs from the jr2-images ConfigMap pods actually read, and one skew verdict comparing the two kit numbers: same/behind/ahead/unknown, with the jr2 up consequence spelled out and, in checkout mode, a reminder that same is weak and the hash is the address. Nothing is computed that jr2 up computes: no bundle staging, no local hash. It is the one Instance verb that reads the Instance without asserting it — a Kit mismatch is a line, never the ADR-0056 refusal, because this is the verb you reach for when that refusal fires. It hands off like every verb. The shared addressing flags (--url, -n, --context) as every run verb; --url leaves only the orchestrator line; --local skips the cluster. Exit 0 whatever it finds: a report. No registry lookup: "out of date" here means behind the Instance, and npm view answers the other question.

jr2 check. It runs the Instance check of jr2 up and nothing more. First it runs the Instance's own tsc --noEmit. Then it loads each registered Workflow and walks its Machine. The load and the walk find the refusals that the compiler cannot make: a refusal of createMachine, defineEvent or workspace() when a module loads, an Open part, a Chart file, a dispatch, an Effect secret, a Size. It reads no kube context. It touches no cluster, docker or registry, and it writes no file. When it refuses, it exits 1 with the refusing: or error: line of jr2 up. It is one function with jr2 up (checkInstance), not a copy, so the two cannot disagree.

It is for an Assistant that may not run jr2 up. npm run typecheck loads no module. And node cannot import a workflow module of an installed Instance. The kit's .ts sources are under node_modules, and Node does not strip types there. The jr2 binary strips them. The scaffold names jr2 check as its check script. The AGENTS.md block names it as the step before the Assistant gives a change to the user (ADR-0072).

It does not stage the Assembly. Staging installs the production dependencies from the lockfile, and its refusals are about that install. It also does not write the AGENTS.md block. An Assistant or CI must be able to run a check with no diff as a result.

jr2 run — blocking, attach-by-default. Start the run, stream activity to stderr, print the terminal RunStatus as JSON to stdout, exit — so jr2 run … | jq yields just the result. It attaches to the running orchestrator, not a temporary per-invocation runtime — a jr2 run is durable and may park on a Gate indefinitely, outliving the CLI call. --detach starts the run, prints its runId, returns.

Attach / detach / re-attach. A run lives server-side, so attaching = opening GET /runs/:runId/events and detaching = closing it (Ctrl-C); neither affects the run. jr2 logs <runId> -f re-attaches to any running run. On attach, the SSE replays the run's current status immediately (so a parked run shows where it is) before streaming live deltas; it carries status events (auto, per transition) and emit events (the workflow author's emit({…})).

Terminal runs stay readable. The final snapshot persists to the store before the run drops from the live registry, so status / GET /runs/:runId read through to the store — a completed run reports its terminal status/context instead of 404. (GET /runs stays live-only; history via ?all is deferred.)

Run ids abbreviate to a unique prefix. A run id is a bare uuid, so every id-taking verb takes a git-style short form: jr2 status 1a2b3c4d. Prefix only — not fuzzy, not suffix — which keeps it an indexed range scan and keeps the mental model borrowed intact. Four characters is the floor, and that floor is about noise, not safety: a short prefix never resolves to the wrong run, it resolves to a list. Safety comes from ambiguity being an error — matching more than one id prints the candidates and fails (exit 1), never guesses. A malformed or too-short argument is a usage failure (exit 2); a full id short-circuits resolution entirely, so scripted pipelines issue unchanged traffic.

The CLI resolves; the addressed routes stay full-id. Abbreviation is a human affordance and lives on the human surface — GET /runs/resolve answers prefix→ids, and the CLI then addresses the run by its full id. /runs/:runId and both event POSTs never accept a prefix, because a prefix is not an identity: one that resolves today goes ambiguous tomorrow when an unrelated run starts, and jr2 send <prefix> --event CANCEL is a write. Resolving as a separate read-only step is what guarantees no write is ever prefix-sensitive. (It also gives a bad id a real error: host.stop() returns silently for an unknown run, so an unresolved CANCEL used to report success.)

The candidate set is the ids that exist, not the ones that read: lost runs are included, because dropping them would let a prefix they share with a live run resolve silently to the live one. The set unions the live registry with the store — neither alone is complete, since persist() is microtask-scheduled (a just-started run is live before it is stored) and a settled run is stored but not live. Note the deliberate tension with GET /runs's deferred ?all: this route does reveal that settled run ids exist to an Instance-token holder. It answers ids only — no status, no context, no gates — to keep that widening as small as the feature allows, and it is Instance-only rather than open, since prefix probing on an open route would be a run-id enumeration oracle.

jr2 init (v1). Scaffolds the minimum runnable instance: jr2.config.ts (root marker), package.json (deps on @jr2/* + xstate), one starter workflows/<name>.ts, images/default/Dockerfile (the Sandbox Image every Workspace falls back to, ADR-0037), .gitignore (.jr2/, .env, node_modules/), and AGENTS.md with a CLAUDE.md that imports it (the index of the Kit's bundled docs for an Assistant, ADR-0072). [dir] positional (default cwd); --name for the package name (default: the folder's name). It skips each file that exists, and says so; for an AGENTS.md with no markers, or a CLAUDE.md that does not import AGENTS.md, it also says what to add. manifests/ and .env are added by their later slices. No auto-install — it prints the next step, naming no package manager, since the instance's lockfile is what picks one (ADR-0043).

Consequences ​

  • The instance-facing packages (@jr2/cli, @jr2/orchestrator, @jr2/agent-protocol) publish to npm; @jr2/harness ships inside a Kit image, never via npm (ADR-0043). The CLI ships as the jr2 bin (npx jr2).
  • Workspaces are a first-class CLI resource backed by the operator's Sandbox CRs, label-linked to their runs.
  • Code is never loaded from a third party at run time (ADR-0008, ADR-0069): discovery is over the instance's own Assembly, which only jr2 up publishes. A run may bind a Machine's Open body with a Chart, which is data (ADR-0070).

The docs of Kit version 0.5.1. Each page is also at its .md URL.