Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,5 +116,7 @@ cat program.ts | node skills/rig/rig.ts --typecheck

## Docs

See [skills/rig/SKILL.md](skills/rig/SKILL.md) for construction rules and
[skills/rig/references/runtime.md](skills/rig/references/runtime.md) for launcher and engine details.
See [skills/rig/SKILL.md](skills/rig/SKILL.md) for construction rules,
[skills/rig/references/runtime.md](skills/rig/references/runtime.md) for launcher and engine details, and
[skills/rig/references/claude-workflow-conversion.md](skills/rig/references/claude-workflow-conversion.md)
for porting Claude Code dynamic workflows to rig.
6 changes: 5 additions & 1 deletion skills/rig/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ Defaults: `name: "agent"`, `model: "small"`, `maxTurns: 4`, string input/output,
| Numeric schema choice | `s.int` for counts/line numbers; `s.number` for measurements and ratios |
| Optional versus nullable | `s.optional(shape)` for omission; `s.nullable(shape)` for explicit `null` |
| Deterministic TypeScript fan-out | `workflow({ meta, input?, body })` + `export default`; use `call`, `pipeline`, `parallel`, `until` inside `body` |
| One-off prompt inside a workflow | `call.text(prompt)` for a string, `call.json(prompt, schema)` for structured output |
| Reusable workflow step | Define an `agent({ input, output })` and `call(worker, input, { label, phase })` |
| Phase or log from an agent program | Import `phase` / `log` from `rig` and call them at top level; the launcher runs every program inside a workflow |
| Custom model-callable operation | `defineTool(name, { description, parameters, handler })` |
| Structured-output retries | `maxTurns` on the agent plus `addons: [repair()]` |
| Retry with final-turn warning | `addons: [steering(), repair()]` in that order |
Expand Down Expand Up @@ -103,6 +106,7 @@ Read only when the task needs the listed detail:
- [Agent API and schemas](references/agent-api.md) — spec fields, schema overloads, tools, and invocation options.
- [Prompt intents](references/prompt-intents.md) — complete helper semantics, dynamic inputs, writes, and failure behavior.
- [Composition and addons](references/composition.md) — delegation patterns, dynamic sets, repair, steering, and addon lifecycle.
- [Dynamic workflows](references/dynamic-workflows.md) — bounded fan-out, failure semantics, limits, events, and convergence loops.
- [Dynamic workflows](references/dynamic-workflows.md) — bounded fan-out, failure semantics, limits, budget, events, and convergence loops.
- [Claude workflow conversion](references/claude-workflow-conversion.md) — mapping Claude Code dynamic-workflow scripts onto rig primitives.
- [Running and engines](references/runtime.md) — markdown/file launch modes, typechecking, Agentic Workflows, and SDK adapters.
- [Linting](references/linting.md) — linter usage, autofixes, rules, and rule development.
155 changes: 155 additions & 0 deletions skills/rig/references/claude-workflow-conversion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Converting Claude dynamic workflows to rig

Read this reference when porting a Claude Code dynamic workflow script
(`.claude/workflows/*.workflow.js`) to a rig `workflow()` program, or when
comparing the two API surfaces.

A dynamic workflow is a sandboxed JavaScript module that exports a literal
`meta`, reads `args`, and orchestrates subagents with injected globals
(`agent`, `parallel`, `pipeline`, `phase`, `log`, `budget`). rig covers the same
shape with a typed `workflow({ meta, input, body })` whose `body` receives those
primitives from its context instead of from globals.

## Primitive mapping

