Skip to content

A model decides ​

At the end of this page, you have a Workflow, triage, in which a model makes a decision. An Agent reads a feature request and picks one of three events. When it asks a question, the run waits at a Gate until you answer. You answer one run from the CLI and one run from the Console.

Before you start ​

Do Connect a model first. The Instance my-instance must have the model of your tab.

You need the port forward of Create an Instance. Keep it open in its terminal. Then open a new terminal in my-instance for jr2 send. This page calls it the send terminal. Give the other commands in your first terminal, in my-instance.

Write the Workflow ​

Make the file workflows/triage.ts in my-instance. The name of the file is the name of the Workflow, so the Workflow is triage. Copy this code into the file:

ts
import { assign } from "xstate";
import { z } from "zod";
import { agent, defineEvent, jr2Setup } from "@jr2/orchestrator";

// The run input: what you give `jr2 run triage --input`. The Console makes its start form from it.
const door = z.object({
  request: z.string().describe("A feature request, in the requester's own words."),
});

// The Agent's three picks. Each one is a tool that the model can call.
const accept = defineEvent({
  name: "accept",
  description: "The request is clear enough to build: it names what to change, where, and how to tell it is done.",
  input: z.object({ summary: z.string().describe("The request restated as one actionable sentence.") }),
});
const needsInfo = defineEvent({
  name: "needs_info",
  description: "The request could be built more than one way, or its outcome cannot be checked. Ask one question.",
  input: z.object({ question: z.string().describe("The one question whose answer would make it buildable.") }),
});
const reject = defineEvent({
  name: "reject",
  description: "The request is not a change to software, or it asks for something harmful.",
  input: z.object({ reason: z.string().describe("Why, in one sentence.") }),
});
// Your one reply. Only a Gate takes it, so the Agent can never send it.
const answer = defineEvent({
  name: "answer",
  description: "Answer the triager's question. It triages the request again with the answer in hand.",
  input: z.object({ info: z.string().describe("The answer, in prose.") }),
});

type Ctx = z.infer<typeof door> & {
  // Each question that the Agent asked, and the answer that it got.
  exchange: { question: string; info?: string }[];
  // Why the last Turn ended with no pick, when it did.
  fault?: string;
  summary?: string;
  reason?: string;
};
// The output of the run: the decision. The job of a triage is the decision itself, so "rejected" is a
// correct answer: a final state, with the reason in the output, and not a Failure.
type Output = { outcome: "accepted"; summary: string } | { outcome: "rejected"; reason: string };

export const machine = jr2Setup({
  types: {} as { context: Ctx; input: z.infer<typeof door>; output: Output },
  events: [accept, needsInfo, reject, answer],
  actors: {
    triager: agent({
      // The provider, then the model. jr2.config.ts holds the key for the provider.
      model: "anthropic/claude-haiku-5-5",
      description: "Reads one feature request and decides whether it can be built as written.",
      // No Sandbox and no Working tools: the Agent can only read its prompt and pick.
      workspace: "none",
      thinkingLevel: "off",
      instructions: `You triage feature requests for a small software team. You read one request and
decide, by calling exactly one tool:

- accept — it names WHAT to change, WHERE (which command, screen, or file), and how to tell it
  is done.
- needs_info — any of those is missing. Ask the ONE question that would settle it. Never guess
  what the requester meant: if you would have to assume, ask.
- reject — it is not a change to software, or it asks for something harmful.

You have no tools but these three, and you MUST end your turn by calling one of them.`,
    }),
  },
}).createMachine({
  id: "triage",
  input: door,
  context: ({ input }) => ({ request: input.request, exchange: [] }),
  initial: "triaging",
  states: {
    // The Agent's Menu is the events that this state takes: accept, needs_info and reject.
    triaging: {
      invoke: { src: "triager", input: ({ context }) => ({ prompt: prompt(context) }) },
      on: {
        accept: { target: "accepted", actions: assign({ summary: ({ event }) => event.summary }) },
        reject: { target: "rejected", actions: assign({ reason: ({ event }) => event.reason }) },
        needs_info: {
          target: "clarify",
          actions: assign({
            exchange: ({ context, event }) => [...context.exchange, { question: event.question }],
            fault: undefined,
          }),
        },
        // The Turn failed, with no pick. Wait at the Gate, so that your answer starts a new Turn.
        "agent.fault": { target: "clarify", actions: assign({ fault: ({ event }) => event.reason }) },
      },
    },
    // The Gate. Its name is the name of this state, and it takes the events of this state: answer.
    clarify: {
      invoke: {
        src: "gate",
        input: ({ context }) => ({
          meta:
            context.fault !== undefined ? { fault: context.fault } : { question: context.exchange.at(-1)!.question },
        }),
      },
      on: {
        answer: {
          target: "triaging",
          actions: assign({
            exchange: ({ context, event }) =>
              context.fault !== undefined
                ? context.exchange
                : [...context.exchange.slice(0, -1), { ...context.exchange.at(-1)!, info: event.info }],
            fault: undefined,
          }),
        },
      },
    },
    accepted: { type: "final", output: ({ context }): Output => ({ outcome: "accepted", summary: context.summary! }) },
    rejected: { type: "final", output: ({ context }): Output => ({ outcome: "rejected", reason: context.reason! }) },
  },
  // xstate reads only this root `output`. It gets the output of the final state as `event.output`.
  output: ({ event }) => event.output as Output,
});

