Composition
Composition is how you make a Workflow from Machines that exist already. It has three parts. A Machine invokes a different Machine as its child. customize() changes the parts that a Machine carries. And you bind the parts that a Machine leaves Open. The kit ships Machines for you to compose in this way, in the package @jr2/machines.
Why it exists
A Machine that you import must work in your Instance with no change to its module. Thus a Machine carries all that it needs: its Vocabulary, its Agents, its Effects, its Sandbox Image and its Repo Slots. The Machine does not need an Agent list or event names from the Instance. The Instance gives only access and defaults. Access is the model provider and its key, the secrets, and the git credentials. A default is a Sandbox Image or a Size for a Machine that states none.
A package cannot know some parts. It does not know your Repo, and it cannot pay for your model. The package leaves these parts Open. You bind them where you register the Machine as a Workflow, or where you compose it. If you do not bind an Open part, jr2 up stops before it converges. Thus a missing part does not become a run that spends money on a model that nobody chose.
A Machine inside a Machine
A Machine composes a different Machine as an xstate child. It imports the Machine, puts it under actors, and a state invokes it by its key. The key is a slot, the same as the slot of an Agent.
This Machine composes the shipped Machine task two times. A Menu-only Agent, triager, reads the prompt first. Then the run goes to one of the two copies of task. The copies differ only in the thinking level of their coder.
The Workflow, with the model of the tab:
import { z } from "zod";
import { agent, customize, defineEvent, fail, jr2Setup, type HostInjectedInput } from "@jr2/orchestrator";
import { task } from "@jr2/machines";
const model = "anthropic/claude-haiku-5-5";
// Bind the two Open parts of `task`: the Repo that the coder works in, and the coder's model.
const coding = customize(task, {
repos: { target: { url: "https://github.com/you/app.git" } },
agents: { coder: { model } },
});
// Two changed copies of one Machine. `coding` does not change.
const deep = customize(coding, { agents: { coder: { thinkingLevel: "high" } } });
const quick = customize(coding, { agents: { coder: { thinkingLevel: "low" } } });
const door = z.object({ prompt: z.string().describe("What to do, in prose.") });
const route = defineEvent({
name: "route",
description: "Say how much care the task needs.",
audience: "agent",
input: z.object({
size: z.enum(["small", "large"]).describe("small: a change to one file. large: any other change."),
}),
});
type Input = HostInjectedInput & z.infer<typeof door>;
export const machine = jr2Setup({
types: {} as { input: Input; context: Input },
events: [route],
actors: {
triager: agent({
model,
workspace: "none",
thinkingLevel: "off",
instructions: "Read the task. Call route once: small for a change to one file, large for any other change.",
}),
// Each composed Machine is a slot, like an Agent.
deep,
quick,
fail: fail(),
},
}).createMachine({
id: "route",
input: door,
context: ({ input }) => input,
on: { "agent.fault": ".failed" },
initial: "triaging",
states: {
triaging: {
invoke: { src: "triager", input: ({ context }) => ({ prompt: context.prompt }) },
on: {
route: [{ guard: ({ event }) => event.size === "large", target: "deep" }, { target: "quick" }],
},
},
// A composed `task` gets its input from this Machine, so it needs a branch.
deep: {
invoke: {
src: "deep",
input: ({ context }) => ({ prompt: context.prompt, branch: `jr2/route-${context.instanceId}` }),
onDone: "done",
},
},
quick: {
invoke: {
src: "quick",
input: ({ context }) => ({ prompt: context.prompt, branch: `jr2/route-${context.instanceId}` }),
onDone: "done",
},
},
done: { type: "final" },
failed: { invoke: { src: "fail", input: { reason: "the Agent's Turn could not go on" } } },
},
});To use @jr2/machines, add it to the Instance at the Kit version: npm install --save-exact @jr2/machines@<version>. <version> is the version of @jr2/orchestrator in your package.json.
A composed Machine is a part of the run of its parent:
- The run has one run id and one snapshot. A child Machine does not start a second run.
- The Gates of the child show in the run of the parent. Its Emits go to the same feed.
- Each Machine keeps its own Vocabulary. The
approveof a child is not an event of its parent, and the parent does not declare it again. - The parent gets to the child by
import. It does not use the Workflow name thatjr2 runuses. - Only the root Machine of a run gets
instanceId(HostInjectedInput) beside the run input. A child gets only the input that its parent gives it. Thus the example gives eachtaskabranch.
Composing a Workflow
The file workflows/route.ts registers the Machine route as the Workflow route. The Machine declares three slots of its own: the Agent triager and the two Machines deep and quick. Each of the two is a customize() of coding, and coding is a customize() of task. task leaves its Repo Slots and the model of its coder Open. coding binds them, so deep and quick have them too.
customize()
customize(machine, parts) gives a new Machine with the parts changed. The Machine that you give it does not change. Thus two calls on one import give two Machines, as deep and quick show.
Customize gives the parts that you can change:
| Key | What it changes |
|---|---|
agents | The definition of an Agent, by slot, field by field. { model } keeps the rest. |
effects | The Open url, argv or secret of an Effect, by slot. |
actors | A Machine that this Machine composes, by slot. The value has the same keys, one down. |
repos | The Repo Slots of the workspace(). |
image | The Sandbox Image of the workspace(). |
user | The User Container of the workspace(). |
resources | The Size of the workspace(), field by field. |
The type comes from the Machine. It offers only the Agents, Effects, Machines and Repo Slots that the Machine declares. A slot that the Machine does not have does not compile.
workspace() and pool() wrap a Machine. customize() goes through each wrapper to the Machine inside it. Thus customize(task, { agents }) changes the Agents of the Machine in the workspace() of task, and you do not name the wrapper's own slot. repos, image, user and resources go down to the first workspace() on that path.
Open parts
A Machine marks a part Open with open. These parts can be Open:
- a Repo Slot with no url, or the whole Repo Slot map (
repos: open) - the model of an Agent (
agent({ model: open, … })) - the url of an
httpEffect, the argv of acommand, or the name of the secret of an Effect (secret(open))
An Open part has no default. You bind it with customize(). Each Open part has two checks:
jr2 upreads every registered Machine. It refuses an Open part that nobody bound, and it prints thecustomize()line that binds it.- At run time, the part refuses too. An Agent does not start a Turn with an Open model. An Effect does not run while it is Open.
Bind a shipped Machine's Open parts shows each binding.
@jr2/machines
@jr2/machines is the package of Machines that the kit ships. It ships Machines and never Workflows. A Machine becomes a Workflow only when a file in your workflows/ folder exports it.
It ships task: a coder Agent works on one prompt in a Workspace, and a person approves the work at the review Gate. task leaves two parts Open: the whole Repo Slot map and the model of coder. The first Repo Slot that you name is the Repo that the coder works in. The coder can read the other Repos. It also ships body, the Machine inside task, for a workspace() of your own.
The version of @jr2/machines is the Kit version. It names @jr2/orchestrator at that exact version as a peer dependency.
A Machine as an npm package
A package exports a Machine and nothing more. The run input, the Vocabulary, the Agents, the Effects, the Sandbox Image and the Repo Slots are all on the Machine. A Workflow uses an imported Machine in one of three ways:
- as it is, when it leaves no part Open:
export const machine = imported; - changed:
export const machine = customize(task, { … }); - composed in a Machine of your own, as in the example above
task cannot be used as it is, because it leaves two parts Open.
A file in workflows/ uses the same code. A package has no API of its own.
A Machine package names @jr2/orchestrator, xstate and zod as peer dependencies, never as dependencies. jr2 keeps the parts of a Machine in the one copy of @jr2/orchestrator and of xstate that the Instance loads. A Machine made with a second copy of either has empty Menus and Gates that accept nothing. jr2 up shows no error for it, and prints no seats: line for it. Its run stops with the status error when an Agent or a Gate of the Machine starts. @jr2/machines has this shape, and your package copies it.
A package can ship a docker context for its Sandbox Image. The Machine names the folder with import.meta.resolve("./image"), and jr2 up builds it.
Rules
- A nested Machine is a child in the same run. jr2 has no second run for a child, and no invoke by Workflow name.
- An
agent(), an Effect or aworkspace()is a key ofactors. jr2 refuses one that a state writes in itsinvoke.src.jr2Setup().createMachinerefuses it, andjr2 uprefuses it too. customize()changes only the parts that a Machine declares. You cannot add a part. A Machine that needs one more Agent is a new Machine.- For a part that the Machine does not declare, the compiler refuses the key, and
customize()refuses it at run time.image,userandresourcescompile on every Machine.customize()refuses them when the Machine composes noworkspace(). jr2 uprefuses a registered Machine that leaves a part Open, and it names the Workflow and the part.jr2 upalso refuses an Open part on a Machine that a state writes in itsinvoke.src, because nocustomize()can get to it. Put that Machine underactors.- A Repo Slot map that is Open takes the slots that you name. Name one slot or more. Their order means what the Machine says it means.
- An Effect's Open part is bound one time, to a value. A run does not choose it, and a second
customize()cannot change it. - A file in
workflows/that a different Workflow imports is also a Workflow. Give a Machine that is only for composition a file name that starts with_, or keep it outsideworkflows/. taskinside a different Machine needsbranchin its input. Without it,taskrefuses to start.
Read next
- Bind a shipped Machine's Open parts
- Publish a Machine as an npm package
- Machines and Workflows
- Workspaces
- Pools and Sources
- Reference:
customize(),Customize,open,@jr2/machines,jr2 up - ADR-0049: a Machine carries its parts and composes by invoke
- ADR-0051: a Repo is a slot on the Workspace
- ADR-0054: the kit ships Machines, and a shipped Machine leaves its parts Open