| Dynamic workflow | rig | Notes |
| --- | --- | --- |
| `export const meta = { name, description, phases, whenToUse }` | `workflow({ meta: { name, description, phases, whenToUse } })` | `phases` accepts `"Title"` or `{ title, detail }`; `meta` may reference variables |
| `args` (JSON string or object) | `input` schema + `context.input` | Parsed and validated by the launcher; no defensive `JSON.parse` |
| `await agent(prompt)` | `await call.text(prompt, options?)` | Returns `string \| null` |
| `await agent(prompt, { schema })` | `await call.json(prompt, schema, options?)` | `schema` is `s.object({ ... })`; result is typed and validated |
| Reused prompt + schema pair | `agent({ input, output, instructions })` then `call(worker, input, options?)` | Preferred for anything invoked more than once |
| `parallel(thunks)` | `parallel(thunks)` | Same barrier semantics; failures become `null` holes |
| `pipeline(items, ...stages)` | `pipeline(items, ...stages)` | Stages receive `(previous, item, index)`; the first stage's `previous` is the item |
| `phase(title)` | `phase(title)` | Same |
| `{ phase: "Verify" }` on a call | `{ phase: "Verify" }` in call options | Overrides the ambient phase for that call only |
| `{ label: "verify:x" }` | `{ label: "verify:x" }` | Appears in `agent_start`/`agent_done` events |
| `{ model: "sonnet" }` | `{ model: "sonnet" }` | rig passes the id straight to the engine |
| `{ effort: "high" }` | — | Not modeled; encode importance structurally or in the model id |
| `{ agentType: "Explore" }` | Prompt wording plus a narrow `tools` list | rig has no built-in read-only agent type |
| `{ timeoutMs }` / `{ retries }` | `{ timeout }` on the call; `maxTurns` + `repair()` on the agent | rig retries are turn-based, not process-based |
| `log(message)` | `log(message)` | Same |
| `budget.total / spent() / remaining()` | `budget.total / spent() / remaining()` | rig meters **agent calls** (`limits.maxAgents`), not tokens |
| `workflow(ref, args)` | `call.workflow(child, args, options?)` | Shares the limiter, budget, phase, and event stream |
| top-level `return value` | `return value` from `body` | Same |
| `Workflow({ scriptPath, args })` from a session | `cat args.json \| node skills/rig/rig.ts program.ts` | See [runtime](runtime.md) |

Globals such as `phase` and `log` also exist as module-level imports from `rig`.
The launcher runs every program — including one whose root export is an `agent` —
inside a workflow run, so a partially ported script can call `phase()` and `log()`
at top level before the orchestration itself moves into `workflow({ body })`.

## Schema conversion

Dynamic workflows pass OpenAI-strict JSON Schema literals. rig schemas are
`s.*` values that compile to the same JSON Schema, so conversion is mechanical:

| JSON Schema | rig |
| --- | --- |
| `{ type: "string" }` | `s.string` |
| `{ type: "string", description: "d" }` | `s.string("d")` |
| `{ type: "integer" }` | `s.int` |
| `{ type: "boolean" }` | `s.boolean` |
| `{ type: "string", enum: [...] }` | `s.enum("a", "b")` |
| `{ type: "array", items: X }` | `s.array(X)` |
| `{ type: "object", properties, required, additionalProperties: false }` | `s.object({ ... })` |
| `{ anyOf: [X, { type: "null" }] }` | `s.nullable(X)` |
| omitted from `required` | `s.optional(X)` |

`additionalProperties: false` and a full `required` list are implicit in
`s.object`, so drop them. Use `s.path` for file paths and `s.url` for URIs.

## Worked conversion

Original dynamic workflow:

```js
export const meta = {
name: 'audit',
description: 'Find and verify issues',
phases: [{ title: 'Find' }, { title: 'Verify' }],
}

const FINDINGS = {
type: 'object', additionalProperties: false,
required: ['findings'],
properties: {
findings: { type: 'array', items: {
type: 'object', additionalProperties: false,
required: ['title', 'file'],
properties: { title: { type: 'string' }, file: { type: 'string' } },
} },
},
}

phase('Find')
const found = await parallel(args.areas.map((area) => () =>
agent(`Audit ${area}. Report findings.`, { label: area, schema: FINDINGS })))

phase('Verify')
const verified = await pipeline(
found.filter(Boolean).flatMap((r) => r.findings),
(finding) => agent(`Verify: ${finding.title} in ${finding.file}.`, {
phase: 'Verify', schema: VERDICT,
}),
)
return verified.filter(Boolean).filter((v) => v.real)
```

