Skip to content

Set an Agent's access and Dials ​

Set what an Agent can do to the Workspace, and set the Dials and continue of one Turn.

The definition of the Agent sets its access, in the workspace field of agent(). The state that invokes the Agent sets the Dials and continue, in the input of the invoke. In this Workflow, a reviewer reads one commit and asks for changes. A coder makes the changes on the branch. Then a Menu-only checker decides if the changes are complete. Save the file as workflows/review.ts in your Instance:

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

const approve = defineEvent({
  name: "approve",
  description: "Call this when the change is complete and correct.",
  input: z.object({}),
});
const requestChanges = defineEvent({
  name: "request_changes",
  description: "Call this when the change is not complete or not correct. Say what to change.",
  input: z.object({ notes: z.string() }),
});
const finished = defineEvent({
  name: "finished",
  description: "Call this when you committed the changes. Say what you changed.",
  input: z.object({ summary: z.string() }),
});

// The run input: the branch, the commit to review, and an optional model for the coder's Turns.
const runInput = z.object({ branch: z.string(), sha: z.string(), model: z.string().optional() });
type BodyInput = Workspaced<z.infer<typeof runInput>, "app">;
type Context = BodyInput & { notes: string; summary: string };

const body = jr2Setup({
  types: {} as { input: BodyInput; context: Context },
  events: [approve, requestChanges, finished],
  actors: {
    // "read": no write tool and no edit tool. It keeps bash.
    reviewer: agent({
      model: "anthropic/claude-haiku-5-5",
      instructions: "You review one commit. You do not change files.",
      workspace: "read",
    }),
    // No `workspace` field: "write", all the Working tools.
    coder: agent({
      model: "anthropic/claude-haiku-5-5",
      instructions: "You change code as the review asks, and you commit each change.",
    }),
    // "none": a Menu-only Agent. It has no Working tools, and it runs on the Instance Harness.
    checker: agent({
      model: "anthropic/claude-haiku-5-5",
      instructions: "You read review notes and the summary of a change, and you decide if the change is complete.",
      workspace: "none",
      thinkingLevel: "low",
    }),
    fail: fail(),
  },
}).createMachine({
  id: "review",
  context: ({ input }) => ({ ...input, notes: "", summary: "" }),
  on: { "workspace.lost": ".lost", "agent.fault": ".failed" },
  initial: "reviewing",
  states: {
    reviewing: {
      invoke: {
        src: "reviewer",
        input: ({ context }) => ({
          prompt: `Review commit ${context.sha}.`,
          // The review Worktree holds the commit of `reviewSha`, detached from the branch.
          cwd: context.workspace.review?.app ?? context.workspace.repos.app,
          // A Dial: this Turn thinks harder than the default of the model.
          thinkingLevel: "high",
        }),
      },
      on: {
        approve: "done",
        request_changes: { target: "fixing", actions: assign({ notes: ({ event }) => event.notes }) },
      },
    },
    fixing: {
      invoke: {
        src: "coder",
        input: ({ context }) => ({
          prompt: `Change the code on the branch as the review asks:\n\n${context.notes}`,
          cwd: context.workspace.repos.app,
          // Each Turn of the coder continues the same conversation.
          continue: true,
          // A Dial: the model of this Turn, from the run input. Absent, the model of the definition.
          model: context.model,
        }),
      },
      on: { finished: { target: "checking", actions: assign({ summary: ({ event }) => event.summary }) } },
    },
    checking: {
      invoke: {
        src: "checker",
        input: ({ context }) => ({
          prompt: `The review asked for this:\n\n${context.notes}\n\nThe coder did this:\n\n${context.summary}`,
        }),
      },
      on: {
        approve: "done",
        request_changes: { target: "fixing", actions: assign({ notes: ({ event }) => event.notes }) },
      },
    },
    done: { type: "final" },
    failed: { invoke: { src: "fail", input: { reason: "the Agent's Turn could not go on" } } },
    lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
  },
});

export const machine = workspace(body, {
  input: runInput,
  repos: { app: "https://github.com/<you>/app.git" },
  spec: ({ input }) => ({ branch: input.branch, reviewSha: input.sha }),
});

Write your GitHub user name in place of <you>. Each Agent uses the model of the tab.

Set the access ​

The workspace field selects the Working tools of the Agent. It also selects where its Turns run. The default is "write".

workspaceThe Working toolsWhere the Turn runs
"write" (default)read, write, edit, bash, grep and globThe Sandbox of the Workspace
"read"read, bash, grep and glob: no write, no editThe Sandbox of the Workspace
"none"noneThe Instance Harness, with no Sandbox

An Agent with "write" or "read" works in a Sandbox. Thus invoke it in the body of a workspace(). An Agent with "none" is a Menu-only Agent. It reads its prompt and picks from its Menu. You can invoke it in the body of a workspace(), as the example does, or in a Machine with no Workspace.

A "read" Agent keeps bash, so a command can still change a file or the branch. To keep a reviewer away from the branch, give its Turn the review Worktree:

  1. In the spec of the workspace(), set reviewSha to the commit to review.
  2. In the Turn, set cwd to context.workspace.review?.<slot>.

The review Worktree is /work/<slot>/<branch>-review, with each / in the branch changed to -. It holds the commit of reviewSha, detached from the branch. In the example, <slot> is app, and the commit comes from the run input.

