writing-agent-relay-workflows-ffe26f — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited writing-agent-relay-workflows-ffe26f (Agent Skill) and scored it 96/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 1 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 1 flagged
The text {match} tells the agent to skip the normal "ask the user first" gate. Used adversarially it removes the human-in-the-loop check before destructive or sensitive actions, turning a normally-gated agent into a fire-and-forget executor.
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
The @relayflows/core 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.
Every generated workflow should satisfy this checklist before it is considered complete:
failOnError: false, hand it to a repair owner, then rerun the same check.BLOCKED_NO_COMMIT with exact evidence and skip commit/PR creation instead of crashing the workflow.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:
captureOutput: true.failOnError: false for intermediate validation gates so the workflow can pass the output to a repair agent.{{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.FAILED..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.
Every workflow must include two comprehensive fresh-eyes review/fix loops before final acceptance, commit, PR creation, or handoff: first Claude, then Codex. This applies even to small workflows and even when deterministic tests pass. Tests prove commands passed; the fresh-eyes loops make independent agents read the actual resulting files and artifacts as if they did not author them.
The required 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.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.
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 detailsUse 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.
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 mandatory final Claude-then-Codex review/fix loops 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](#interactive-team-lead--workers-on-shared-channel) for the canonical recipe.
A blunt rule of thumb: if your workflow only usesagentsteps withpreset: 'worker'chained by{{steps.X.output}}, you are not using the relay — you are usingclaude -p | codex exec. That may still be the right answer; just make it a deliberate choice.
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' },
})
.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' },
})
.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' },
})
.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' },
})
.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' },
})
.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' },
})
.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' },
})
.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' },
})
.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' },
})
.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);
});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' },
})
.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' },
})
.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' },
})
.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' },
})
.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' },
})
.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' },
})
.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' },
})
.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' },
})
.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' },
})
.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:
{{output}} chaining.Critical workflow rules for this shape:
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."preset: 'worker' on the implementer agents — interactive mode is what lets them receive channel messages via PTY injection..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.
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:
Encode the loop explicitly:
For small doc/spec workflows, a lead + author + the mandatory Claude-then-Codex review/fix loops is enough. For serious implementation workflows, do not collapse implementer self-reflection, shadow review, independent review, final dual review, and repair into one vague "review" step.
Critical TypeScript rules:
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.agent-relay local run <file.ts> executes the file as a standalone subprocess — it does NOT inspect exports. The file MUST call .run()..run({ cwd: process.cwd() }) — createWorkflowRenderer does not exist.run({ dryRun: true, cwd: process.cwd() }) or runWorkflow(path, { dryRun: true }) from TypeScript. Use agent-relay local run <file> for execution.This is the most important design consideration. Sequential workflows waste hours. Always design for maximum parallelism.
When a project has multiple workflows, group independent ones into parallel waves:
# BAD — sequential (14 hours for 27 workflows at ~30 min each)
agent-relay local run workflows/34-sst-wiring.ts
agent-relay local run workflows/35-env-config.ts
agent-relay local run workflows/36-loading-states.ts
# ... one at a time
# GOOD — parallel waves (3-4 hours for 27 workflows)
# Wave 1: independent infra (parallel)
agent-relay local run workflows/34-sst-wiring.ts &
agent-relay local run workflows/35-env-config.ts &
agent-relay local run workflows/36-loading-states.ts &
agent-relay local run workflows/37-responsive.ts &
wait
git add -A && git commit -m "Wave 1"
# Wave 2: testing (parallel — independent test suites)
agent-relay local run workflows/40-unit-tests.ts &
agent-relay local run workflows/41-integration-tests.ts &
agent-relay local run workflows/42-e2e-tests.ts &
wait
git add -A && git commit -m "Wave 2"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 |
Help wave planners (human or automated) understand what each workflow touches:
workflow('48-comparison-mode')
.packages(['web', 'core']) // monorepo packages touched
.isolatedFrom(['49-feedback-system']) // explicitly safe to parallelize
.requiresBefore(['46-admin-dashboard']); // explicit ordering constraintUse shared dependsOn to fan out independent sub-tasks:
// BAD — unnecessary sequential chain
.step('fix-component-a', { agent: 'worker', dependsOn: ['review'] })
.step('fix-component-b', { agent: 'worker', dependsOn: ['fix-component-a'] }) // why wait?
// GOOD — parallel fan-out, merge at the end
.step('fix-component-a', { agent: 'impl-1', dependsOn: ['review'] })
.step('fix-component-b', { agent: 'impl-2', dependsOn: ['review'] }) // same dep = parallel
.step('verify-all', { agent: 'reviewer', dependsOn: ['fix-component-a', 'fix-component-b'] })Real-world example (Relayed — 60 workflows):
These workflow files are easy to break in ways that only appear mid-run. Follow these rules when authoring or editing workflow .ts files.
awaitExecutor-driven workflow files may be run through a tsx/esbuild path that behaves like CJS. Raw top-level await can fail with:
Top-level await is currently not supported with the "cjs" output formatAlways wrap execution like this:
async function runWorkflow() {
const result = await workflow('my-workflow')
// ...
.run({ cwd: process.cwd() });
console.log('Workflow status:', result.status);
}
runWorkflow().catch((error) => {
console.error(error);
process.exit(1);
});Do not end workflow files with bare top-level await workflow(...).run(...).
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:
git/gh locally or a script that uses GitHubClient from @relayflows/github-primitive.Do not hide commit/PR work in agent prose. Model it as deterministic steps whenever possible. For local workflows, use git and gh after a preflight that proves gh auth status works. For adapter-based GitHub operations, use a deterministic script that imports GitHubClient from @relayflows/github-primitive. 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:
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:
ALLOWED_DIRTY. Both npm install and npm ci can touch it idempotently.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.setup\.ts not setup.ts. In a JS template literal this means four backslashes: "setup\\\\.ts".setup.ts would also match packages/core/src/bootstrap/setup.ts).set -e and the whole preflight fails before the if can even run.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.
.join() for multi-line shell commandsWhen 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.
command: [
'set -e',
'HITS=$(grep -c diag src/cli/commands/setup.ts || true)',
'if [ "$HITS" -lt 6 ]; then echo "FAIL"; exit 1; fi',
'echo OK',
].join(' && '),`.join('\n')` — use when array elements must be part of a larger compound statement that spans multiple physical lines:
cat <<EOF ... EOF)if / while / for bodies&& 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 when a workflow uses raw gh commands.
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\b _is_ 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 timeThe 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:
\\s, \\b, \\n (the \\ renders to \ in the output, producing a correct regex at runtime).'\\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.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).
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.tsDo not rely on basic grep alternation like:
grep -c "foo\|bar\|baz" file.tsThat can silently misbehave and create fake failures even when the generated code is correct.
Commit:
Do not commit by default:
.logs/Default team split for workflow-authored agent roles:
codexclaudecodexUse 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.
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:
/opt/homebrew/bin/bash workflows/your-workflow/execute.sh --wave 2Document clearly whether the executor supports:
--wave--workflow--resumeDo 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.
--resume vs --start-from when fixing a buggy stepWhen 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:
--resume <id> is fine, fast, and correct.--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.
After editing workflow .ts files, run a lightweight syntax check before launching a large batch run. This is especially important if the workflow contains:
task template literalsIf 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.
Pattern:
// workflows/lib/cloud-repo-setup.ts
export interface CloudRepoSetupOptions {
branch: string;
committerName?: string;
extraSetupCommands?: string[];
skipWorkspaceBuild?: boolean;
}
export function applyCloudRepoSetup<T>(wf: T, opts: CloudRepoSetupOptions): T {
// adds two steps: setup-branch, install-deps
// install-deps runs: npm install + workspace prebuilds (build:platform, build:core, etc.)
// ...
}Consumer workflows break the builder chain once and call through:
const baseWf = workflow(NAME)
.description(...)
.pattern('dag')
.agent(...)
.agent(...);
const wf = applyCloudRepoSetup(baseWf, {
branch: BRANCH,
committerName: 'My Workflow Bot',
});
await wf
.step('read-spec', { dependsOn: ['install-deps'], ... })
...
.run(...);Rules:
@agent-relay/sdk should stay agnostic.package.json main/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.--legacy-peer-deps --no-audit --no-fund 2>&1 | tail -10 (or equivalent noise-trimming) because full install output blows past captureOutput size limits.CLAUDE.md / AGENTS.md so new workflow authors (and agents writing workflows) discover it.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.
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:
If the right proving environment is unclear, first write a meta-workflow that:
This is often better than jumping straight to implementation.
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. Don't tell the operator to follow up with gh pr create; make the workflow's own last step the PR when the workflow owns shipping.
Current @relayflows/core does not provide createGitHubStep. Use one of these current surfaces:
| Where the workflow runs | Current PR surface | What you provide |
|---|---|---|
Local (agent-relay local run) | Deterministic git and gh steps | gh auth status works |
| Adapter-based script | GitHubClient from @relayflows/github-primitive | Runtime config or environment credentials |
Cloud (agent-relay cloud run) | Cloud push-back for declared paths[], when configured | Allowlisted repo paths |
import { workflow } from '@relayflows/core';
const BRANCH = `agent-relay/run-${Date.now()}`;
async function runWorkflow() {
await workflow('feature-x')
// ... your real implementation, repair, review loops, and final acceptance ...
.step('write-marker', {
type: 'deterministic',
command: `echo "fix landed at $(date -u)" >> CHANGELOG.md`,
})
.step('create-branch', {
type: 'deterministic',
dependsOn: ['write-marker'],
command: `git switch -c ${BRANCH}`,
})
.step('commit-change', {
type: 'deterministic',
dependsOn: ['create-branch'],
command: 'git add CHANGELOG.md && git commit -m "chore: changelog entry"',
})
.step('push-branch', {
type: 'deterministic',
dependsOn: ['commit-change'],
command: `git push -u origin ${BRANCH}`,
})
.step('open-pr', {
type: 'deterministic',
dependsOn: ['push-branch'],
command: `gh pr create --base main --head ${BRANCH} --title "feat: ship feature X" --body-file .workflow-artifacts/feature-x/pr-body.md`,
verification: { type: 'pr_url', value: 'AgentWorkforce/cloud' },
})
.run({ cwd: process.cwd() });
}
runWorkflow().catch((error) => {
console.error(error);
process.exit(1);
});For non-local GitHub operations, create a deterministic script that imports GitHubClient from @relayflows/github-primitive and calls the client methods for createBranch, createFile or updateFile, createPR, and getPR.
gh pr create" is a regression to a manual step the workflow could have done. The whole point of running this in cloud is that there is no operator's shell.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: false.paths[] mount and cloud push-back is configured, let cloud open that PR. Use explicit GitHub steps when you need a PR against a repo or branch outside the paths[] set, or when you want to add an extra PR.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 opens a PR with that evidence templated into the body. The PR opening is the ship — there is no further manual step.
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 isA | 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](#choose-your-coordination-style--conversation-vs-pipeline) before defaulting to this.
Only chain output from clean sources:
preset: 'worker' — clean stdout)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.)
verification: { type: 'exit_code' } // preferred for code-editing steps
verification: { type: 'output_contains', value: 'DONE' } // optional accelerator
verification: { type: 'file_exists', value: 'src/out.ts' } // deterministic file check
verification: { type: 'pr_url', value: 'owner/repo' } // step must leave behind a PROnly 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.
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. Pair it with a deterministic PR step that prints the PR URL, or with an adapter script that uses GitHubClient from @relayflows/github-primitive. 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.
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.
Steps with dependsOn wait for all listed steps. Steps with no dependencies start immediately. Steps sharing the same dependsOn run in parallel:
.step('fix-types', { agent: 'worker', dependsOn: ['review'], ... })
.step('fix-tests', { agent: 'worker', dependsOn: ['review'], ... })
.step('final', { agent: 'lead', dependsOn: ['fix-types', 'fix-tests'], ... })Do NOT add exit instructions to task strings. The runner handles this automatically.
For bounded Codex steps that must produce one artifact or a structured answer, use a non-interactive preset (preset: 'worker', reviewer, or analyst) instead of interactive PTY. This runs through one-shot subprocess mode (codex exec), so completion is the process lifecycle plus verification. Interactive Codex is for live channel coordination; it is weaker for "write one file then exit" loops because idle detection can see an auth or prompt-delivery failure as silence.
Steps complete through a multi-signal pipeline (highest priority first):
exit_code, file_exists, output_contains pass → immediate completionOWNER_DECISION: COMPLETE|INCOMPLETE_RETRY|INCOMPLETE_FAILSTEP_COMPLETE:<step-name> (optional accelerator)Key principle: No single signal is mandatory. Describe the deliverable, not what to print.
Agents can dynamically subscribe, unsubscribe, mute, and unmute channels after spawn. This eliminates the need for client-side channel filtering and manual peer fanout.
#### SDK API
// Subscribe an agent to additional channels post-spawn
relay.subscribe({ agent: 'security-auditor', channels: ['review-pr-456'] });
// Unsubscribe — agent leaves the channel entirely
relay.unsubscribe({ agent: 'security-auditor', channels: ['general'] });
// Mute — agent stays subscribed (history access) but messages are NOT injected into PTY
relay.mute({ agent: 'security-auditor', channel: 'review-pr-123' });
// Unmute — resume PTY injection
relay.unmute({ agent: 'security-auditor', channel: 'review-pr-123' });Agent-level methods are also available:
const agent = await relay.claude.spawn({ name: 'auditor', channels: ['ch-a'] });
await agent.subscribe(['ch-b']); // now subscribed to ch-a and ch-b
await agent.mute('ch-a'); // ch-a messages silenced (still in history)
await agent.unmute('ch-a'); // ch-a messages resume
await agent.unsubscribe(['ch-b']); // leaves ch-b
console.log(agent.channels); // ['ch-a']
console.log(agent.mutedChannels); // []#### Semantics
| Operation | Channel membership | PTY injection | History access |
|---|---|---|---|
subscribe | Yes | Yes | Yes |
unsubscribe | No | No | No (leaves) |
mute | Yes (stays) | No (silenced) | Yes (can query) |
unmute | Yes | Yes (resumes) | Yes |
#### Events
relay.onChannelSubscribed = (agent, channels) => {
/* ... */
};
relay.onChannelUnsubscribed = (agent, channels) => {
/* ... */
};
relay.onChannelMuted = (agent, channel) => {
/* ... */
};
relay.onChannelUnmuted = (agent, channel) => {
/* ... */
};#### When to Use in Workflows
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.