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:
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".
workspace | The Working tools | Where the Turn runs |
|---|---|---|
"write" (default) | read, write, edit, bash, grep and glob | The Sandbox of the Workspace |
"read" | read, bash, grep and glob: no write, no edit | The Sandbox of the Workspace |
"none" | none | The 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:
- In the
specof theworkspace(), setreviewShato the commit to review. - In the Turn, set
cwdtocontext.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.
thinkingLevelis one of"off","minimal","low","medium","high"and"xhigh". In the example, the definition ofcheckersets"low", and thereviewingstate sets"high"for the reviewer's Turn. A definition with nothinkingLeveluses"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.modelis<provider>/<model id>, as in the definition. In the example, thefixingstate gives the coder themodelof the run input. When the run input has nomodel, the value isundefined, and the Turn uses the model of the definition.
Run jr2 up to converge the new Workflow:
jr2 upjr2 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:
jr2 run review --input '{"branch":"<branch>","sha":"<commit>","model":"<provider>/<model id>"}' --detach→ 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 upgives 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
inputhas a key that a Turn does not have, such asinstructionsorworkspace. 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
cwdthat is not an absolute path. The run getsagent.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 getsagent.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 aworkspace():turn <id>: agent "<slot>" has workspace: "<access>" — invoke it inside a workspace() (ambient resolution), or pass an explicit `endpoint` (workspace-less stub path) modelis required in each definition. A Machine from a package can leave it Open.jr2 uprefuses 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)
Read next
- Agents and Turns: the definition, the Frame, the Dials and
continue. - Workspaces: the Worktrees and the review Worktree.
- The Menu: the picks of an Agent.
- Bind a shipped Machine's Open parts: bind an Open model.
- Reference:
agent,AgentDefinition,ThinkingLevel,WorkspaceAccessandagent.fault. - ADR-0028: what an Agent can do to the Workspace is a part of its definition.
- ADR-0057: a Turn is its Frame, its Dials, and
continue. - ADR-0018: an Agent is a definition.