Let an Agent write a Chart
Write a Chart wrapper whose planner Agent writes a Chart and runs it.
This example is workflows/plan.ts. The planner reads the task, and it picks run_chart with a Chart. The Chart runs in the Open body body. It can use the Agent writer and the Gate, and it can add its own Agents with the model of the tab. When the door refuses the Chart, the planner gets the report as the answer to its pick, and it can pick again:
import { assign } from "xstate";
import { z } from "zod";
import {
agent,
chartSchemaFor,
defineEvent,
fail,
fromPick,
jr2Setup,
openBody,
type OpenBodyDeclaration,
type OpenBodyOutput,
} from "@jr2/orchestrator";The model of the tab:
const model = "anthropic/claude-haiku-5-5";The Open body and the pick:
// What a Chart can use. A Chart names these parts by their slot keys, and nothing else.
const declaration: OpenBodyDeclaration = {
// The Agent `writer`, and the Gate, so that a Chart can ask a person.
lends: { agents: ["writer"], gate: true },
// A Chart can add its own Agents, with this model and with no Sandbox.
allows: { models: [model], workspace: ["none"] },
// The limits of one run of a Chart.
ceilings: { turns: 12, wallClock: "30m", transitions: 500, fanOut: 2, size: 65536 },
};// The pick of the planner. Its parameter is a Chart, in the grammar of this Open body.
const runChart = defineEvent({
name: "run_chart",
description: "Run this Chart. Its outcome is the answer to this call.",
input: z.object({ chart: chartSchemaFor(declaration) }),
});The Machine:
export const machine = jr2Setup({
types: {} as { context: Context; input: Input },
events: [runChart],
actors: {
planner: agent({
model,
workspace: "none",
instructions: "You plan work as a Chart. You write the Chart, and you run it with run_chart.",
}),
writer: agent({
model,
workspace: "none",
instructions: "You write clear, short text for engineers.",
}),
body: openBody(declaration),
fail: fail(),
},
}).createMachine({
id: "plan",
input: z.object({ task: z.string() }),
context: ({ input }) => ({ task: input.task }),
on: { "agent.fault": ".failed" },
initial: "planning",
states: {
planning: {
invoke: {
src: "planner",
input: ({ context }) => ({
prompt: `Write a Chart for this task, and pick run_chart with it: ${context.task}`,
continue: true,
}),
},
initial: "ready",
states: {
ready: { on: { run_chart: "running" } },
running: {
// The Chart of the pick runs here. The task goes into the input of the Chart.
invoke: {
src: "body",
input: fromPick(runChart, ({ context }: { context: Context }) => ({ task: context.task })),
onDone: [
{
guard: ({ event }) => outcome(event.output) === "done",
target: "#plan.done",
actions: assign({ result: ({ event }) => event.output as OpenBodyOutput }),
},
// A refusal or a ceiling goes back to the planner, as the answer to its pick.
{ target: "ready", actions: assign({ result: ({ event }) => event.output as OpenBodyOutput }) },
],
},
},
},
},
done: { type: "final" },
failed: { invoke: { src: "fail", input: { reason: "the Agent's Turn could not go on" } } },
},
output: ({ context }) => context.result,
});Read the example
openBody(declaration)is the slot that the Chart binds. Its declaration lends the Agentwriterand the Gate, lets a Chart add Agents withmodeland no Sandbox, and sets the ceilings. Charts tells what each part does.- The pick
run_charthas one parameter,chart. Its schema ischartSchemaFor(declaration): the Chart grammar with the names of this Open body. The model gets it as the parameters of its tool. - The state
runningis below the state of the planner. Thus the pick is outstanding while the Chart runs, and the Turn of the planner stays open. fromPickgives the Chart of the pick to the Open body, and addstask. The Chart reads it as{{input.task}}.- The Open body ends with an
OpenBodyOutput.donegoes to the final state.refusedandceilinggo back toready, where jr2 answers the pick with the output. The planner reads the report, and it can pickrun_chartagain.
Run it
Run jr2 up. The planner and writer are Menu-only Agents, so jr2 up converges the Instance Harness. It also puts the key of each model in allows.models on the Instance Harness, because a Chart can add an Agent with that model. Then start a run:
jr2 run plan --input '{"task":"Write the release notes for the greet CLI, and ask a person to approve them."}'Open the run in the Console. After the planner picks run_chart, the Console draws the Chart inside running. The Chart of each run is different, because the planner writes it.
When the door refuses a Chart, the answer to the pick holds the report. The log of the Instance Harness shows each answer. Replace <namespace> with the namespace of your Instance, and <run id> with the full run id. The log holds the lines of each run of a Menu-only Agent, and the first grep keeps the lines of your run:
kubectl -n <namespace> logs statefulset/jr2-instance-harness -c harness | grep -F '[<run id>/' | grep -F '[run_chart answer]'For example, in one run with the model of each tab, the first Chart of the planner gave a prompt to the Gate. The door refused it:
[planner] [<run id>/root/planner] [run_chart answer] Delivered "run_chart" to the workflow (delivery ede62e99-7596-41fe-bda3-c6dcc194517f). The workflow took it and is back in the state that asked for this turn. Its answer: the output of `body`: {"outcome":"refused","report":["states.approve.invoke.input.prompt: a Gate's input takes gate and meta only; `gate` is the kit's Gate, which waits for an outside answer (a human, a webhook), not an Agent's Turn"]}. Your turn is not over. Allowed now: `mcp__jr2__run_chart`.The planner writes a different Chart in each run. Thus your report can be different, and the door can accept the first Chart. Your delivery id is different. After a refusal, the planner can pick run_chart again with a corrected Chart.
When the Chart gets to its Gate, the run waits. jr2 status <run id> gives the id of the Gate, such as 0.plan.planning.running.approve, and the events that it takes. Answer a Gate tells how to answer it. Then the Chart ends, and the run ends with the status done.
Rules
openBody()refuses a declaration that is not correct, with one line for each problem. The refusal starts withopenBody:, then each problem, separated by;. For example:lends.<kind>: an Open body lends agents, effects, machines, guards, actions, delays, gate and fail onlyallows.workspace: "<value>" is not a Workspace access (write, read, none)ceilings.<ceiling>: missing; an Open body states every ceilingceilings.<ceiling>: <value> is not a positive integerceilings.wallClock: <value> is not a positive duration (like "90s", "10m", "2h")ceilings.size: <value> is more than 1048576 bytes (1 MiB)
createMachinerefuses a lent name that the Machine does not have. The refusal starts withmachine "<id>": Open body "<slot>":, then each problem:lends.<kind>: "<name>" is no actor slot of this Machine; its actors: <slots>lends.<kind>: "<name>" is not an Agent slot (agent(...)), oris not an Effect slot, oris not a Machinelends.<kind>: "<name>" is no <guard, action or delay> of this Machine; its <kind>: <names>lends.<kind>: "<name>" is the Open body itself
- The door refuses a Chart, and gives one line for each problem. Each line starts with the path in the Chart. For example:
<path>: "<name>" is not a lent actor or an Agent of this Chart; lent: <names>; kit: <gate, fail or none>; minted: <names><path>: "<name>" is not a lent guard; lent guards: <names> (and jr2.sentBy with params { id }, and jr2.failure)<path>.type: "<name>" is not a generic or a lent action; generic: set, append, increment, clear; lent: <names><path>: <reference> is supplied by nothing: it is not in the initial `context`, and no `set`, `append` or `increment` writes it, then what a write supplies<path>: "<reference>" is not a reference: it starts at context, event or input (a reference is a path, never an expression)
- The door then compiles the Chart as the Open body does. A line of this last check starts with
(chart):, and names a state by its xstate id (for examplebody.writing), not by its path in the Chart. - A refusal is output, not an error: the Open body ends with
{ outcome: "refused", report }. A Chart wrapper that routesonDoneback to the state of the pick gives the report to the planner. - When the Turn of an Agent of the Chart cannot go on, and no transition of the Chart takes
agent.fault, the Open body sends the event, unchanged, to this wrapper. The wrapper's root route takes it, as it takes the fault ofplanner, so the run endsfailed. - A Chart that gets to a ceiling stops, and the Open body ends with
{ outcome: "ceiling", ceiling, detail }.
Read next
- Charts
- The Menu: how a pick waits for its answer.
- Run a Chart by name
- Reference:
openBody,OpenBodyDeclaration,chartSchemaFor,fromPick - ADR-0070: a Machine lends its parts to a Chart.