Use when building multi-agent workflows with relay broker-sdk. Covers conversation vs pipeline coordination, WorkflowBuilder/DAG steps, agents, {{steps.X.output}} chaining, Relayfile-backed human assistance, Slack answer injection, integration subscriptions, waitFor gates, repairable verification, review-depth fresh-eyes review/fix loops with test hardening, channels, chat-native recipes, error handling, event listeners, step sizing, lead+workers teams, and parallel waves.
Writing Agent Relay Workflows
Overview
The relay broker-sdk workflow system orchestrates multiple AI agents (Claude, Codex, Gemini, Aider, Goose) through typed DAG-based workflows. Workflows can be written in TypeScript (preferred), Python, or YAML.
Language preference: TypeScript > Python > YAML. Use TypeScript unless the project is Python-only or a simple config-driven workflow suits YAML.
Pattern selection: Do not default to dag blindly. If the job needs a different swarm/workflow type, consult the choosing-swarm-patterns skill when available and select the pattern that best matches the coordination problem.
Package: import from @relayflows/core. @agent-relay/sdk/workflows was removed and @relayflows/core replaces it (see cloud/scripts/smoke-sandbox-image.mjs, which asserts @relayflows/core is present in the sandbox image). @agent-relay/sdk exports ., ./messaging, ./delivery, ./actions, ./session, and ./capabilities — there is no ./workflows subpath, so the old specifier cannot resolve. If you find a workflow importing it, or a hand-written ambient stub standing in for the missing types, both are stale: repoint them at and delete the stub. The same package works locally and inside the cloud workflow runner, so -dispatched workflows use it too.
declare module '@agent-relay/sdk/workflows'
@relayflows/core
ctx.workflow.run(...)
Runners — verified 2026-08-31 against agent-relay 11.8.7 / @relayflows/cli 1.1.0:
Goal
Command
Note
Run locally
relayflows run <file>
From @relayflows/cli. There is no agent-relay run subcommand.
Validate without running
relayflows run --dry-run <file>
--dry-run exists only here
Run in cloud
agent-relay cloud run <file> --file-type ts
Has no--dry-run
createGitHubStep is in @relayflows/core/integrations/github, not @agent-relay/sdk. Worked, executed examples live in examples/, which records exactly what was proven end-to-end and how to reproduce it.
When to Use
Building multi-agent workflows with step dependencies
Orchestrating different AI CLIs (claude, codex, gemini, aider, goose)
Creating DAG, pipeline, fan-out, or other swarm patterns
Needing verification gates, retries, or step output chaining
Designing product-contract workflows where failing checks should route to agents for repair instead of stopping the run
Adding human-in-the-loop Slack questions that block the workflow and inject the answer back into the active agent
Subscribing workflows or agents to Relayfile integration events such as GitHub PR reviews, status checks, Linear issue events, or Slack replies
Building event-started or event-gated workflows that use SDK surfaces instead of local provider CLIs
Non-Negotiable Workflow Checklist
Every generated workflow should satisfy this checklist before it is considered complete:
Start with a deterministic, resumable preflight for repository state, credentials, and declared write scope.
Pick the coordination shape deliberately: Conversation for non-trivial coordination, Pipeline only for linear one-shot handoffs.
Use repairable validation gates: capture red output with failOnError: false, hand it to a repair owner, then rerun the same check.
Run fresh-eyes review at the depth warranted by the spec: deep-tier workflows use Claude review/fix/final review/final fix followed by Codex review/fix/final review/final fix; lighter generated workflows may scale down only when deterministic gates, hard validation, and at least one independent Claude review/fix pass remain on the critical path.
Require review fixers to add or update appropriate tests, fixtures, assertions, or deterministic proofs for testable findings.
Run final deterministic acceptance after the selected review-depth path and before commit, PR creation, or handoff.
If a real blocker remains, write BLOCKED_NO_COMMIT with exact evidence and skip commit/PR creation instead of crashing the workflow.
If the workflow owns shipping, model branch, commit, push, PR creation, and PR URL verification as explicit deterministic steps.
For Slack questions, declare swarm.humanAssistance.slack.channel and instruct agents to print exactly one HUMAN_QUESTION: <question> line when blocked.
For integration-backed behavior, declare integrations.relayfile: {} and use SDK integration steps, subscriptions, or waitFor gates; do not require workspaceId, tokens, Slack bot tokens, or local provider CLIs in the workflow.
Default Principle: Workflows Repair Before They Fail
The point of an agent team workflow is not to discover a red gate and stop. The point is to capture the failure, route it to the right agent, fix it, and continue toward a shippable result. Author non-trivial workflows as repairable systems:
Run deterministic checks as evidence-capturing gates with captureOutput: true.
Prefer failOnError: false for intermediate validation gates so the workflow can pass the output to a repair agent.
Add a repair step immediately after each red-prone gate. The repair agent reads {{steps.<gate>.output}}, fixes source/tests/config, reruns the same command locally, and exits only after the gate is green or the blocker is external.
Keep final acceptance deterministic, but still put an agent repair step before commit/PR creation. If the repair budget is exhausted or a true external blocker remains, write a blocked artifact and skip commit/PR creation; do not let the workflow end as FAILED.
Use .reliable() or .repairable() on SDK versions that support it, especially for product-contract workflows. As of AgentWorkforce/relay#827, retry-mode workflows with agents are repair-aware by default, repair agents run before retrying malformed/failed agent steps, and the SDK covers DAG, pipeline, fan-out, worktree-backed, deterministic-only, and agent-plus-gate shapes.
Avoid hard-stop gates (failOnError: true with no repair step) in workflows that are supposed to be self-healing. Even cheap preconditions such as missing credentials, wrong repository, or an unsafe dirty worktree should normally write a clear BLOCKED_* artifact and exit cleanly. For implementation, build, test, lint, schema, artifact, and review failures, model the fix path in the workflow.
Human Assistance and Relayfile Events
Relayflows can block on humans and provider events without shelling out to Slack, GitHub, Linear, or the Relayfile CLI. Declare the integration intent in the workflow and let the SDK/runtime use the existing local Relayfile/Pear connection.
Slack Human Assistance
Requires @relayflows/core >= 1.1.0.humanAssistance and the HUMAN_QUESTION protocol do not exist at all in 1.0.x — verified by unpacking the published tarballs: 1.0.1 contains zero occurrences of either symbol, 1.1.0 is the first release that ships them. On a 1.0.x runner the key is simply ignored, so the gate never parks, nothing reaches Slack, and the run behaves as if you had configured no gate. Check the copy your runner will actually load — and match the check to how the workflow runs, because a bare node -e "require('@relayflows/core/package.json')" resolves from the current project, which is the wrong answer (or a MODULE_NOT_FOUND) whenever the runner is elsewhere. Project-local run: npm ls @relayflows/core from the workflow's own directory. Globally-installed relayflows, or one bundling its own copy: npm ls -g @relayflows/core, or resolve relative to the CLI's install path rather than the cwd. agent-relay cloud run executes in the sandbox image, so no local command reports its version — read it from the image's pinned snapshot. On 1.0.x, escalate through a Relayfile-mount file instead.
Use swarm.humanAssistance.slack when an interactive agent may need a human answer before continuing. The channel may be a Slack channel name (proj-cloud or #proj-cloud) or a channel ID (C...). Prefer names in checked-in workflows; the Relayfile Slack channel index resolves them to IDs at runtime.
With integrations.relayfile: {}, the workflow uses the existing Relayfile connection and mount. Do not add workspaceId, Relayfile token, Slack bot token, or provider tokens to the workflow. Mounting is enabled by default; set mount: false only for a controlled test runtime that intentionally does not need .integrations.
The asking agent MUST be interactive. Human assistance is wired only into the interactive PTY path. The non-interactive subprocess path — every preset: 'worker' | 'reviewer' | 'analyst' agent — has no HUMAN_QUESTION handling whatsoever. Put a gate on one of those presets and the run never parks, nothing reaches Slack, and the agent prints its own approval token because the non-interactive wrapper prompt demands the task finish in a single pass with no follow-up. An approval gate that fails open is worse than no gate. Leave preset off the asking agent (the default is interactive), or set interactive: true.
Two more ways this fails silently:
Do not set humanAssistance on the step. A step-level value replaces the swarm-level one rather than merging with it, so humanAssistance: { slack: true } on a gate step discards the channel and timeout configured on swarm and falls back to SLACK_DEFAULT_CHANNEL. Configure it once, on swarm.
The workspace credential needs a writable /slack grant, and a mount covering it. A credential scoped to only some providers takes the write, logs Wrote Slack question through local Relayfile mount: <path>, and then dead-letters the op with HTTP 401 — so the run parks on a question nobody was ever asked. Check before a first real run: relayfile writeback status --json (a growing deadLettered list with lastStatus: 401 is this), relayfile integration list --json (slack ready), and ps aux | grep relayfile-mount for --remote-path /slack.
Agent contract:
When blocked, the agent prints exactly one line: HUMAN_QUESTION: <concise question>.
The runner posts the question to Slack and blocks the workflow.
A threaded Slack reply resumes the workflow.
The runner injects HUMAN_ANSWER: <reply text> into the same active agent.
The agent continues from that answer and exits normally.
version: '1.0'
name: blocked-agent-question
swarm:
pattern: dag
humanAssistance:
slack:
channel: proj-cloud
timeoutMs: 3600000
integrations:
relayfile: {}
agents:
- name: implementer
cli: codex
role: Implementer that can ask the human one concise question when blocked
workflows:
- name: default
steps:
- name: implement
agent: implementer
task: |
Implement the requested change.
If you are blocked by missing product intent or an unsafe decision, print exactly:
HUMAN_QUESTION: <your concise question>
Then wait for the injected HUMAN_ANSWER before continuing.
Use an explicit Slack integration step when the workflow itself owns the question instead of an agent:
version: '1.0'
name: explicit-slack-gate
swarm:
pattern: dag
integrations:
relayfile: {}
agents:
- name: implementer
cli: codex
workflows:
- name: default
steps:
- name: ask-scope
type: integration
integration:
provider: slack
action: askQuestion
params:
channel: proj-cloud
text: Which package should receive this change?
timeoutMs: 3600000
output:
mode: data
format: json
path: answer.text
- name: implement
agent: implementer
dependsOn: [ask-scope]
task: |
The human answered: {{steps.ask-scope.output}}
Implement only that scope.
Relayfile Integration Subscriptions
Use integrations.subscriptions for workflow-level provider events and agent subscriptions/watch for agent-specific streams. Active agents receive matching provider events as INTEGRATION_EVENT messages. Use waitFor when a DAG step must block until a matching event arrives.
version: '1.0'
name: pr-feedback-babysitter
swarm:
pattern: dag
humanAssistance:
slack:
channel: proj-cloud
integrations:
relayfile: {}
subscriptions:
- name: pr-feedback
provider: github
path: /github/repos/acme/web/pulls/42/**
events: [created, updated]
agents: [pr-babysitter]
agents:
- name: pr-babysitter
cli: codex
role: PR babysitter that fixes review comments until the PR is ready
subscriptions:
- name: review-events
provider: github
paths:
- /github/repos/acme/web/pulls/42/reviews/**
- /github/repos/acme/web/pulls/42/status/**
events: [created, updated]
workflows:
- name: default
steps:
- name: wait-for-first-review
type: waitFor
waitFor:
provider: github
path: /github/repos/acme/web/pulls/42/reviews/**
event: created
timeoutMs: 3600000
- name: address-feedback
agent: pr-babysitter
dependsOn: [wait-for-first-review]
task: |
Review the first event payload:
{{steps.wait-for-first-review.output}}
Stay subscribed to INTEGRATION_EVENT updates for this PR.
Address review comments and failing status checks until no open feedback remains.
If you need a decision, print exactly one HUMAN_QUESTION line and wait for HUMAN_ANSWER.
waitFor must include either path or paths. Its output is the matching event payload serialized as JSON, suitable for {{steps.<name>.output}} injection.
Dynamic Kickoff
A running relayflow can block on waitFor, and an active agent can receive subscribed events with no polling. Starting a new relayflow from an external event belongs to the host SDK surface, such as Factory or another service with a template registry. That host subscribes to Relayfile events, resolves the template, and calls the Relayflows SDK directly. Do not generate workflows that depend on relayflows or provider CLIs being installed on the machine.
Review-Depth Fresh-Eyes Loops
Review depth changes only the number of LLM fresh-eyes passes. It never removes deterministic proof, repairable validation, final hard validation, scoped diff evidence, blocked-state handling, or final signoff.
Use this contract when Ricky or a workflow generator selects a depth:
For every tier, final-hard-validation must depend on final-review-pass-gate, and commit, PR creation, or handoff must depend on final deterministic acceptance, not directly on implementation or a review step. light and standard are valid only when the generator selected that tier from bounded complexity signals or the operator explicitly requested it; ambiguous, high-risk, production, security, billing, destructive, or broad multi-file work should use deep.
The required deep-tier shape is:
claude-review: Claude reads the spec, repo rules, changed files, artifacts, test evidence, and final diff. It must produce a durable review artifact with either actionable findings or an explicit NO_ISSUES_FOUND verdict.
claude-fix: a fixer repairs every valid Claude finding, adds or updates appropriate tests/proofs for the fix, reruns the relevant checks, and records what changed. If the review found no issues, it records that no fix was needed.
claude-review-final: Claude reviews the post-fix state from scratch. It must not rely on the first review or the fixer's summary.
claude-fix-final: if the final Claude review still finds issues, fix them, add or update appropriate tests/proofs, and rerun the checks. If anything cannot be fixed, write a BLOCKED_NO_COMMIT artifact with exact evidence.
codex-review: Codex starts after the Claude loop and reviews the post-Claude-fix state from scratch.
codex-fix: a fixer repairs every valid Codex finding, adds or updates appropriate tests/proofs for the fix, reruns relevant checks, and records what changed.
codex-review-final: Codex reviews the post-fix state from scratch.
codex-fix-final: if the final Codex review still finds issues, fix them, add or update appropriate tests/proofs, and rerun checks. If anything cannot be fixed, write BLOCKED_NO_COMMIT.
Final acceptance/commit/PR steps depend on the selected post-fix review path, not directly on implementation or tests alone. In deep-tier workflows, that means the post-Codex-fix review path.
Because WorkflowBuilder DAGs do not provide an unbounded dynamic while loop, model this as explicit bounded review/fix loops plus a final signoff gate. Inside each fix step, instruct the agent to keep iterating locally: review the finding, edit, add or update appropriate regression tests/proofs, rerun targeted checks, review its own fix, and repeat until that round has no remaining valid issues. For high-risk workflows, add more unrolled review/fix rounds or split the reviews into focused reviewers by subsystem.
When the selected review depth includes both reviewers, use Claude first and Codex second unless one of those CLIs is unavailable in the target environment. If one is unavailable, write that limitation into the workflow artifact and keep the remaining review loop mandatory.
Review artifacts should use a consistent schema so later steps can act on them deterministically:
verdict: FINDINGS | NO_ISSUES_FOUND | BLOCKED
finding_id: short stable id
severity: blocker | high | medium | low
file: path/to/file
issue: what is wrong
fix_required: concrete change needed
test_required: test, fixture, assertion, or proof command needed
status: open | fixed | wontfix | blocked
evidence: commands run, file paths, or blocker details
Use NO_ISSUES_FOUND only when there are no actionable findings. Use BLOCKED only when the blocker is external or unsafe to resolve inside the workflow.
Choose Your Coordination Style — Conversation vs Pipeline
Before writing the workflow, decide how the agents will coordinate. The relay primitive supports two very different shapes, and picking the wrong one wastes the most valuable thing the SDK gives you.
Shape
What it is
Use when
Conversation (chat-native)
Interactive agents share a channel; messages, @-mentions, and ambient awareness drive coordination. Lead and workers spawn in parallel and self-organize. The relay is the coordination layer, not just transport.
Multi-file work, peer review loops, cross-agent feedback, dynamic re-planning, multi-PR coordination, anything with a human-in-the-loop escape, swarms where workers pick up each other's output.
Pipeline (one-shot DAG)
Each step runs as a one-shot subprocess (claude -p, codex exec); steps hand off via {{steps.X.output}} text injection. No agents are alive at the same time; no chat happens.
Linear, well-specified transformations; deterministic data passing; no live agent-to-agent coordination during implementation. The selected review-depth path and deterministic final gates still apply.
Default to Conversation for any non-trivial work. Pipeline DAGs are simpler to reason about but they do not exercise the relay primitive — they are a Unix pipe with extra steps. If you would happily write the same task as a single shell pipeline, pipeline-shape is fine. Otherwise, you almost certainly want a Conversation shape.
The two shapes can mix within one workflow: pipeline-style deterministic preflight → conversation in the middle → pipeline-style commit-and-PR at the end. See Quick Reference (Conversation) below and Common Patterns → Interactive Team for the canonical recipe.
A blunt rule of thumb: if your workflow only uses agent steps with preset: 'worker' chained by {{steps.X.output}}, you are not using the relay — you are using claude -p | codex exec. That may still be the right answer; just make it a deliberate choice.
Quick Reference (Pipeline shape)
Use this when steps are linear, well-specified, and need no agent-to-agent feedback. For anything with iteration, review, or coordination, jump to Quick Reference (Conversation shape) below.
Note: examples use ESM import syntax, but workflow execution is always wrapped in an async function. See Failure Prevention → Do not use raw top-level await before copy-pasting into CJS or executor-generated files.
import { workflow } from '@relayflows/core';
async function runWorkflow() {
const result = await workflow('my-workflow')
.description('What this workflow does')
.pattern('dag') // or 'pipeline', 'fan-out', etc.
.channel('wf-my-workflow') // dedicated channel (auto-generated if omitted)
.maxConcurrency(3)
.timeout(3_600_000) // global timeout (ms)
.repairable()
.agent('lead', { cli: 'claude', role: 'Architect', retries: 2 })
.agent('worker', { cli: 'codex', role: 'Implementer', retries: 2 })
.agent('claude-reviewer', { cli: 'claude', role: 'First-pass fresh-eyes reviewer', retries: 1, preset: 'reviewer' })
.agent('claude-fixer', { cli: 'claude', role: 'First-pass review-finding fixer', retries: 2 })
.agent('codex-reviewer', { cli: 'codex', role: 'Second-pass fresh-eyes reviewer', retries: 1, preset: 'reviewer' })
.agent('codex-fixer', { cli: 'codex', role: 'Review-finding fixer', retries: 2 })
.step('preflight', {
type: 'deterministic',
command: 'git rev-parse --show-toplevel >/dev/null && echo PREFLIGHT_OK',
captureOutput: true,
failOnError: true,
})
.step('plan', {
agent: 'lead',
dependsOn: ['preflight'],
task: `Analyze the codebase and produce a plan.`,
retries: 2,
verification: { type: 'output_contains', value: 'PLAN_COMPLETE' },
})
.step('implement', {
agent: 'worker',
task: `Implement based on this plan:\n{{steps.plan.output}}`,
dependsOn: ['plan'],
verification: { type: 'exit_code', value: '0' },
})
.step('claude-review', {
agent: 'claude-reviewer',
dependsOn: ['implement'],
task: `Fresh-eyes review the completed workflow output. Read the actual files, diff, repo rules, and available evidence.
Write findings to .workflow-artifacts/my-workflow/claude-review.md.
If there are no actionable issues, write NO_ISSUES_FOUND.`,
verification: { type: 'exit_code', value: '0' },
})
.step('claude-fix', {
agent: 'claude-fixer',
dependsOn: ['claude-review'],
task: `Read .workflow-artifacts/my-workflow/claude-review.md.
Fix every valid issue, add or update appropriate tests/proofs for the fix, rerun relevant checks, and update .workflow-artifacts/my-workflow/claude-fix.md.
If the review says NO_ISSUES_FOUND, record that no fix was needed.`,
verification: { type: 'exit_code', value: '0' },
})
.step('claude-review-final', {
agent: 'claude-reviewer',
dependsOn: ['claude-fix'],
task: `Fresh-eyes review the post-fix state from scratch. Do not rely on the prior review or fix summary.
Write .workflow-artifacts/my-workflow/claude-review-final.md with either actionable findings or NO_ISSUES_FOUND.`,
verification: { type: 'exit_code', value: '0' },
})
.step('claude-fix-final', {
agent: 'claude-fixer',
dependsOn: ['claude-review-final'],
task: `If .workflow-artifacts/my-workflow/claude-review-final.md contains findings, fix them, add or update appropriate tests/proofs, and rerun relevant checks.
If no fix is possible, write .workflow-artifacts/my-workflow/BLOCKED_NO_COMMIT.md with exact evidence.
If it says NO_ISSUES_FOUND, record Claude review signoff.`,
verification: { type: 'exit_code', value: '0' },
})
.step('codex-review', {
agent: 'codex-reviewer',
dependsOn: ['claude-fix-final'],
task: `Second-pass fresh-eyes review of the post-Claude-fix state. Read the actual files, diff, repo rules, and available evidence.
Write findings to .workflow-artifacts/my-workflow/codex-review.md.
If there are no actionable issues, write NO_ISSUES_FOUND.`,
verification: { type: 'exit_code', value: '0' },
})
.step('codex-fix', {
agent: 'codex-fixer',
dependsOn: ['codex-review'],
task: `Read .workflow-artifacts/my-workflow/codex-review.md.
Fix every valid issue, add or update appropriate tests/proofs for the fix, rerun relevant checks, and update .workflow-artifacts/my-workflow/codex-fix.md.
If the review says NO_ISSUES_FOUND, record that no fix was needed.`,
verification: { type: 'exit_code', value: '0' },
})
.step('codex-review-final', {
agent: 'codex-reviewer',
dependsOn: ['codex-fix'],
task: `Fresh-eyes review the post-Codex-fix state from scratch. Do not rely on the prior review or fix summary.
Write .workflow-artifacts/my-workflow/codex-review-final.md with either actionable findings or NO_ISSUES_FOUND.`,
verification: { type: 'exit_code', value: '0' },
})
.step('codex-fix-final', {
agent: 'codex-fixer',
dependsOn: ['codex-review-final'],
task: `If .workflow-artifacts/my-workflow/codex-review-final.md contains findings, fix them, add or update appropriate tests/proofs, and rerun relevant checks.
If no fix is possible, write .workflow-artifacts/my-workflow/BLOCKED_NO_COMMIT.md with exact evidence.
If it says NO_ISSUES_FOUND, record final review signoff.`,
verification: { type: 'exit_code', value: '0' },
})
.step('acceptance-after-review', {
type: 'deterministic',
dependsOn: ['codex-fix-final'],
command: 'test ! -f .workflow-artifacts/my-workflow/BLOCKED_NO_COMMIT.md && echo ACCEPTANCE_OK',
captureOutput: true,
failOnError: true,
})
.onError('retry', { maxRetries: 2, retryDelayMs: 10_000 })
.run({ cwd: process.cwd() });
console.log('Result:', result.status);
}
runWorkflow().catch((error) => {
console.error(error);
process.exit(1);
});
Quick Reference (Conversation shape)
Use this for any non-trivial work — peer review, multi-file edits, cross-agent feedback, dynamic re-planning. Lead and workers spawn in parallel on a shared channel and self-organize via messages. The relay primitive does the coordinating; verification gates downstream of the lead close the workflow.
import { workflow } from '@relayflows/core';
import { ClaudeModels, CodexModels } from '@agent-relay/config';
async function runWorkflow() {
const result = await workflow('my-workflow')
.description('Multi-file change with peer review')
.pattern('dag')
.channel('wf-my-feature') // dedicated channel — agents share it
.maxConcurrency(4)
.timeout(3_600_000)
.repairable()
// Interactive agents — no preset, they live on the channel
.agent('lead', {
cli: 'claude',
model: ClaudeModels.OPUS,
role: 'Architect + reviewer. Plans, assigns, reviews, posts feedback.',
retries: 1,
})
.agent('impl-a', {
cli: 'codex',
model: CodexModels.GPT_5_4,
role: 'Implementer. Listens on channel for assignments and feedback.',
retries: 2,
})
.agent('impl-b', {
cli: 'codex',
model: CodexModels.GPT_5_4,
role: 'Implementer. Listens on channel for assignments and feedback.',
retries: 2,
})
.agent('claude-reviewer', {
cli: 'claude',
model: ClaudeModels.OPUS,
preset: 'reviewer',
role: 'First-pass fresh-eyes reviewer. Reads the final diff and artifacts from scratch.',
retries: 1,
})
.agent('claude-fixer', {
cli: 'claude',
model: ClaudeModels.SONNET,
role: 'First-pass review-finding fixer. Repairs valid findings, adds tests/proofs, and reruns checks.',
retries: 2,
})
.agent('codex-reviewer', {
cli: 'codex',
model: CodexModels.GPT_5_4,
preset: 'reviewer',
role: 'Second-pass fresh-eyes reviewer. Reviews the post-Claude-fix state from scratch.',
retries: 1,
})
.agent('codex-fixer', {
cli: 'codex',
model: CodexModels.GPT_5_4,
role: 'Review-finding fixer. Repairs valid findings, adds tests/proofs, and reruns checks.',
retries: 2,
})
// Deterministic context — pre-reads files once, posts to the channel for everyone
.step('preflight', {
type: 'deterministic',
command: 'git rev-parse --show-toplevel >/dev/null && echo PREFLIGHT_OK',
captureOutput: true,
failOnError: true,
})
.step('context', {
type: 'deterministic',
dependsOn: ['preflight'],
command: 'git ls-files src/',
captureOutput: true,
})
// Lead and workers all depend on `context` — they start CONCURRENTLY.
// They coordinate over #wf-my-feature, not via {{steps.X.output}}.
.step('lead-coordinate', {
agent: 'lead',
dependsOn: ['context'],
task: `You are the lead on #wf-my-feature. Workers: impl-a, impl-b.
Post the plan. Assign files. Review their PRs/diffs. Post feedback in-channel.
Workers iterate based on your feedback. Exit when both files pass review.`,
})
.step('impl-a-work', {
agent: 'impl-a',
dependsOn: ['context'], // SAME dep as lead → starts in parallel, no deadlock
task: `You are impl-a on #wf-my-feature. Wait for the lead's plan.
Implement your assigned file. Post a completion message. Address feedback.`,
})
.step('impl-b-work', {
agent: 'impl-b',
dependsOn: ['context'], // SAME dep as lead
task: `You are impl-b on #wf-my-feature. Wait for the lead's plan.
Implement your assigned file. Post a completion message. Address feedback.`,
})
// Downstream gates on the lead — lead exits when satisfied.
// Capture failures, then hand them to an agent for repair.
.step('verify', {
type: 'deterministic',
dependsOn: ['lead-coordinate'],
command: 'npm run typecheck && npm test 2>&1',
captureOutput: true,
failOnError: false,
})
.step('repair-verify', {
agent: 'lead',
dependsOn: ['verify'],
task: `If verification passed, summarize evidence.
If it failed, use this output to assign and fix issues, then rerun the command until green:
{{steps.verify.output}}`,
verification: { type: 'exit_code', value: '0' },
})
.step('verify-final', {
type: 'deterministic',
dependsOn: ['repair-verify'],
command: 'npm run typecheck && npm test 2>&1',
captureOutput: true,
failOnError: false,
})
.step('claude-review', {
agent: 'claude-reviewer',
dependsOn: ['verify-final'],
task: `First-pass fresh-eyes review of the post-implementation state.
Read the actual changed files, git diff, repo instructions, task spec, and verification output:
{{steps.verify-final.output}}
Write .workflow-artifacts/my-feature/claude-review.md with:
- actionable findings, each with file paths and required fix
- or NO_ISSUES_FOUND if there are no remaining issues`,
verification: { type: 'exit_code', value: '0' },
})
.step('claude-fix', {
agent: 'claude-fixer',
dependsOn: ['claude-review'],
task: `Read .workflow-artifacts/my-feature/claude-review.md.
If there are findings, fix every valid one and add or update appropriate tests/proofs. After each fix, rerun the relevant check and review the changed files again.
Keep iterating locally until this round has no remaining valid issues.
Write .workflow-artifacts/my-feature/claude-fix.md with fixes and commands run.
If the review says NO_ISSUES_FOUND, write that no fix was needed.`,
verification: { type: 'exit_code', value: '0' },
})
.step('claude-review-final', {
agent: 'claude-reviewer',
dependsOn: ['claude-fix'],
task: `Perform a fresh post-fix review from scratch. Do not rely on previous review text or the fixer's summary.
Read files, diff, repo rules, task spec, and evidence. Write .workflow-artifacts/my-feature/claude-review-final.md.
Use NO_ISSUES_FOUND only if there are no actionable issues left.`,
verification: { type: 'exit_code', value: '0' },
})
.step('claude-fix-final', {
agent: 'claude-fixer',
dependsOn: ['claude-review-final'],
task: `If the final Claude review found issues, fix them, add or update appropriate tests/proofs, and rerun the relevant checks until green.
If no fix is possible, write .workflow-artifacts/my-feature/BLOCKED_NO_COMMIT.md with exact evidence and do not commit.
If the final review says NO_ISSUES_FOUND, record signoff in .workflow-artifacts/my-feature/claude-signoff.md.`,
verification: { type: 'exit_code', value: '0' },
})
.step('verify-after-claude-review', {
type: 'deterministic',
dependsOn: ['claude-fix-final'],
command: 'test ! -f .workflow-artifacts/my-feature/BLOCKED_NO_COMMIT.md && npm run typecheck && npm test 2>&1',
captureOutput: true,
failOnError: false,
})
.step('codex-review', {
agent: 'codex-reviewer',
dependsOn: ['verify-after-claude-review'],
task: `Second-pass fresh-eyes review of the post-Claude-fix state.
Read the actual changed files, git diff, repo instructions, task spec, and verification output:
{{steps.verify-after-claude-review.output}}
Write .workflow-artifacts/my-feature/codex-review.md with:
- actionable findings, each with file paths and required fix
- or NO_ISSUES_FOUND if there are no remaining issues`,
verification: { type: 'exit_code', value: '0' },
})
.step('codex-fix', {
agent: 'codex-fixer',
dependsOn: ['codex-review'],
task: `Read .workflow-artifacts/my-feature/codex-review.md.
If there are findings, fix every valid one and add or update appropriate tests/proofs. After each fix, rerun the relevant check and review the changed files again.
Keep iterating locally until this round has no remaining valid issues.
Write .workflow-artifacts/my-feature/codex-fix.md with fixes and commands run.
If the review says NO_ISSUES_FOUND, write that no fix was needed.`,
verification: { type: 'exit_code', value: '0' },
})
.step('codex-review-final', {
agent: 'codex-reviewer',
dependsOn: ['codex-fix'],
task: `Perform a fresh post-Codex-fix review from scratch. Do not rely on previous review text or the fixer's summary.
Read files, diff, repo rules, task spec, and evidence. Write .workflow-artifacts/my-feature/codex-review-final.md.
Use NO_ISSUES_FOUND only if there are no actionable issues left.`,
verification: { type: 'exit_code', value: '0' },
})
.step('codex-fix-final', {
agent: 'codex-fixer',
dependsOn: ['codex-review-final'],
task: `If the final Codex review found issues, fix them, add or update appropriate tests/proofs, and rerun the relevant checks until green.
If no fix is possible, write .workflow-artifacts/my-feature/BLOCKED_NO_COMMIT.md with exact evidence and do not commit.
If the final review says NO_ISSUES_FOUND, record signoff in .workflow-artifacts/my-feature/codex-signoff.md.`,
verification: { type: 'exit_code', value: '0' },
})
.step('verify-after-review', {
type: 'deterministic',
dependsOn: ['codex-fix-final'],
command: 'test ! -f .workflow-artifacts/my-feature/BLOCKED_NO_COMMIT.md && npm run typecheck && npm test 2>&1',
captureOutput: true,
failOnError: true,
})
.onError('retry', { maxRetries: 2, retryDelayMs: 10_000 })
.run({ cwd: process.cwd() });
console.log('Result:', result.status);
}
runWorkflow().catch((error) => {
console.error(error);
process.exit(1);
});
What this exercises that pipeline-shape does not:
Ambient awareness — workers see each other's completion messages and start dependent work without the lead relaying.
Lead-as-reviewer — the lead reads actual files between rounds and posts diff-aware feedback in chat. One agent does coordination + review; no separate reviewer step.
Iterative correction — when the lead pings "impl-a, the type on line 42 is wrong", impl-a fixes and re-posts. No new step, no re-spawn, no {{output}} chaining.
Critical workflow rules for this shape:
Lead and workers MUST share the same dependsOn (e.g., both depend on context). If a worker depends on the lead, you have a deadlock — the lead is waiting for worker output, the worker is waiting for the lead step to "complete."
Drop preset: 'worker' on the implementer agents — interactive mode is what lets them receive channel messages via PTY injection.
Downstream gates depend on the lead step, not the workers. The lead exits when it's satisfied; that's the workflow's signal that implementation is ready for repairable deterministic checks.
Use a dedicated .channel('wf-...') so the team is isolated from other workflows and the global general channel.
See Common Patterns → Interactive Team for production notes from real runs and decision criteria for picking this shape over one-shot DAG.
Default For Serious Implementation: Shadowed Squad Review Loop
When a workflow is expected to produce production-quality code, generated workflows, runtime behavior, or shared execution contracts, use a structured squad-review-loop unless the task is clearly small enough for the lighter shape.
The default unit is a 2-3 agent squad:
implementer: owns a tight file/subsystem scope and writes the change
shadow reviewer: follows the implementer in real time, checks drift against the spec, and leaves feedback early
optional validation owner: owns tests, dry-run proof, or fixture coverage when that is a separate deliverable
Encode the loop explicitly:
Deterministically read the spec, AGENTS.md / CLAUDE.md, workflow standards, recent local docs, and declared file targets.
Lead splits work into bounded squads with non-overlapping ownership.
Squads run in parallel. The shadow reads actual files and channel updates, then posts feedback while the implementer is still active.
Each implementer writes a self-reflection artifact before external review. It must answer: what changed, what spec items are satisfied, what tests/proofs ran, what risks remain, and how the work follows repo rules.
A fresh self-review agent reads the post-implementation files, recent local conventions, AGENTS.md / CLAUDE.md, and related rules. It should not rely on the implementer's summary.
The implementer gets that feedback and performs a repair pass.
Deterministic gates run with captured output. Red output goes to a repair owner, then the same gate reruns.
Run the selected review-depth fresh-eyes loop exactly: light ends after fix-loop and post-fix-validation; standard adds final-review-claude and final-fix-claude; deep adds the full Codex loop after the Claude final fix.
Optional extra reviewers can be added for high-stakes work, but they do not replace the selected review-depth loop.
Final signoff only happens after the selected post-fix review path and final deterministic gates prove the spec is complete, or a blocker artifact explains why it cannot be completed.
For small doc/spec workflows, a lead + author + the selected review-depth review/fix loop is enough. For serious implementation workflows, do not collapse implementer self-reflection, shadow review, independent review, final dual review when selected, and repair into one vague "review" step.
Critical TypeScript rules:
Check the project's package.json for "type": "module" — if ESM, use import; if CJS, use require(). In both cases, wrap execution in an async function instead of raw top-level await.
relayflows run <file.ts> executes the file as a standalone subprocess — it does NOT inspect exports. The file MUST call .run().
Use .run({ cwd: process.cwd() }) — createWorkflowRenderer does not exist
Validate with --dry-run before running: relayflows run --dry-run workflow.ts
.run({ dryRun: true }) returns a different type than .run()..run() resolves to a WorkflowRunRow (which has status); .run({ dryRun: true }) resolves to a DryRunReport, which has no status field at all. So the usual if (result.status !== 'completed') process.exit(1) guard is true on a passing dry run and exits 1. Branch on valid instead:
This is the most important design consideration. Sequential workflows waste hours. Always design for maximum parallelism.
Cross-Workflow Parallelism: Wave Planning
When a project has multiple workflows, group independent ones into parallel waves:
# BAD — sequential (14 hours for 27 workflows at ~30 min each)
relayflows run workflows/34-sst-wiring.ts
relayflows run workflows/35-env-config.ts
relayflows run workflows/36-loading-states.ts
# ... one at a time
# GOOD — parallel waves (3-4 hours for 27 workflows)
# Wave 1: independent infra (parallel)
relayflows run workflows/34-sst-wiring.ts &
relayflows run workflows/35-env-config.ts &
relayflows run workflows/36-loading-states.ts &
relayflows run workflows/37-responsive.ts &
wait
git add -A && git commit -m "Wave 1"
# Wave 2: testing (parallel — independent test suites)
relayflows run workflows/40-unit-tests.ts &
relayflows run workflows/41-integration-tests.ts &
relayflows run workflows/42-e2e-tests.ts &
wait
git add -A && git commit -m "Wave 2"
Wave Planning Heuristics
Two workflows can run in parallel if they don't have write-write or write-read file conflicts:
Touch Zone
Can Parallelize?
Different packages/*/src/ dirs
✅ Yes
Different app/ routes
✅ Yes
Same package, different subdirs
⚠️ Usually yes
Same files (shared config, root package.json)
❌ No — sequential or same wave with merge
Explicit dependency
❌ No — ordered waves
Declare File Scope for Planning
Help wave planners (human or automated) understand what each workflow touches:
Do not end workflow files with bare top-level await workflow(...).run(...).
1b. Make commit and PR boundaries explicit
Workflows do not get a PR for free just because they pass validation. If the intended deliverable is a branch, commit, push, or GitHub PR, the workflow itself must own that boundary explicitly and document the expected file scope.
Use this pattern only when the workflow is supposed to own repository delivery:
Preflight the git state and fail on unexpected staged changes.
Create or verify the intended branch.
Run implementation, repairable validation, the selected review-depth review/fix loops, and final acceptance gates.
Stage only the declared target files and review/signoff artifacts.
Commit with a deterministic message.
Push the branch.
Open the PR from a deterministic gh pr create step (its stdout is the PR URL, which the pr_url gate needs). Use createGitHubStep only in an agent-free workflow where you inject GitHubStepExecutor — see "Integration steps need an executor".
Verify the PR URL/state deterministically and write it into the final signoff artifact.
Do not hide commit/PR work in agent prose. Model it as deterministic steps whenever possible. createGitHubStep (from @relayflows/core/integrations/github) is the nicer shape for GitHub operations — typed actions, no shell quoting — but it only runs when the runner has an executor implementing executeIntegrationStep, which rules it out for any workflow that also has agent steps. Use a deterministic gh step there. The downstream acceptance gate must still verify the PR exists before signoff, and any PR creation failure should route to a repair step before the workflow stops.
If commit or PR creation is intentionally outside the workflow, say that directly in the workflow description and signoff so the operator knows to do it after completion.
Raw triple-backtick code fences inside large inline task: \...`template strings are fragile and can break outer TypeScript parsing, especially when they contain language tags likeswiftordiff`.
Preferred options, in order:
Avoid inline fenced examples entirely
Move larger examples to referenced files
Use plain indented examples instead of fenced blocks
If fenced blocks must exist inside generated inner code, escape them consistently and syntax-check the outer workflow file afterward
2b. Standard preflight template for resumable workflows
Every non-trivial workflow should start with a deterministic preflight step that validates the environment before any agent runs. A workflow that fails mid-DAG and gets re-run (or resumed via --start-from) will re-execute preflight, so preflight must tolerate the partial state left behind by the previous run — specifically, dirty files that the workflow itself is expected to edit.
The battle-tested template:
.step('preflight', {
type: 'deterministic',
command: [
'set -e',
'BRANCH=$(git rev-parse --abbrev-ref HEAD)',
'echo "branch: $BRANCH"',
'if [ "$BRANCH" != "fix/your-branch-name" ]; then echo "ERROR: wrong branch"; exit 1; fi',
// Files the workflow is allowed to find dirty on entry:
// - package-lock.json: npm install is idempotent and often touches it
// - every file the workflow's edit steps will rewrite: a prior partial
// run may have left them dirty, and the edit step will rewrite
// them cleanly before commit
// Everything else is unexpected drift and must fail preflight.
'ALLOWED_DIRTY="package-lock.json|path/to/file1\\\\.ts|path/to/file2\\\\.ts"',
'DIRTY=$(git diff --name-only | grep -vE "^(${ALLOWED_DIRTY})$" || true)',
'if [ -n "$DIRTY" ]; then echo "ERROR: unexpected tracked drift:"; echo "$DIRTY"; exit 1; fi',
'if ! git diff --cached --quiet; then echo "ERROR: staging area is dirty"; git diff --cached --stat; exit 1; fi',
'gh auth status >/dev/null 2>&1 || (echo "ERROR: gh CLI not authenticated"; exit 1)',
'echo PREFLIGHT_OK',
].join(' && '),
captureOutput: true,
failOnError: true,
}),
Rules baked into this template:
Always include package-lock.json in ALLOWED_DIRTY. Both npm install and npm ci can touch it idempotently.
Include every file the workflow's edit steps will rewrite. The commit step uses explicit git add <path> (never git add -A), so allowing these files to be dirty on entry is safe — unrelated drift in other files still fails preflight.
Escape dots in regex paths:setup\.ts not setup.ts. In a JS template literal this means four backslashes: "setup\\\\.ts".
Use grep -vE "^(...)$" for full-line match. Substring matches bleed across unrelated files (e.g., setup.ts would also match packages/core/src/bootstrap/setup.ts).
Append || true to the grep. Without it, an empty result triggers set -e and the whole preflight fails before the if can even run.
Check the staging area separately. A dirty index is different from a dirty working tree and both must be clean (modulo allow-list).
Check gh auth status early if downstream GitHub operations will use the local transport. Failing on auth at the end of a long DAG is painful.
Never use git diff --quiet alone as your "clean tree" check. It fails on any dirty file, including the ones the workflow is expected to rewrite, which causes false failures on every resume / re-run.
2c. Picking the right .join() for multi-line shell commands
When a command: field is a JS array that gets joined into a shell command string, the join delimiter determines what kinds of content the array can contain.
.join(' && ') — use when every element is a self-contained shell statement. Each element becomes independent and the next one runs only if the previous succeeded. Works for linear scripts with set -e.
.join('\n') — use when array elements must be part of a larger compound statement that spans multiple physical lines:
heredocs (cat <<EOF ... EOF)
multi-line if / while / for bodies
shell functions defined inline
&& is a command separator. It cannot appear between a heredoc's opening line and its body, between a for and its body, or inside an if's consequent block. Joining such content with && produces a shell syntax error.
Never mix heredocs with && joining. The most common failure mode:
// ❌ BROKEN — heredoc body gets && inserted between each line
command: [
'set -e',
'cat > /tmp/f <<EOF',
'line 1',
'line 2',
'EOF',
'next-command',
].join(' && '),
Results in set -e && cat > /tmp/f <<EOF && line 1 && line 2 && EOF && next-command — a shell syntax error because && cannot appear inside a heredoc body. Use .join('\n') or (better) sidestep the heredoc entirely.
The printf + mktemp alternative — use this for commit messages, raw-CLI fallback PR bodies, and any other multi-line file content. It avoids heredocs altogether and composes with .join(' && '):
command: [
'set -e',
'BODY=$(mktemp)',
// Each line of the file is a separate printf argument. No heredoc,
// no shell metacharacter hazards, no command-substitution nesting.
'printf "%s\\n" "## Summary" "" "body line 1" "body line 2" > "$BODY"',
'gh pr create --title "..." --body-file "$BODY"',
'rm -f "$BODY"',
].join(' && '),
This pattern is specifically recommended over git commit -m "$(cat <<'EOF' ... EOF)" and raw gh pr create --body "$(cat <<'BODY' ... BODY)". Nesting a heredoc inside $(...) forces the shell to match a closing paren across many lines of unparsed body text, and any stray parenthesis in the body text can silently break the match. --body-file + mktemp + printf is immune to that entire class of bug. For workflow-owned PR creation, prefer createGitHubStep over raw gh; this shell pattern is for raw CLI fallback cases.
2d. Template-literal escape sequences are processed once before the string is rendered
If your file generates code as a giant template literal (the pattern used by packages/core/src/bootstrap/script-generator.ts in cloud), every backslash in that template gets processed by JavaScript before the string is returned. This silently breaks regexes and escape sequences that are meant to appear in the generated output.
Specifically:
\s is not a recognized string escape → the backslash is stripped → \s renders as a literal s
\bis a recognized string escape (backspace, U+0008) → \b renders as a backspace character in the output
\n, \t, \r, \\, \0, \uXXXX, \xXX all get resolved at template time
The footgun: the outer TypeScript compiles cleanly, the rendered code parses and runs, and the regex/escape just never matches what the author intended. See AgentWorkforce/cloud#113 for the exact incident (hasConfigExport = /^export\s+.../m silently became /^exports+.../m in the generated bootstrap, making every TS workflow fall through to the standalone-script fallback).
Guidelines:
If you want a regex pattern that survives the template-literal pass unchanged, double every backslash in the source: \\s, \\b, \\n (the \\ renders to \ in the output, producing a correct regex at runtime).
If you want to write a long string-literal newline into the output, '\\n' in the template renders to '\n' in the output, which the runtime JS interprets as a newline. Using a literal '\n' would render an actual newline into the JS source — visually messy and sometimes surprising.
If you add anything non-trivial to a generator file that returns a big template literal, add a unit test that calls the generator with canonical inputs and asserts something about the rendered output — either exact string matches or, for regexes, eval/construct the regex and test it against known samples. See tests/orchestrator/script-generator.test.ts in cloud for prior art.
Task-prompt workaround: for agent-relay workflow task prompts (where the contents go into a template literal but the inner content is plain text for an LLM), it's often cleaner to build the string as an array and .join('\n') at the boundary. That sidesteps the "does this backslash survive?" question entirely — no backslashes in the source, no processing to reason about. Several workflows in cloud/workflows/ use this pattern (see the sage migration PRs).
3. Keep final verification boring and deterministic
Final verification should validate real outputs with simple, portable shell commands. If checking for multiple symbols, use extended regex explicitly:
grep -Eq "foo|bar|baz" file.ts
Do not rely on basic grep alternation like:
grep -c "foo\|bar\|baz" file.ts
That can silently misbehave and create fake failures even when the generated code is correct.
4. Separate durable outputs from execution exhaust
Commit:
generated product code
migrations
tests
docs
workflow-definition fixes
Do not commit by default:
.logs/
transient executor output
retry artifacts
temporary step-output files
5. Prefer Codex for implementation-heavy roles and deep-tier second review loops
Default team split for workflow-authored agent roles:
lead / implementer / writer / fixer → codex
first fresh-eyes review loop → claude
deep-tier second fresh-eyes review loop → codex
Use Claude as the primary implementer only when there is a specific reason. Use only one reviewer CLI only when the target environment cannot run the other, and record that limitation in the workflow artifact.
6. Be explicit about shell requirements
If executor scripts use Bash-only features such as associative arrays, require modern Bash explicitly. On macOS, prefer a known-good Bash path when needed, for example:
Do not assume users will infer the behavior. In particular, --wave N should be understood as "run only this wave" unless the executor explicitly chains onward.
7a. --resume vs --start-from when fixing a buggy step
When a workflow fails at step X and you want to re-run it after editing the workflow file, the flag choice matters:
Flag
Reads workflow file fresh?
Uses cached step outputs?
--resume <id>
❌ replays stored config from DB
✅ from same run id
--start-from <step> --previous-run-id <id>
✅ reads fresh file
✅ from previous run id's cached outputs
Rule: if you edited the workflow file to fix the failing step, use --start-from <failing-step> --previous-run-id <id>, not--resume <id>. --resume pulls the entire workflow config from the run's DB record and replays it — your edits to the workflow file are ignored, and the step re-runs with its original (broken) definition.
This is counterintuitive because "resume" sounds like "pick up where you left off with whatever I just changed." It does not. It picks up where you left off with the stored config from when the run first started.
When to use each:
Transient failure (network hiccup, rate limit, flaky agent), no code edits: --resume <id> is fine, fast, and correct.
You edited the workflow file (any step definition, any prompt, any verify gate): always--start-from <failing-step> --previous-run-id <id>. Everything upstream of the failing step loads from cache, the fresh file supplies the fixed definition, and downstream steps run as normal.
If the runner complains that --start-from can't find cached outputs for the previous run id, fall back to a clean from-scratch run. The workflow's preflight should be forgiving enough (see §2b "Standard preflight template") that a from-scratch re-run succeeds even when a prior partial run left files dirty.
8. Syntax-check workflow files after editing
After editing workflow .ts files, run a lightweight syntax check before launching a large batch run. This is especially important if the workflow contains:
large inline task template literals
embedded code examples
escaped backticks
wrapper changes around workflow execution
9. Factor repo-specific setup into a shared helper
If multiple workflows in the same repo need the same boilerplate before any agent touches code (branch checkout, npm install, workspace-package prebuild, language toolchain init, etc.), do not copy-paste those steps into every workflow. Put them in workflows/lib/<repo>-setup.ts and import from there.
Why it matters: without a shared helper, the first workflow that needs a new prerequisite step (e.g. npm run build:platform because a workspace package's package.json points types at dist/) adds it locally, and every other workflow silently misses it. In a fresh cloud sandbox that means agents hit Cannot find module '@cloud/platform' during typecheck and paper over it with ad-hoc external-modules.d.ts shims or as GetObjectCommandOutput casts scattered across unrelated files. Those workarounds sync back down with the patch and pollute the PR.
The helper lives in the consumer repo, not in the SDK. Different customer repos have different languages, package managers, and build graphs — @agent-relay/sdk should stay agnostic.
Pre-build any workspace package whose package.jsonmain/types point at a generated dist/. Fresh sandboxes don't have that dist/ yet, and agents will invent workarounds rather than run the build. See the @cloud/platform case above.
Every install step includes --legacy-peer-deps --no-audit --no-fund 2>&1 | tail -10 (or equivalent noise-trimming) because full install output blows past captureOutput size limits.
Document the helper in the repo's CLAUDE.md / AGENTS.md so new workflow authors (and agents writing workflows) discover it.
End-to-End Bug Fix Workflows
For bug-fix or reliability workflows, do not stop at unit or integration tests. The workflow should explicitly prove that the original user-visible problem is fixed.
Required phases for fix workflows
Capture the original failure
Reproduce the bug first in a deterministic or evidence-capturing step
Save exact commands, logs, status codes, or screenshots/artifacts
State the acceptance contract
Define the exact end-to-end success criteria before implementation
Include the real entrypoint a user would run
Implement the fix
Rebuild / reinstall from scratch
Do not trust dirty local state
Prefer a clean environment when install/bootstrap behavior is involved
Run targeted regression checks
Unit/integration tests are helpful but not sufficient by themselves
Run a full end-to-end validation
Use the real CLI / API / install path
Prefer a clean environment (Docker, sandbox, cloud workspace, Daytona, etc.) for install/runtime issues
Compare before vs after evidence
Show that the original failure no longer occurs
Record residual risks
Call out what was not covered
Ship the result as a PR
Open the pull request from the workflow itself with createGitHubStep from @relayflows/core/integrations/github — nevergh pr create, never id: inside the config, never action: 'createPullRequest', never separate owner/repo fields
A workflow that fixes a bug and stops short of the PR has only done half the loop
Clean-environment validation guidance
When the bug involves install, bootstrap, PATH/shims, auth, brokers, background services, OS-specific packaging, or first-run UX, add a second workflow (or second phase) that validates the fix in a fresh environment.
Preferred order of proving environments:
disposable sandbox / cloud workspace
Docker / containerized environment
fresh local shell with isolated paths
Meta-workflow guidance
If the right proving environment is unclear, first write a meta-workflow that:
compares candidate validation environments
defines the acceptance contract
chooses the best swarm pattern
then authors the final fix/validation workflow
This is often better than jumping straight to implementation.
Shipping the Result — Open a PR via createGitHubStep
A workflow whose final artifact is "a clean working tree on a sandbox you'll throw away" has not shipped anything. End every code-changing workflow by opening a pull request, and do it from inside the workflow using createGitHubStep from @relayflows/core/integrations/github. Don't tell the operator to follow up with gh pr create — make the workflow's own last step the PR.
Why createGitHubStep (and not raw gh / octokit)
The primitive itself can pick a transport at runtime (gh CLI, Nango → the workspace's GitHub App, or the relay-cloud GitHub proxy). But the workflow runner will not call it unless you hand the runner an executor that implements executeIntegrationStep, and neither the local default nor the cloud runtime does. See "Integration steps need an executor" below before choosing this shape — for most workflows the answer is a deterministic gh step.
Phase C interaction (cloud only):agent-relay cloud run already auto-pushes per-paths[] diffs as separate PRs after the workflow callback when the repos are allowlisted (see pushedTo in the run record). Phase C is the catch-all — if your workflow does nothing else, you still get one PR per declared path. Use createGitHubStepon top of that when you need PRs the catch-all can't produce: cross-cutting issues, follow-up tracking issues, opening one PR that spans multiple paths, draft PRs you want labeled/assigned in specific ways, or PRs against a repo you didn't paths[] in.
The minimal "open a PR" recipe
This is the shape that runs today, in any workflow, local or cloud. It is the one in examples/ship-issue.local.ts, which was executed end-to-end and opened a real PR.
import { workflow, WorkflowRunner } from '@relayflows/core';
const REPO = 'AgentWorkforce/cloud';
const BRANCH = `agent-relay/run-${Date.now()}`;
async function runWorkflow() {
const config = workflow('feature-x')
.agent('worker', { cli: 'claude', preset: 'worker', cwd: WORKDIR })
// ... your real implementation, repair, review loops, and final acceptance ...
.step('implement', {
agent: 'worker',
task: `Make the change, then: git checkout -b ${BRANCH} && git add -A && git commit -m "..." && git push -u origin ${BRANCH}`,
verification: {
type: 'custom',
value: `git ls-remote --exit-code --heads origin ${BRANCH}`,
description: 'branch was actually pushed',
},
})
// The load-bearing step. `gh pr create` prints the PR URL on stdout, which
// is exactly what the pr_url gate needs.
.step('open-pr', {
type: 'deterministic',
dependsOn: ['implement'],
cwd: WORKDIR,
command: `gh pr create --repo ${REPO} --head ${BRANCH} --base main --title "feat: ship feature X" --body-file pr-body.md`,
verification: { type: 'pr_url', value: REPO },
})
.toConfig();
// Verification runs in the RUNNER's cwd, not the step's or agent's.
const row = await new WorkflowRunner({ cwd: WORKDIR }).execute(config);
if (row.status !== 'completed') process.exitCode = 1;
}
The createGitHubStep variant
Only for a workflow with no agent steps, where you inject the executor yourself. Note both departures from the old guidance: integration steps are spliced into the built config (.step() rejects them), and the runner needs an explicit executor.
import { workflow, WorkflowRunner } from '@relayflows/core';
import { createGitHubStep, GitHubStepExecutor } from '@relayflows/core/integrations/github';
const config = workflow('open-pr').agent('unused', { cli: 'claude' })
.step('report', { type: 'deterministic', dependsOn: ['open-pr'], command: `echo "https://github.com/${REPO}/pull/{{steps.open-pr.output}}"`, verification: { type: 'pr_url', value: REPO } })
.toConfig();
config.workflows[0].steps.unshift(createGitHubStep({
name: 'open-pr',
action: 'createPR',
repo: REPO,
params: { title: 'feat: ship feature X', head: BRANCH, base: 'main', body: '## Summary\n\n- ...' },
// The mapped PullRequest has NO url field — `number` is the identifier.
output: { mode: 'data', format: 'json', path: 'number' },
}));
await new WorkflowRunner({
cwd: WORKDIR,
executor: new GitHubStepExecutor({ runtime: 'local' }),
}).execute(config);
createGitHubStep ships in @relayflows/core (which depends on @relayflows/github-primitive); do not add a separate install. The action enum is exactly 22 values — GITHUB_ACTIONS from @relayflows/github-primitive:
Anything else throws uses unsupported action "<name>" at build time. Note there is no getIssue — to read one issue, use listIssues and select from the array.
Common authoring mistakes that cause startup parse errors
These produce hard errors at workflow boot (before any step runs), not at runtime. createGitHubStep validates its config on construction, and the builder validates step shape before the workflow can start.
Mistake
Correct form
.step('open-pr', createGitHubStep({...})) — any form
.step() REJECTS integration steps entirely: it throws Agent steps must have both agent and task. Splice into config.workflows[0].steps after .toConfig() — see below.
createGitHubStep({ id: 'open-pr', ... })
No id field — use name: 'open-pr', which must be set because the spliced step carries its own name
action: 'createPullRequest'
action: 'createPR' (camelCase enum, not the GitHub API method name)
owner: 'AgentWorkforce', repo: 'nightcto'
repo: 'AgentWorkforce/nightcto' — single owner/repo string
import { createGitHubStep } from '@agent-relay/sdk' (any subpath)
import { createGitHubStep } from '@relayflows/core/integrations/github'
createGitHubStep has no command field. Use GitHub primitive fields (name, action, repo, params) instead of shell-step shape.
Integration steps do not go through the builder
WorkflowBuilder.step() only understands three shapes — agent, type: 'deterministic', and type: 'worktree'. A createGitHubStep() result is type: 'integration', falls through to the agent branch, and throws:
Error: Agent steps must have both agent and task
Build the config first, then splice integration steps into it:
Integration steps require a cloud executor. Step "find-issue" cannot run locally.
The actual guard is if (!this.executor?.executeIntegrationStep).
That error message's advice is wrong — cloud does not supply one either.SandboxedStepExecutor (exported as DaytonaStepExecutor, in cloud/packages/core/src/executor/executor.ts) implements exactly executeAgentStep and executeDeterministicStep, and bootstrap-inner.mjs passes that object straight to new WorkflowRunner({ executor, ... }). Nothing in the cloud repo implements executeIntegrationStep.
The only executor that does is GitHubStepExecutor. So, as of 2026-08-31:
Workflow shape
GitHub integration steps?
No agent steps, local, new WorkflowRunner({ executor: new GitHubStepExecutor({ runtime: 'local' }) })
Works — see examples/github-steps.local.ts
Has agent steps, local
No. Setting executor also routes agent steps through it, and GitHubStepExecutor.executeAgentStep() throws
Cloud (agent-relay cloud run)
No. The cloud executor has no executeIntegrationStep
So a workflow that mixes agent steps with GitHub work must use deterministic gh steps today. That is the one legitimate use of gh in a workflow — see examples/ship-issue.local.ts, which runs green end-to-end and satisfies a pr_url gate. examples/integration-steps-need-an-executor.ts reproduces the failure against the cloud executor's exact interface shape.
Authoring rules for PR-shipping workflows
Use a deterministic gh pr create step for any workflow that also has agent steps.createGitHubStep is the nicer shape — PR creation stays inside the DAG with real retry and verification — but it needs an executor that neither the local default nor the cloud runtime provides (above). A deterministic gh step satisfies a pr_url gate just as well, and it is proven. Reach for createGitHubStep only in an agent-free workflow where you inject GitHubStepExecutor yourself.
One PR per workflow, by default. A workflow that opens five PRs from one run is almost always wrong — humans review one PR at a time. If you genuinely need multiple, prefer a tracking issue + linked PRs, or split into separate workflows.
Branch name encodes the run.agent-relay/run-${runId} or agent-relay/${workflow-name}-${timestamp} so reviewers can tell the PR apart from other automation, and so reruns don't clash.
draft: true while iterating. Once the workflow is stable end-to-end, flip to draft: false.
Body is a real PR description. Summary + Test plan, generated from the workflow's own evidence (verification step output, diff stats, test run output). If you find yourself writing a placeholder body, the workflow isn't done — capture the real evidence in an earlier step and template it in.
Don't use createGitHubStep to substitute for paths[] push-back in cloud. If the diff lives in a tarballed paths[] mount, let cloud's Phase C push-back open that PR (it handles the patch generation, branch lifecycle, and per-repo allowlist). Use createGitHubStep when you need a PR against a repo or branch outside the paths[] set, or when you want to add an extra PR (e.g. a tracking issue, a follow-up against a sibling repo, a docs-only PR).
PR creation failures route to repair. If createPR errors (auth, permissions, branch conflict), capture the output and give a repair owner a chance to fix auth, branch state, labels, or body generation before stopping. A "successful" workflow that silently failed to open the PR is the worst-case outcome — the human thinks the work shipped.
Where this fits in the bug-fix phases
End-to-End Bug Fix Workflows lists "Ship the result as a PR" as phase 9. Concretely that means: after phase 7 (compare before/after evidence) succeeds, the workflow's next step is createPR with that evidence templated into the body. The PR opening is the ship — there is no further manual step.
Key Concepts
Step Output Chaining
Use {{steps.STEP_NAME.output}} in a downstream step's task to inject the prior step's terminal output.
Mental model: this is a Unix pipe, not agent communication. {{steps.A.output}} flowing into step B is A | B — A is dead by the time B reads its stdout. There is no chat, no feedback, no addressing. If your workflow's coordination story is only output chaining, you're using the relay as transport, not as a coordination layer. See Choose Your Coordination Style before defaulting to this.
Never chain from interactive agents (cli: 'claude' without preset) — PTY output includes spinners, ANSI codes, and TUI chrome. Instead, have the agent write to a file, then read it in a deterministic step. (Or: don't use chaining at all — let the agents coordinate over the channel.)
Only these five types are valid: exit_code, output_contains, file_exists, custom, pr_url. Invalid types are silently ignored and fall through to process-exit auto-pass.
value is required on every type, exit_code included. It is value: string on VerificationCheck, not optional, so { type: 'exit_code' } fails to typecheck with TS2741: Property 'value' is missing. Write { type: 'exit_code', value: '0' } — verified: the gate passes a step that exits 0 and fails one that exits 3. For pr_url alone, an empty value: '' is meaningful (accept any GitHub PR URL); everywhere else an empty value is a mistake.
In YAML there is no typechecker, and nothing else catches it either.validateWorkflow() returns zero issues for verification: {type: exit_code} with no value, so it passes validation and passes a dry run — then fails at run time on a step that did everything right. The check is Number(check.value) === exitCode, and Number(undefined) is NaN, so the comparison can never be true: Verification failed for "edit": recorded exit code "0" did not match "undefined". Every YAML recipe below carries value: '0' for this reason.
Use pr_url for any step whose deliverable is a published change — opening a PR, merging a branch, publishing a package. It blocks the common failure mode where a worker produces green tests and posts OWNER_DECISION: COMPLETE but never actually opened a PR. Pass <owner>/<repo> to require the URL belongs to a specific repository, or leave value: '' to accept any GitHub PR URL in the step output.
Put the gate on a step whose output actually contains a URL. A createGitHubStep({ action: 'createPR' }) output does not — the mapped PullRequest has no URL field, so a pr_url gate on the create step can never fire. Either gate a deterministic gh pr create step (its stdout is the URL), or capture path: 'number' from the integration step and gate the echoed URL:
Verification checks always run in the runner's cwd — never the step's or the agent's. The runner calls runVerification(..., { ...options, cwd: this.cwd }), so a custom check like git ls-remote ... runs wherever the runner was constructed, not where the agent worked. Set it explicitly: new WorkflowRunner({ cwd: WORKDIR }). Getting this wrong fails a step whose work was actually correct.
Verification token gotcha: If the token appears in the task text, the runner requires it twice in output (once from task echo, once from agent). Prefer exit_code for code-editing steps to avoid this.