---
title: workspace
description: "Run a body Machine in a Sandbox: a pod of its own, with a Worktree of each Repo Slot on one branch."
---

# `workspace`

Function, in `@jr2/orchestrator/src/workspace.ts`. It is an export of `@jr2/orchestrator`, on the [Authoring surface](../authoring-surface.md) page, in Workspaces and Repos.

## Overload 1

```ts
function workspace<
  TSchema extends z.ZodObject,
  TBody extends AnyStateMachine,
  TSlots extends string = never,
>(
  body: TBody & BodyAcceptsDoor<TBody, z.infer<TSchema>, TSlots>,
  options: WorkspaceOptions<TSchema, TSlots> | NoSlotWorkspaceOptions<TSchema>,
): WorkspaceMachine<WithoutEmptyIndex<z.infer<TSchema>>, OutputFrom<TBody>, TBody, TSlots>
```

Run a body Machine in a Sandbox: a pod of its own, with a Worktree of each Repo Slot on one
branch. The result is a Machine. Register it as a Workflow, or invoke it from another Machine as
an actor slot.

When a run enters it, the Workspace makes the Sandbox and waits for room on a node, with no
deadline. It clones each Repo Slot and makes its Worktree on the `branch` that `spec` gives. Then
it runs the body with the run input plus `workspace`, the handles (see [`Workspaced`](type-workspaced.md)). The
Agents with Working tools and the `command` Effects in the body run in that Sandbox. An Agent
with `workspace: "none"` runs on the Instance Harness. When the body reaches a final state, the
Workspace deletes the Sandbox, and its output is the body's output. Push the branch before
then: jr2 deletes the Sandbox's commits with it.

Leave out `repos` for a Workspace with no Repo Slot: its Agents and `command` Effects share
`/work`, as the steps of one job share a folder, and `spec` is optional and gives only
`workGroup`. The body's input is then `Workspaced<RunInput, never>`. An `http` Effect sends a
file of `/work` with `file(path)`. A `push`, and a `command` with an Effect secret in its `env`,
need a Repo Slot, so jr2 refuses them there. jr2 deletes `/work` with the Sandbox: send what
must stay before the body ends.

Give each run a different branch, so that two runs do not push to one branch. Make it from a
field of the run input, or from `instanceId` when the run input has no field that is different
for each run: ``spec: ({ input }) => ({ branch: `fix/${input.instanceId}` })``. The input of
`spec` holds `instanceId` with no cast, also for the door `z.object({})`. Only a root
`workspace()` gets `instanceId` from jr2. A `workspace()` that a different Machine invokes gets
it only when its parent gives it.

The Sandbox is Lost when its pod is evicted or its node goes. Then the body gets the event
`workspace.lost`, and the body decides what to do. jr2 does not make the Lost Sandbox again.

When the body fails (it invokes a `fail()` slot), the Workspace deletes the Sandbox, and then the
same Failure goes up to the parent. With no parent to catch it, the run ends `failed`. When the
body has a defect (an error that is not a Failure), the run faults: it ends `error`, and the
Sandbox stays for you to inspect, until jr2's operator removes it after an idle timeout.

Give the run input to `input`, not to the body: a body with an `input` schema of its own is
refused. The types refuse a body whose input does not accept the run input plus the handles,
with the message `the body's declared input must accept the door plus the injected handles`.

It refuses, when you call it:
- a body with a state that is not final, when that state and each state above it have no
  `workspace.lost` route: ``workspace(): the body "<id>" has a state "<state>" that is not final,
  and no route for "workspace.lost" is on it or on a state above — jr2 sends workspace.lost to the
  body when its Sandbox is gone, and with no route the run faults: route it on the body's root,
  as on: { "workspace.lost": "<the state that handles it>" } (ADR-0074)``
- a body with its own `input`: ``workspace(): the body "<id>" declares its own run input, which
  nothing will ever serve or enforce — the wrapper feeds the body the run input PLUS the injected
  `workspace` handles, so the body's schema is not the door. Move it to the wrapper:
  workspace(body, { input, spec }) (ADR-0033).``