Ported to rig:

```ts
import { s, workflow } from "rig";

const finding = s.object({ title: s.string, file: s.path });

// Workflow role: audit areas in parallel, then verify each finding.
const audit = workflow({
meta: {
name: "audit",
description: "Find and verify issues",
phases: [{ title: "Find" }, { title: "Verify", detail: "one verifier per finding" }],
},
input: s.object({ areas: s.array(s.string) }),
body: async ({ call, input, parallel, phase, pipeline }) => {
phase("Find");
const found = await parallel(input.areas.map((area) => () =>
call.json(`Audit ${area}. Report findings.`, s.object({ findings: s.array(finding) }), { label: area })));

phase("Verify");
const verified = await pipeline(
found.flatMap((result) => result?.findings ?? []),
(f: { title: string; file: string }) =>
call.json(`Verify: ${f.title} in ${f.file}.`, s.object({ real: s.boolean }), { phase: "Verify" }),
);
return verified.filter((v) => v?.real);
},
});

export default audit;
```

## Behavior differences to keep in mind

- **Failure holes.** `call` returns `null` when an agent fails, and `parallel`
turns rejected thunks into `null`. A rig `pipeline` stage that throws fails the
whole run instead of dropping that item to `null`, so programming bugs stay
visible; wrap a stage in `try`/`catch` when you want the Claude behavior.
- **Budget units.** rig counts agent calls, not tokens, so guard loops with
`budget.remaining() > n` where `n` is a call count.
- **Nesting depth.** `call.workflow` has no one-level restriction, but it shares
the parent's `maxAgents` and concurrency, so a nested run cannot escape the
parent's limits.
- **No sandbox restrictions.** rig programs are normal TypeScript modules:
`Date.now()`, imports, and Node built-ins are allowed, and the launcher owns
isolation instead of the runtime.
- **No resume journal, worktree isolation, or human checkpoints.** These are
runtime features of Claude Code, not API surface; a rig workflow that needs a
human decision should return a structured `needs_human` result and be re-run
with the answer in its input.

## Related references

- [Dynamic workflows](dynamic-workflows.md) — full rig workflow API.
- [Agent API and schemas](agent-api.md) — `s.*` helpers and call options.
- [Running and engines](runtime.md) — launching a workflow program.
59 changes: 54 additions & 5 deletions skills/rig/references/dynamic-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@ const results = await runWorkflow(audit, {
});
```

`meta` describes the workflow for tools and progress displays. `input` preserves
Rig schema inference in both `body` and `runWorkflow({ args })`.
`meta` describes the workflow for tools and progress displays: `name`,
`description`, optional `phases` (each `"Title"` or `{ title, detail }`), and
optional `whenToUse`. `input` preserves Rig schema inference in both `body` and
`runWorkflow({ args })`.

## Workflow as default export

Expand Down Expand Up @@ -75,22 +77,62 @@ export default linter;
- Omit `input` for a no-input program (inline mode) or add `input: s.object({ ... })` for file-mode programs that read stdin JSON.
- Use `agent()` with `agents:` when an LLM should improvise the coordination order. Use `workflow()` when TypeScript owns the orchestration (fan-out, branching, convergence).

## Top-level constructs in agent programs

The launcher runs every program inside a workflow run, including programs whose
root export is an `agent`, a string, or a prompt builder. Module evaluation
happens inside that run, so `phase()` and `log()` imported from `rig` work at the
program's top level without declaring a `workflow()`:

```ts
import { agent, log, phase } from "rig";

phase("Review");
log("reviewing the staged diff");