The access is a part of the identity of the Agent. Only the definition sets it. A Turn cannot change it.

Set the Dials of a Turn ​

The Dials are model and thinkingLevel. Write them in the input of the invoke, beside the Frame (prompt and cwd). A Dial changes only that Turn. When the Turn sets no Dial, it uses the value of the definition.

  • thinkingLevel is one of "off", "minimal", "low", "medium", "high" and "xhigh". In the example, the definition of checker sets "low", and the reviewing state sets "high" for the reviewer's Turn. A definition with no thinkingLevel uses "off". A model can lack a level. Then the Harness runs the Turn at a different level, and the printed conversation names it in a [thinking level] line. See Watch a run.
  • model is <provider>/<model id>, as in the definition. In the example, the fixing state gives the coder the model of the run input. When the run input has no model, the value is undefined, and the Turn uses the model of the definition.

Run jr2 up to converge the new Workflow:

sh
jr2 up

jr2 up prints no line that is special to this Workflow. The seats: lines show the picks of the three Agents.

To give a model to the coder's Turns, add model to the run input:

sh
jr2 run review --input '{"branch":"<branch>","sha":"<commit>","model":"<provider>/<model id>"}' --detach
text
→ context kind-jr2 / namespace my-instance
{"runId":"48401e39-f8b2-4246-a24e-6ee2665ee892"}

The first line gives your kubectl context and the namespace of the Instance. runId is the id of the new run. Your run id is different.

Write a new branch name for the coder in place of <branch>. The branch starts from the default branch of the Repo. Thus write the full id of a commit on the default branch in place of <commit>, such as its tip. Start a run tells how to follow the run.

jr2 up reads the models of the definitions. It cannot read a model that a Turn sets as a Dial. Thus:

  • The Harness checks a Dial model only when it admits the Turn. A model that it cannot find gives agent.fault.
  • jr2 up gives each pod the model key of the providers that the definitions of its Agents name. For an Agent in a Sandbox, use a Dial model of a provider that a definition in that Sandbox names. For a Menu-only Agent, use a provider that a Menu-only definition of the Instance names.

Continue a conversation ​

Without continue, each Turn starts a new conversation. With continue: true, the Turn continues the conversation of this Agent in this Machine instance. In the example, the first fixing Turn starts the coder's conversation. When the checker picks request_changes, the run goes back to fixing, and the coder continues with what it did before.

Set continue: true on each Turn that must join the conversation. A Turn without it starts a new conversation and does not join.

After agent.fault on a Turn with continue: true, the next Turn with continue: true starts a new conversation. A Harness restart that cut only the Turn is the exception: the conversation stays. Thus write a prompt that gives the full task, also on a Turn that continues. In the example, each fixing prompt gives the notes of the review.

Rules ​

  • jr2 refuses a Turn whose input has a key that a Turn does not have, such as instructions or workspace. Only the definition sets those two. <keys> is each key that is not correct, in backticks, with a comma between them: agent "<slot>": the Turn's input carries <keys>, which a Turn has no use for — its input is its Frame (`prompt`, `cwd`), its Dials (`model`, `thinkingLevel`), and `continue` (ADR-0057)
  • Under a Workspace with more than one Repo Slot, jr2 refuses a Turn of an Agent with Working tools that sets no cwd: turn <id>: agent "<slot>" works under a Workspace carrying more than one Repo Slot (<slots>) and its Turn framed no `cwd` — say which Worktree it works in (`cwd: context.workspace.repos.<slot>`, one of those), because the kit gives no slot a meaning (ADR-0051) and a Turn's Frame is where it works (ADR-0057)
  • The Harness refuses a cwd that is not an absolute path. The run gets agent.fault, with this reason: harness admission failed (400): agent "<slot>": the Turn's Frame carries a `cwd` that is not an absolute path — the Frame says WHERE this Turn works (ADR-0057), and a Turn rooted anywhere else would work in the wrong directory silently
  • The Harness refuses a model that is not in its catalog and not a model of the custom provider. The run gets agent.fault, with this reason: harness admission failed (400): agent "<slot>": model "<provider>/<model id>" resolves to nothing — not in pi's catalog, and no custom provider "<provider>" is configured (ADR-0018)
  • The Harness refuses a model that is not in the form <provider>/<modelId>. The run gets agent.fault, with this reason: harness admission failed (400): agent "<slot>": model "<model>" is not a <provider>/<modelId> specifier (ADR-0018)
  • jr2 refuses a Turn of a "write" or "read" Agent outside a workspace(): turn <id>: agent "<slot>" has workspace: "<access>" — invoke it inside a workspace() (ambient resolution), or pass an explicit `endpoint` (workspace-less stub path)
  • model is required in each definition. A Machine from a package can leave it Open. jr2 up refuses an Open model that nobody bound: refusing: workflow "<workflow>" leaves Agent "<slot>" open — bind it where the Machine is registered: export const machine = customize(<import>, { agents: { <slot>: { model: "<provider>/<model>" } } }) (ADR-0054)
  • When a Turn starts with an Open model, jr2 refuses it: agent "<slot>" has an Open model — nobody bound it; bind it where the Machine is registered (`customize(<machine>, { agents: { <slot>: { model: "<provider>/<model>" } } })`), because a packaged Machine cannot pick a model on your behalf (ADR-0054)

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