Skip to content

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:

ts
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:

ts
const model = "anthropic/claude-haiku-5-5";

The Open body and the pick:

ts
// 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 },
};
ts
// 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:

ts
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 Agent writer and the Gate, lets a Chart add Agents with model and no Sandbox, and sets the ceilings. Charts tells what each part does.
  • The pick run_chart has one parameter, chart. Its schema is chartSchemaFor(declaration): the Chart grammar with the names of this Open body. The model gets it as the parameters of its tool.
  • The state running is below the state of the planner. Thus the pick is outstanding while the Chart runs, and the Turn of the planner stays open.
  • fromPick gives the Chart of the pick to the Open body, and adds task. The Chart reads it as {{input.task}}.
  • The Open body ends with an OpenBodyOutput. done goes to the final state. refused and ceiling go back to ready, where jr2 answers the pick with the output. The planner reads the report, and it can pick run_chart again.

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:

sh
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:

sh
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:

text
[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 with openBody: , then each problem, separated by ; . For example:
    • lends.<kind>: an Open body lends agents, effects, machines, guards, actions, delays, gate and fail only
    • allows.workspace: "<value>" is not a Workspace access (write, read, none)
    • ceilings.<ceiling>: missing; an Open body states every ceiling
    • ceilings.<ceiling>: <value> is not a positive integer
    • ceilings.wallClock: <value> is not a positive duration (like "90s", "10m", "2h")
    • ceilings.size: <value> is more than 1048576 bytes (1 MiB)
  • createMachine refuses a lent name that the Machine does not have. The refusal starts with machine "<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(...)), or is not an Effect slot, or is not a Machine
    • lends.<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 example body.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 routes onDone back 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 of planner, so the run ends failed.
  • A Chart that gets to a ceiling stops, and the Open body ends with { outcome: "ceiling", ceiling, detail }.

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