export default agent({ instructions: "Review the staged diff." });
```

`currentWorkflow()` returns the active run context (`call`, `budget`, `signal`,
`phase`, `log`) or `undefined` outside a run; `phase()` and `log()` are no-ops
outside a run. A `workflow()` default export is nested into the same run, so it
shares the launcher's limiter, budget, and event stream instead of starting a
second run.

## Context

| Member | Behavior |
| --- | --- |
| `input` | Typed workflow arguments |
| `call(worker, input, options?)` | Runs a typed agent; returns its output or `null` on agent failure |
| `call.text(prompt, options?)` | Runs a one-off string-output agent |
| `pipeline(items, fn)` | Starts every item immediately; agent calls flow through the shared limiter |
| `call.json(prompt, schema, options?)` | Runs a one-off agent constrained to `schema`; returns typed output or `null` |
| `call.workflow(child, args?, options?)` | Runs another workflow inline on the same limiter, budget, and event stream |
| `pipeline(items, ...stages)` | Streams each item through every stage independently; agent calls flow through the shared limiter |
| `parallel(thunks)` | Runs all thunks as a barrier and preserves their order |
| `until(options, step)` | Runs a bounded convergence loop |
| `phase(name)` | Sets the phase attached to subsequent events |
| `log(message)` | Emits a structured log event |
| `budget` | Agent-call meter: `total`, `spent()`, `remaining()` |
| `signal` | Run cancellation signal for non-agent work |

Call options support `label` plus the normal per-call `model`, `timeout`,
`maxTurns`, and `signal` overrides.
Call options support `label` and `phase` (a per-call phase override) plus the
normal per-call `model`, `timeout`, `maxTurns`, and `signal` overrides.

Each `pipeline` stage receives `(previous, item, index)`. The first stage's
`previous` is the item itself, so a single-stage pipeline is just
`pipeline(items, (item) => ...)`. Stages run per item with no barrier between
them, so one item can be in stage 3 while another is still in stage 1.

`budget` is denominated in agent calls, not tokens: `budget.total` is the
effective `limits.maxAgents`, `spent()` counts started calls, and `remaining()`
is what is left before the run fails. Use it to scale depth:
`while (budget.remaining() > 10) { ... }`.

`call.workflow` runs a child `workflow()` inline. It shares the parent's
concurrency limiter, agent budget, cancellation signal, and `onEvent` stream, and
brackets the child with `log` events. Restore a phase after the nested run if the
child called `phase()`.

`parallel` turns rejected thunks into `null` holes. Agent failures passed through
`pipeline` are already `null` because `call` handles them; other pipeline callback
Expand Down Expand Up @@ -146,3 +188,10 @@ const final = await until(

The loop stops when `done` is true, after `max` rounds, or after
`noProgressRounds` consecutive equal defined progress keys.

## Porting from Claude dynamic workflows

The rig primitives mirror the Claude Code dynamic-workflow globals (`meta`,
`args`, `agent`, `parallel`, `pipeline`, `phase`, `log`, `budget`, nested
`workflow`). See [Converting Claude dynamic workflows to rig](claude-workflow-conversion.md)
for the full mapping, schema translation table, and behavior differences.
6 changes: 6 additions & 0 deletions skills/rig/references/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ Stdin coercion follows the root schema:

The launcher writes string results, or the string `text` field of an object result, directly to stdout. It JSON-serializes other results.

Both modes evaluate the program and run its root inside a workflow run, so
top-level `phase()` and `log()` work in any program and `currentWorkflow()` is
defined from module scope. A `workflow` default export nests into that run
instead of starting a second one. Run events are emitted under the
`workflow:event` debug category.

Add `--server` in either mode to start the Copilot server over stdio and force the Copilot engine. Without it, `copilotEngine()` connects over HTTP using `COPILOT_SDK_URI`, then `localhost:7777`.

Use `--help`, `-h`, `help`, `/help`, or `/?` to print launcher usage.
Expand Down
Loading