// The prompt of each Turn: the request, then each question and its answer.
function prompt(context: Ctx): string {
  const answered = context.exchange
    .filter((e) => e.info !== undefined)
    .map((e) => `\n\nYou asked: ${e.question}\nThe requester answered: ${e.info}`)
    .join("");
  return `Feature request:\n${context.request}${answered}`;
}

The two tabs are different only in the model line and its comment. The file has five parts:

  • The run input. door is a zod schema. A run of triage starts with one request.
  • The Vocabulary. Each defineEvent() makes one event of the Vocabulary. Each transition belongs to the party that its state invokes. triaging invokes the Agent, so only the Agent can send accept, needs_info and reject. clarify invokes a Gate, so only the Gate can take answer.
  • The Agent. agent() makes the Agent triager, with its model and its instructions. workspace: "none" gives it no Sandbox and no Working tools. It can only read its prompt and pick an event. thinkingLevel: "off" makes the model pick with no thinking step. Agents and Turns explains the other fields.
  • The Machine. jr2Setup() takes the Vocabulary and the Agent. Then createMachine gives the states of the Machine.
  • The prompt. prompt() makes the text of each Turn from the request and each question and answer.

The state triaging invokes triager. Its transitions take accept, needs_info and reject, so these three events are the Menu of the Agent. The model sees each event as a tool, with its description and its input schema. The Agent ends its Turn when it calls one of them. The fourth transition, agent.fault, is not on the Menu. jr2 sends it when a Turn fails with no pick. Then the run waits at the Gate, and the Gate shows a fault in place of a question.

The state clarify invokes gate, which jr2 supplies. Its meta holds the question of the Agent, and its one transition takes answer. The Gate has the name of its state: clarify. When you send answer, the run goes back to triaging. The next Turn gets the request, the question, and your answer in its prompt.

Converge the Instance ​

jr2 up publishes the Workflows to the Orchestrator. Thus you run it each time that you change a Workflow. In the first terminal, run:

sh
jr2 up

jr2 up prints who can send each event, in each state of triage. Then it converges the cluster.

The output shows where the model key goes. Then jr2 up asks your model for one tool call, from inside the cluster:

text
→ .env: ANTHROPIC_API_KEY
jr2 up — instance "my-instance" → context kind-jr2 / namespace my-instance
typecheck: tsc --noEmit (the instance's own compiler)
seats: workflow "triage" — who may send what, per state (ADR-0015)
  triage.triaging agent:triager: [accept, needs_info, reject]
  triage.clarify gate: [answer]
agents: triager (carried by 2 workflow machine(s))
…
secret ANTHROPIC_API_KEY: Held in the Instance Harness
the Instance Harness: Custodian holds ANTHROPIC_API_KEY (provider)
held secret ANTHROPIC_API_KEY → api.anthropic.com (literal)
held secrets: minted the Instance's CA into jr2-held-ca
provider: checking anthropic/claude-haiku-5-5 on the Instance Harness, through a Custodian that holds ANTHROPIC_API_KEY for api.anthropic.com
provider: the check pod has up to 5 min to start, then up to 6 min for its model
…
provider: anthropic/claude-haiku-5-5 reachable, and it completed a tool call
…
instance harness: converging (triager declares workspace: "none")
instance harness: verified — 1 pod(s) running ghcr.io/snapwich/jr2-harness:<version>
…
converged — `jr2 run <workflow>` when ready

The warning of Connect a model, secrets[0] (ANTHROPIC_API_KEY) is named by nothing; goes nowhere, is gone. triager names an Anthropic model, so jr2 up gives the key to the Custodian of the pod that runs its Turns. Then it checks the model through a Custodian that holds the key. If the check fails, jr2 up stops and prints what to examine.

The Orchestrator loads the new Workflows with no restart, so the port forward stays open.

Start the first run ​

In the first terminal, run:

sh
jr2 run triage --input '{"request":"People keep asking what version they are running."}'

jr2 run starts the run and stays attached to it. The Agent reads the request. The request does not say where to show the version, so the Agent normally asks a question. The run goes to clarify and waits there:

text
…
→ context kind-jr2 / namespace my-instance
run 8fecb896-2100-4ee6-88d6-788eaf0d7244 (triage) — attached
  → active "triaging"
  → active "triaging"
  → active "clarify"

The output of each jr2 command starts with the line → .env:, with the keys that the CLI took from .env. From this step on, the output on this page shows … in place of that line. Your run id is different. The line → active "triaging" can show more than one time. jr2 run prints a line for each status that the run sends. Two lines can show the same state. jr2 run does not return while the run waits. Keep it open.

Note

The model decides, so your run can be different. If the Agent picks accept or reject, the run ends with no Gate. A request that says what to change and how to tell that it is done is clear, and the Agent accepts it immediately. For example, this run started with the request Add a --version flag to the CLI. It prints the installed version and exits.:

text
…
→ context kind-jr2 / namespace my-instance
run 89cc6ea5-9e2d-4f8a-8d02-18a6e47f2a5c (triage) — attached
  → active "triaging"
  → done "accepted"
{"runId":"89cc6ea5-9e2d-4f8a-8d02-18a6e47f2a5c","workflow":"triage","instanceId":"fac049b9-9dd2-4ba5-aca8-0b3ebe619701","status":"done","value":"accepted","context":{"request":"Add a --version flag to the CLI. It prints the installed version and exits.","exchange":[],"summary":"Add a --version flag to the CLI that prints the installed version and exits."},"children":[],"waiting":[],"held":[],"assembly":"3eb6cd4d61bf","output":{"outcome":"accepted","summary":"Add a --version flag to the CLI that prints the installed version and exits."}}

Your run id and your Instance ID are different, and so is the summary, the text of the model. If your run with the first request ends with no Gate, start it again until it waits at clarify.

Read the question ​

The Gate holds the question of the Agent in its meta. In the send terminal, run:

sh
jr2 status <run id>

<run id> is the run id that jr2 run printed. You can give its first 4 characters or more, such as 8fecb896. jr2 status prints the status of the run as one line of JSON. The Gate is in gates, at the end:

text
…
→ context kind-jr2 / namespace my-instance
{"runId":"8fecb896-2100-4ee6-88d6-788eaf0d7244","workflow":"triage","instanceId":"a814d943-9b27-410c-a790-2c4225aa05ba","status":"active","value":"clarify","context":{"request":"People keep asking what version they are running.","exchange":[{"question":"Where specifically should the version number be displayed (e.g., CLI flag, About screen, or help output), and what exact user action should trigger it?"}]},"children":[],"waiting":[],"held":[],"assembly":"3eb6cd4d61bf","gates":[{"gate":"clarify","path":["clarify"],"accepts":[{"name":"answer","description":"Answer the triager's question. It triages the request again with the answer in hand.","input":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"info":{"type":"string","description":"The answer, in prose."}},"required":["info"],"additionalProperties":false}}],"acceptsNow":["answer"],"meta":{"question":"Where specifically should the version number be displayed (e.g., CLI flag, About screen, or help output), and what exact user action should trigger it?"}}]}

The question is in the words of the model, so your question is different. Your instance id is different too. The Gate clarify takes answer, with an info string.