- an empty `image`: ``workspace(): `image` must be a non-empty string (got <value>) — either a
  `file:` URL to a docker context this module ships (`import.meta.resolve("./image")`) or a
  registry ref (ADR-0037).``
- an empty `repos`: ``workspace(): `repos: {}` names no Repo Slot — leave `repos` out for a
  Workspace with no Repo Slot, or name a slot: `repos: { app: "https://…" }` (ADR-0051).``
- a `repos` that is not a map or `open`: ``workspace(): `repos` must be a map of Repo Slots —
  `repos: { app: "https://…" }` — or `open` whole, for a map the consumer names; leave it out
  for a Workspace with no Repo Slot (ADR-0051).``
- a bad Repo Slot name or value (see [`RepoSlot`](type-reposlot.md))
- a `spec` that is not a function, or no `spec` on a Workspace with Repo Slots: ``workspace():
  `spec` must be a function from the run input to the spec — spec: ({ input }) => ({ branch }) —
  and only a Workspace with no Repo Slot may leave it out (ADR-0051).``
- a bad `secrets` list: ``workspace(): `secrets` must be a list of distinct names from
  jr2.config.ts's `secrets` list (got <value>) — secrets: ["JIRA_TOKEN"] (ADR-0059).``
- a bad `effector`: ``workspace(): `effector` is { image } — a `file:` URL to a docker context
  or a registry ref (got <value>) — effector: { image: import.meta.resolve("./effector") }
  (ADR-0068).``
- a `user` that is not an image or `{ image, resources? }`: ``workspace(): `user` must be a
  non-empty string (an image), or { image, resources? } (got <value>) — either a `file:` URL to a
  docker context this module ships (`import.meta.resolve("./image")`) or a registry ref
  (ADR-0037, ADR-0060).``, or ``workspace(): user.<key> is not accepted — the User Container is {
  image, resources? } (ADR-0005, ADR-0060)`` (see [`UserContainer`](type-usercontainer.md))
- a `resources` key other than `limits` or `requests`: ``workspace(): resources.<key> is not accepted — a
  Size is { limits: { memory?, cpu? } } and nothing else — Agent Substrate's template size
  (ADR-0060)``, or the same text for `resources.limits.<key>`. The other texts are in
  [`Size`](type-size.md).

And when a run enters it, a spec without a `branch` (or with `branch: "default"`), or with a bad
`reviewSha` or `workGroup`: ``workspace spec invalid: <fields> — the spec derives from run input;
does `jr2 run --input` carry every field this workflow's workspace() mapping reads?``. With no
Repo Slot, a spec with a `branch` or a `reviewSha`: ``workspace spec invalid: <field> (the
Workspace has no Repo Slot, so it has no branch and no review Worktree; its spec gives only
workGroup) …``

**Parameters**

- `body`: The Machine to run in the Sandbox. Its input is `Workspaced<RunInput, Slots>`.
- `options` ([`WorkspaceOptions`](type-workspaceoptions.md) | [`NoSlotWorkspaceOptions`](type-noslotworkspaceoptions.md)): What the Sandbox is made of (`image`, `user`, `resources`, `effector`), the
  Repo Slots it clones (`repos`), the Held secrets it can use (`secrets`), the run input
  (`input`), and the function from the run input to the branch (`spec`).

**Returns**: A Machine whose input is the run input and whose output is the body's output.

**Example**

