Publish a Machine as an npm package
Publish a Machine as an npm package, so that each Instance can install it and register it as a Workflow.
The example package is @you/labeler. It exports one Machine, labeler: an Agent reads an issue and chooses one label for it. The Machine gives the label as its output. The package leaves the model of the Agent Open, because it cannot know which model each Instance uses. @jr2/machines has the same shape.
Replace @you with your npm scope in each file and each command on this page.
The package
The package has three files:
labeler/
├── package.json
├── tsconfig.json
└── src/
└── index.tspackage.json:
{
"name": "@you/labeler",
"version": "1.0.0",
"description": "A jr2 Machine that gives an issue one label.",
"license": "MIT",
"type": "module",
"engines": {
"node": ">=24"
},
"exports": {
".": "./src/index.ts"
},
"types": "./src/index.ts",
"files": [
"src"
],
"scripts": {
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"@jr2/orchestrator": "<version>",
"xstate": "^5.18.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@jr2/orchestrator": "<version>",
"@types/node": "^26.0.1",
"typescript": "^5.6.0",
"xstate": "^5.18.0",
"zod": "^4.4.3"
}
}peerDependenciesnames@jr2/orchestrator,xstateandzod. The package has nodependencies. Replace<version>with the Kit version that the package is for, such as0.4.1. Write the exact version, with no^and no~.devDependenciesalso names the three packages, at the same versions, andtypescriptand@types/node. With them, you can typecheck the package.exportsgives the.tsfiles, andfilesshipssrc. The package has no build step. jr2 loads.tsfiles fromnode_modules, the same as it loads@jr2/orchestratorand@jr2/machines.
tsconfig.json uses the options of an Instance. The typecheck of an Instance also checks the .ts files of each package that it imports, so the package must pass the same options:
{
"extends": "@jr2/orchestrator/tsconfig.instance.json",
"include": ["src/**/*.ts"]
}src/index.ts exports the Machine, and the type of its output:
import { assign } from "xstate";
import { z } from "zod";
import { agent, defineEvent, fail, jr2Setup, open } from "@jr2/orchestrator";
// The Agent's one pick.
const label = defineEvent({
name: "label",
description: "Give the issue one label. Call this once, then stop.",
audience: "agent",
input: z.object({
kind: z
.enum(["bug", "feature", "question"])
.describe(
"bug: something does not work. feature: a request. question: the author asks for help or for information.",
),
reason: z.string().describe("Why, in one sentence."),
}),
});
const door = z.object({
title: z.string().describe("The title of the issue."),
body: z.string().describe("The text of the issue."),
});
type Input = z.infer<typeof door>;
export type LabelerOutput = { kind: "bug" | "feature" | "question"; reason: string };
/**
* A Machine that gives an issue one label.
*
* Open part: `agents.labeler.model`. Bind it with `customize()` where you register the Machine.
*
* Run input: `{ title, body }`, the title and the text of the issue.
*
* Output: `{ kind, reason }`. `kind` is `bug`, `feature` or `question`.
*
* Fails the run when the Turn of the Agent fails: the reason of the Failure is `no label: ` and the
* reason of the fault.
*/
export const labeler = jr2Setup({
types: {} as { input: Input; context: Input & { result?: LabelerOutput; fault?: string }; output: LabelerOutput },
events: [label],
actors: {
labeler: agent({
// Open: the Instance that registers this Machine binds the model with customize().
model: open,
workspace: "none",
instructions: "You read one issue and give it one label. Call label once.",
}),
fail: fail(),
},
}).createMachine({
id: "labeler",
input: door,
context: ({ input }) => input,
initial: "labeling",
states: {
labeling: {
invoke: {
src: "labeler",
input: ({ context }) => ({ prompt: `Title: ${context.title}\n\n${context.body}` }),
},
on: {
label: {
target: "done",
actions: assign({ result: ({ event }) => ({ kind: event.kind, reason: event.reason }) }),
},
// jr2 sends this when the Agent's Turn fails for good.
"agent.fault": { target: "failed", actions: assign({ fault: ({ event }) => event.reason }) },
},
},
done: { type: "final" },
// Not a final state: the run ends `failed`. The reason of the Failure is `no label: ` and the reason of the fault.
failed: { invoke: { src: "fail", input: ({ context }) => ({ reason: `no label: ${context.fault}` }) } },
},
output: ({ context }) => context.result!,
});The Machine carries all of its parts: its run input, its Vocabulary and its Agent. The model is open, so the Instance binds it with customize(). On the export, write TSDoc that gives its Open parts and its run input, as task does. An Assistant that reads node_modules reads that text.
Check the package
In the folder of the package, install the devDependencies. Then typecheck the package:
npm install
npm run typecheckThen see what the package ships:
npm pack --dry-runnpm notice
npm notice 📦 @you/labeler@1.0.0
npm notice Tarball Contents
npm notice 623B package.json
npm notice 2.4kB src/index.ts
npm notice Tarball Details
npm notice name: @you/labeler
npm notice version: 1.0.0
npm notice filename: you-labeler-1.0.0.tgz
npm notice package size: 1.4 kB
npm notice unpacked size: 3.0 kB
npm notice shasum: b4b5fd9c9ddff44b1be2e852e89969bdbe76750c
npm notice integrity: sha512-5CXrYzHDj/Qy8[...]70mtq84+C6+6w==
npm notice total files: 2
npm notice
you-labeler-1.0.0.tgzThe list holds package.json and the files of src. Your sizes and sums are different.
Publish the package
Log in to your registry with npm login. Then publish the package. npm publishes a package with a scope, such as @you, as restricted, unless you give --access public:
npm publish --access publicRegister the Machine in an Instance
In the Instance folder, install the package. Use npm's --save-exact, so that package.json names one version:
npm install --save-exact @you/labeler@1.0.0Make sure that the Instance has one copy of @jr2/orchestrator:
npm ls @jr2/orchestratormy-instance@0.0.0 <path>
├─┬ @jr2/cli@0.4.1
│ └── @jr2/orchestrator@0.4.1 deduped
├── @jr2/orchestrator@0.4.1
└─┬ @you/labeler@1.0.0
└── @jr2/orchestrator@0.4.1 deduped<path> is the path of your Instance folder, and the version is your Kit version. Each line names the same version. Each deduped line tells you that the package uses the copy of the Instance.
Save this file as workflows/label.ts. It binds the Open model of the Agent labeler to the model of the tab:
import { customize } from "@jr2/orchestrator";
import { labeler } from "@you/labeler";
export const machine = customize(labeler, {
agents: { labeler: { model: "anthropic/claude-haiku-5-5" } },
});Run jr2 up:
jr2 up…
jr2 up — instance "my-instance" → context <context> / namespace my-instance
typecheck: tsc --noEmit (the instance's own compiler)
failures: workflow "label" — the states that fail the run (ADR-0073)
labeler.failed: invokes fail
seats: workflow "label" — who may send what, per state (ADR-0015)
labeler.labeling agent:labeler: [label]
…The first line (…) is the .env line, when .env sets a variable that your shell does not set. <context> is your kubectl context. The seats: lines show that jr2 reads the Vocabulary of the Machine. Start a run tells how to run the Workflow label.
Rules
Name
@jr2/orchestrator,xstateandzodinpeerDependencies, never independencies.When the package names
@jr2/orchestratorindependenciesat a different version, npm installs a second copy under the package, with no error. A Machine that loads a second copy has empty Menus. Thusjr2 uprefuses the Instance before it applies anything. It gives one line for each Kit package that has a second copy, such as@jr2/orchestratorand@jr2/agent-protocol:texterror: cannot stage the Assembly: it would carry a second copy of a Kit package (ADR-0069) <path> is a second copy of <name>, nested by <package>@<package version> (<package path>): the Orchestrator's image ships <name> for @jr2/orchestrator <version>, and an Assembly that carries its own copy loads Machines whose Menus are empty (ADR-0054) — align that dependency with the <name> @jr2/orchestrator uses, or remove it, and reinstallRun
npm ls @jr2/orchestratorto find the second copy.Name
@jr2/orchestratorat the exact Kit version. When the Instance has a different Kit version, npm refuses to install the package:textnpm error code ERESOLVE npm error ERESOLVE unable to resolve dependency tree … npm error Could not resolve dependency: npm error peer @jr2/orchestrator@"<version>" from <package>@<package version> …<package>@<package version>is the package and the version that you install.Do not install with
--forceor--legacy-peer-deps. Publish a version of the package for the Kit version of the Instance. Thus each upgrade of the Kit version needs a new version of the package.In the Instance, the package must be in
dependencies, not indevDependencies. The Assembly holds onlydependencies. Without the package, the Orchestrator cannot load the Workflow, and it refuses the Assembly.The typecheck of
jr2 upincludes the.tsfiles of the package. A type error in it stops the converge:refusing: this instance does not typecheck — nothing was built and nothing was applied.Use only TypeScript that jr2 can erase: no
enum, nonamespace, and no parameter properties in a constructor. The typecheck accepts them, butjr2 upcannot load the Workflow. It stops with a JavaScript syntax error, such aserror: Unexpected token 'export'.Write each relative import in the package with the
.tsextension, as./util.ts. With.js, the typecheck passes, butjr2 upstops:error: Cannot find module '<path>/util.js' imported from <path>/index.ts.A package exports Machines, never Workflows. A Workflow is a file in the
workflows/folder of an Instance.jr2 uprefuses a Workflow that leaves a part Open. It prints thecustomize()line that binds it: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). Bind a shipped Machine's Open parts gives the refusals for the other Open parts.A new version of the package can change the Machine shape: its states, its invokes or its transitions. Then the Orchestrator does not continue a run that the old version started. Its status is
drifted. See Machine shape and drift.
Read next
- Composition
- Bind a shipped Machine's Open parts
- Durable runs
- Reference:
customize(),open,@jr2/machines - ADR-0054: the kit ships Machines, and a shipped Machine leaves its parts Open
- ADR-0049: a Machine carries its parts and composes by invoke