Answer from the CLI ​

In the send terminal, run:

sh
jr2 send <run id> --gate clarify --event answer --input '{"info":"Add a --version flag to the CLI. It prints the installed version and exits."}'

jr2 send gives the event to the Gate:

text
…
→ context kind-jr2 / namespace my-instance
delivered answer to gate "clarify" on 8fecb896-2100-4ee6-88d6-788eaf0d7244

The run goes back to triaging, and the Agent reads your answer. Look at the first terminal. When the Agent picks accept, the run ends, and jr2 run prints the last status:

text
…
  → active "triaging"
  → active "triaging"
  → done "accepted"
{"runId":"8fecb896-2100-4ee6-88d6-788eaf0d7244","workflow":"triage","instanceId":"a814d943-9b27-410c-a790-2c4225aa05ba","status":"done","value":"accepted","context":{"request":"People keep asking what version they are running.","exchange":[{"question":"Where specifically should the version number be displayed (e.g., CLI flag, About screen, or help output), and what exact user action should trigger it?","info":"Add a --version flag to the CLI. It prints the installed version and exits."}],"summary":"Add a --version flag to the CLI that prints the installed version and exits."},"children":[],"waiting":[],"held":[],"assembly":"3eb6cd4d61bf","output":{"outcome":"accepted","summary":"Add a --version flag to the CLI that prints the installed version and exits."}}

summary is the request as the model wrote it again, in one sentence. Your summary is different. The output of the run holds the decision. The job of a triage is the decision, so a reject is a correct answer too: the run ends done, with "outcome":"rejected" and the reason in its output. If the Agent asks a second question, the run goes to clarify again. Read the question, and answer it in the same way.

Start the second run ​

In the first terminal, run:

sh
jr2 run triage --input '{"request":"The greeting looks wrong sometimes."}'

This request does not say what is wrong. The run goes to clarify and waits, as the first run did:

text
…
→ context kind-jr2 / namespace my-instance
run 84079cb2-3cdd-4b9a-ae39-51ab873c5f3c (triage) — attached
  → active "triaging"
  → active "clarify"

84079cb2-3cdd-4b9a-ae39-51ab873c5f3c is the id of the second run. Your run id is different. If the Agent picks accept or reject, start the run again.

Answer from the Console ​

Open http://localhost:4000 in your browser. If the badge after the instance token box shows live, go to the next step. If the box is empty, get the token. In the send terminal, run:

sh
kubectl -n my-instance get secret jr2-instance -o jsonpath='{.data.JR2_INSTANCE_TOKEN}' | base64 -d

Type the token in the instance token box. Push Enter. The badge after the box shows live.

Click triage in the Fleet rail on the left. The center shows the Machine of triage, with its states and transitions. Click the run in the rail. The Console lights the state of the run, clarify.

Click the Attention tab in the drawer on the right. It has one card for the Gate:

  • The card starts with ⚑ clarify, then triage and the first 8 characters of the run id.
  • Under question, it shows the question of the Agent.
  • Under answer, it has a form with the field info *. The * shows that the field is necessary.

Type this text in the info field:

text
Spaces around the name show in the greeting. Trim the name.

Click send. The card disappears, and the run goes back to triaging. If the Agent asks one more question, a new card shows it. Answer it in your own words, in the same way. When the Agent picks accept, the run ends. The jr2 run in the first terminal prints the last status.

What happened ​

You wrote a Machine with two waiting states. In triaging, a model chose the next state. In clarify, a person sent the event that moved it. The Machine gave all of the transitions. The model chose only among the events of its state. Agents and Turns explains the Agent and its Turn.

The Menu of triager came from the transitions of triaging. You did not give the Agent a list of picks. A pick that the state does not take moves nothing. The Menu explains what the Agent can pick, and ADR-0015 gives the reasons.

The Gate clarify was open only while the run was in its state. It took only answer. jr2 send and the Console sent the same event to a Gate with the same name, through the same route. Gates explains who can answer a Gate. Answer a Gate gives the other ways to answer one.

triager has workspace: "none", so its Turns ran on the Instance Harness. No Sandbox started for the run. Architecture shows what runs where.

Next ​

An Agent in a Sandbox.

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