```ts
import { z } from "zod";
import {
  command,
  fail,
  jr2Setup,
  workspace,
  type CommandOutput,
  type Workspaced,
} from "@jr2/orchestrator";

const runInput = z.object({ ticket: z.string(), repo: z.string() });
type BodyInput = Workspaced<z.infer<typeof runInput>, "app" | "docs">;

const passed = (output: CommandOutput): boolean =>
  output.outcome === "exited" && output.code === 0;

const body = jr2Setup({
  types: {} as { input: BodyInput },
  actors: {
    build: command(["npm", "run", "build"], { slot: "app" }),
    fail: fail(),
  },
}).createMachine({
  id: "build",
  on: { "workspace.lost": ".lost" },
  initial: "building",
  states: {
    building: {
      invoke: {
        src: "build",
        onDone: [
          { guard: ({ event }) => passed(event.output), target: "built" },
          { target: "broken" },
        ],
      },
    },
    built: { type: "final" },
    broken: { invoke: { src: "fail", input: { reason: "npm run build did not pass" } } },
    lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
  },
});

export const machine = workspace(body, {
  input: runInput,
  image: "docker.io/library/node:24",
  resources: { limits: { memory: "4Gi", cpu: "2" } },
  repos: {
    app: ({ input }) => input.repo,
    docs: { url: "https://github.com/you/docs.git", ref: "main" },
  },
  spec: ({ input }) => ({ branch: `fix/${input.ticket}` }),
});
```

**Example**

```ts
import { z } from "zod";
import {
  command,
  fail,
  jr2Setup,
  workspace,
  type CommandOutput,
  type Workspaced,
} from "@jr2/orchestrator";

const passed = (output: CommandOutput): boolean =>
  output.outcome === "exited" && output.code === 0;

// The run input has no field that is different for each run, so the branch comes from
// `instanceId`.
const runInput = z.object({});

const body = jr2Setup({
  types: {} as { input: Workspaced<z.infer<typeof runInput>, "app"> },
  actors: { test: command(["npm", "test"], { slot: "app" }), fail: fail() },
}).createMachine({
  id: "nightly",
  on: { "workspace.lost": ".lost" },
  initial: "testing",
  states: {
    testing: {
      invoke: {
        src: "test",
        onDone: [
          { guard: ({ event }) => passed(event.output), target: "tested" },
          { target: "red" },
        ],
      },
    },
    tested: { type: "final" },
    red: { invoke: { src: "fail", input: { reason: "npm test did not pass" } } },
    lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
  },
});

export const machine = workspace(body, {
  input: runInput,
  image: "docker.io/library/node:24",
  repos: { app: "https://github.com/you/app.git" },
  spec: ({ input }) => ({ branch: `fix/${input.instanceId}` }),
});
```

**See also**

- [Workspaces](../../concepts/workspaces.md)
- [Write a Workflow](../../guides/write-a-workflow.md)
- [Size a Workspace](../../guides/size-a-workspace.md)
- [Add tools to the Sandbox](../../guides/add-tools-to-the-sandbox.md)
- [ADR-0012: `workspace()` wraps a body Machine and owns only Sandbox lifecycle](../decisions/0012-workspace-wrapper-machine.md)
- [ADR-0051: A Repo is a slot on the `workspace()` and a cache on the node](../decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md)

## Overload 2

```ts
function workspace<
  TBody extends AnyStateMachine,
  TSlots extends string = never,
  TInput = unknown,
>(
  body: TBody & BodyAcceptsSlots<TBody, TSlots>,
  options: PermissiveWorkspaceOptions<TSlots, TInput> | PermissiveNoSlotWorkspaceOptions<TInput>,
): WorkspaceMachine<TInput, OutputFrom<TBody>, TBody, TSlots>
```

Run a body Machine in a Sandbox, with no `input` schema: the Workspace starts with any input. Use
it for a Workspace that a Pool or another Machine starts. `workspace` tells the rest, and
what it refuses.

**Parameters**

- `body`: The Machine to run in the Sandbox. Its input is the input of the Workspace plus
  `workspace`, the handles.
- `options` ([`PermissiveWorkspaceOptions`](type-permissiveworkspaceoptions.md) | [`PermissiveNoSlotWorkspaceOptions`](type-permissivenoslotworkspaceoptions.md)): The options of the Sandbox, and `spec`. There is no `input`.

**Returns**: A Machine whose input is the run input and whose output is the body's output.

**See also**

- [Workspaces](../../concepts/workspaces.md)
- [ADR-0033: A Machine declares the input that starts it](../decisions/0033-a-machine-declares-the-input-that-starts-it.md)
