write workflows with the SDK
author real control flow (branching, retries, fan-out, schedules) in TypeScript.
the CLI is for one or two agents. the moment a workflow needs branching, retries, fan-out over a computed list, or a schedule, write it in TypeScript with @orchestratr/sdk. the SDK is a typed client of the socket API: anything the CLI can do, the SDK can do, and anything the SDK does, a shell script can do with orcr … --json. there is no private surface.
scaffold a project
orcr scaffold sets up a ready-to-run project and installs dependencies:
orcr scaffold my-workflow
cd my-workflow
npx tsx workflow.tsit creates exactly three files and runs npm install:
package.json @orchestratr/sdk (pinned to this CLI's version) · tsx · typescript
tsconfig.json
workflow.ts a ~15-line runnable example: scope → run --name → wait → last-responserules to know:
- requires Node 20 or newer plus npm. missing →
environment_errorwith an install pointer, and nothing is created. this is the only orcr feature that needs Node; the CLI itself does not. - never overwrites. if any of the three files already exists in the directory, it fails with
state_conflictand touches nothing. - purely local.
scaffoldis the one command that does not talk to the server; the project it generates talks to the server like any client, and the socket version check catches CLI/SDK drift loudly.
it is a plain npm project, so you can add more .ts files freely and npm install any dependency the workflow needs (a GitHub client, a CSV parser). a human can review it like any other code.
where projects live
this is convention, not CLI behavior (the skill teaches it), split by lifetime:
one-time dynamic workflow → $ORCR_AGENT_DATA_DIR/workflows/ (disposable, but auditable)
reusable workflow / loop → ~/.orcr/workflows/<name>/ (the shared library)a one-time script written by an agent for one task dies with that agent's other artifacts but stays auditable. anything reusable, and every loop's script, goes in the shared home, because a loop is by definition reusable. two loops can share one project; removing and recreating a loop leaves its script untouched. a loop's cwd stays the workspace, so a script living in the shared home is invoked by absolute path (<project>/node_modules/.bin/tsx <project>/x.ts); Node finds the project's dependencies by walking up from the script file.
spawn an agent
orcr.agent.run returns an AgentHandle immediately, matching agent run semantics:
import { orcr } from "@orchestratr/sdk";
const a = await orcr.agent.run({
agent: "codex", // optional: falls back to config defaults.agent
prompt: "…",
name: "worker", // --name OR path: exactly one (naming is mandatory)
// gc?, model?, effort?, cwd?, timeout?
});
a.uuid; // permanent id
a.path; // "refactor/phase_1/worker"
a.name; // "worker" (last segment)
a.dataDir; // = ORCR_AGENT_DATA_DIR (the data convention)the handle has the per-agent verbs:
await a.wait({ timeout }); // settles: turn complete | blocked | ended
await a.send(prompt);
await a.logs({ tail }); // snapshot → LogEntry[]
for await (const e of a.followLogs()) { … } // streaming is a separate call
await a.lastResponse(); // → string (throws TranscriptUnavailable)
await a.kill();the one-liner: ask
ask is documented sugar for run({ gc: "immediate" }) then wait() then lastResponse(). naming rules are identical to run.
const answer: string = await orcr.ask({
agent: "claude", name: "quick_check", prompt: "…",
});collections
the collection helpers take glob patterns (relative to your scope, / for absolute, */** wildcards):
await orcr.agent.wait("phase_1/*", { timeout }); // relative to my scope
await orcr.agent.wait("/refactor/**"); // absolute
await orcr.agent.ls({ pattern, agent, status, managed, all });
await orcr.agent.kill("fanout/*", { force }); // no interactive confirm in the SDKthe SDK never prompts. destructive helpers behave like non-interactive CLI calls (-y semantics).
scopes and fan-out
orcr.scope() prefixes every relative path created or targeted inside it, using AsyncLocalStorage (not a process global), and scopes nest. fan-out is plain Promise.all over run/ask:
await orcr.scope("review", async () => {
const reviewers = await Promise.all(files.map((f, i) =>
orcr.agent.run({ agent: "claude", path: `fanout/file_${i}`, gc: "immediate", prompt: `…${f}…` })));
await orcr.agent.wait("fanout/*"); // relative to the review scope
}, { killOnThrow: true }); // barrier-kill review/** on throwthis is its own topic: see scopes, lineage, and fan-out.
context from the environment
inside a spawned agent or a loop run, derive your context from the env contract, never hand-parse ORCR_PATH:
const ctx = orcr.context.fromEnv();
// → { kind: "agent" | "loopRun" | "root", id?, path?, scope?, dataDir?, parent?,
// loop?: { name, runId, path, dataDir } }dataDir is ORCR_AGENT_DATA_DIR for agents; loop membership is detected via ORCR_LOOP_DATA_DIR. see data and file conventions.
live events
watch is snapshot-then-subscribe, the same feed orcr top renders:
const sub = await orcr.watch({ pattern, agent, status, managed, sinceSeq });
for await (const ev of sub) {
// typed events: agent.status_changed, queue.promoted, …
}loops
await orcr.loop.create({
name: "burn_down", cron: "*/30 * * * *", timeout: "25m",
maxConcurrency, overlap,
command: [`${wf}/node_modules/.bin/tsx`, `${wf}/burn-down.ts`],
});
const run = await orcr.loop.run.start("burn_down");
// → { uuid, path: "burn_down/r82c9s", runId, loop, status, dataDir }
await orcr.loop.run.stop("burn_down", { runId });
await orcr.loop.run.ls("burn_down", { all });
await orcr.loop.ls();
await orcr.loop.logs("burn_down", { run, source });
await orcr.loop.pause("burn_down");
await orcr.loop.resume("burn_down");
await orcr.loop.rm(orcr.loopNameFrom(process.env.ORCR_PATH!)); // self-terminateattach is terminal-mediated
the SDK does not fake an interactive method. prepareAttach returns the command for you to exec:
const at = await orcr.agent.prepareAttach("review/worker", { takeover: false });
// → { command, leaseId, uuid, path, ttlMs }
await at.run(); // spawns it, heartbeats the lease while it lives, releases on exitparity and server helpers
the generated protocol client covers every socket method 1:1 (server.*, api.*, loop.run.*, events.*, watch.open, and the rest); the helpers above are the curated layer on top. nothing the CLI can do is missing from the SDK.
await orcr.server.status();
await orcr.api.snapshot();typed errors
failures become typed errors carrying { code, message, details } from the protocol error enum, one class per code:
NotFound · InvalidRequest · StateConflict · Blocked · Timeout
IntegrationMissing · TranscriptUnavailable · EnvironmentError · ServerErrora force-required kill is a StateConflict with err.forceRequired. the socket version check surfaces as EnvironmentError (cause unsupported_version).
next
- scopes, lineage, and fan-out: compose paths correctly.
- orchestration patterns: the runnable recipes.
- data and file conventions: getting structured output from agents.
- SDK reference: the full typed surface.