Workflow engine and CLI for Agent Relay. Orchestrate multi-agent workflows using YAML, TypeScript, or Python. Define agents, wire up dependencies, and let the runner handle execution, retries, and verification.
# Run a YAML workflow
relayflows run workflow.yaml
# Run a TypeScript workflow
relayflows run workflow.ts
# Run a Python workflow
relayflows run workflow.py
# Run a specific named workflow from a file
relayflows run workflow.yaml --workflow deployimport{workflow}from"@relayflows/core";constresult=awaitworkflow("ship-feature").pattern("dag").agent("planner",{cli: "claude",role: "Plans implementation"}).agent("developer",{cli: "codex",role: "Writes code"}).agent("reviewer",{cli: "claude",role: "Reviews code"}).step("plan",{agent: "planner",task: "Create implementation plan for user authentication",}).step("implement",{agent: "developer",task: "Implement the plan",dependsOn: ["plan"],}).step("review",{agent: "reviewer",task: "Review the implementation",dependsOn: ["implement"],}).run();console.log(result.status);// "completed" | "completed_early" | "failed" | "cancelled" | "needs_human"fromagent_relayimportworkflowresult= (
workflow("ship-feature")
.pattern("dag")
.agent("planner", cli="claude", role="Plans implementation")
.agent("developer", cli="codex", role="Writes code")
.agent("reviewer", cli="claude", role="Reviews code")
.step("plan", agent="planner", task="Create implementation plan for user auth")
.step("implement", agent="developer", task="Implement the plan", depends_on=["plan"])
.step("review", agent="reviewer", task="Review the implementation", depends_on=["implement"])
.run()
)When a run starts, the runner mints and prints a link you can open to follow it live:
[workflow 00:02] Workspace created for this workflow.
[workflow 00:02] Observer: https://agentrelay.com/observer?key=ot_live_...
[workflow 00:02] Channel: wf-ship-feature-a1b2c3
Open the Observer: URL and you see messages, agent activity, handoffs, and
deliveries in real time. The link carries a scoped observer token
(ot_live_): read-only, expiring in 24 hours, and individually revocable.
Minting is best-effort, so a link is not guaranteed — see If no link appears. A failed mint never fails the run.
Treat the link itself as a shared secret. The token is a bearer credential in a query parameter, so anyone who gets the URL can read the stream it covers until it expires or you revoke it. It is far safer than a workspace key — it cannot send, spawn, or administer — but it is not public.
Which link you get depends on where the run's Relaycast workspace comes from.
No RELAY_API_KEY set — the runner creates a throwaway workspace for this
run alone and mints an observer link covering all of it, DMs included. The
workspace is anonymous and disappears from your reach when the run ends, so
copy the link while the run is going. Nothing persists the underlying key,
and there is no way to recover it afterward.
RELAY_API_KEY set — the runner uses your workspace and mints a link scoped
to just this run's channel, with agent DMs excluded, so the link does not expose
unrelated traffic in a shared workspace. This is the better setup for anything
you may want to revisit: the run is in a workspace you own, so you can mint
fresh links whenever you like.
# One-time: create a workspace you own and keep the key
curl -sX POST https://api.relaycast.dev/v1/workspaces \
-H 'content-type: application/json' -d '{"name":"my-workflows"}' \
| jq -r '.data.api_key // .api_key'export RELAY_API_KEY=rk_live_... # put this in your shell profile
relayflows run workflow.yamlIf you already use the Agent Relay CLI, agent-relay workspace key --reveal-secrets
prints the key of your active workspace. Note it is masked without
--reveal-secrets.
With a workspace you own, agent-relay observer mints links on demand:
agent-relay observer # read-only link, 24h, DMs excluded
agent-relay observer --channels wf-ship-a1b2c3 # scope to one run
agent-relay observer --include-dms # include agent DMs
agent-relay observer --expires 7d # longer-lived link
agent-relay observer list # what is outstanding
agent-relay observer revoke <id># cut one off immediatelyA workspace key (rk_live_) is an administrative credential — it can send
messages, spawn and remove agents, and change workspace settings. Do not put one
in an observer URL, a chat message, or a terminal transcript; query strings end
up in browser history, referrer headers, and proxy logs. The realtime endpoint
rejects it outright, so a link built from one cannot stream anyway.
Workspace key (rk_live_) | Observer token (ot_live_) | |
|---|---|---|
| Read messages and activity | yes | yes |
| Send, spawn agents, administer | yes | no |
| Expires | no | yes |
| Revocable individually | no | yes |
| Scopable to channels | no | yes |
The runner only ever prints ot_live_ links, and scrubs rk_live_ values out of
channel output.
| Variable | Purpose |
|---|---|
RELAY_API_KEY | Workspace key to run against. Unset means a throwaway workspace per run. |
RELAY_OBSERVER_URL | Observer dashboard base. Defaults to https://agentrelay.com/observer. |
RELAY_OBSERVER_EXPIRES | Link lifetime as 30m / 24h / 7d. Defaults to 24h; unparseable or over 90d falls back to 24h. |
RELAYCAST_BASE_URL | Relaycast engine base. Defaults to https://api.relaycast.dev. |
Observation: unavailable— the token could not be minted (engine unreachable, or it rejected the request). The run is unaffected; minting is best-effort by design and never fails a run.- No observer lines at all — the run needed no broker: every step was
deterministic,worktree,integration, orwaitFor, or an external executor handled agent spawning, or Relaycast was disabled withAGENT_RELAY_WORKFLOW_DISABLE_RELAYCAST=1. Observation: run agent-relay observer— you setRELAY_API_KEYand minting failed. Mint a link by hand withagent-relay observer.
A good production split is:
- AI SDK app handles the user conversation and streaming UI
- Communicate /
onRelay()lets that point-person coordinate with specialists over Relay - Workflows /
runWorkflow()take over when a request needs multi-step execution, verification, or handoffs
import{streamText,wrapLanguageModel}from'ai';import{openai}from'@ai-sdk/openai';import{Relay}from'@agent-relay/sdk/communicate';import{onRelay}from'@agent-relay/sdk/communicate/adapters/ai-sdk';import{runWorkflow}from'@relayflows/core';exportasyncfunctionPOST(req: Request){const{ prompt, escalate, repo }=awaitreq.json();constrelay=newRelay('AppLead');constrelaySession=onRelay({name: 'AppLead',instructions: 'You are the customer-facing lead. Keep the user updated and delegate implementation via Relay when needed.',},relay);constmodel=wrapLanguageModel({model: openai('gpt-4o-mini'),middleware: relaySession.middleware,});if(escalate){constworkflow=awaitrunWorkflow('workflows/feature-dev.yaml',{vars: {task: prompt, repo },});returnResponse.json({status: workflow.status,runId: workflow.runId});}returnstreamText({
model,tools: relaySession.tools,system: 'Answer directly when possible; coordinate internally when the task needs specialists.',
prompt,}).toUIMessageStreamResponse({onFinish(){relaySession.cleanup();voidrelay.close();},});}That pattern keeps the user experience snappy while still letting longer Relay workflows run with proper ownership, retries, and verification.
Workflows are defined as relay.yaml files:
version: "1.0"name: my-workflowdescription: "Optional description"swarm:
pattern: dag # Execution pattern (see Patterns below)maxConcurrency: 3# Max agents running in paralleltimeoutMs: 3600000# Global timeout (1 hour)channel: my-channel # Relay channel for agent communicationagents:
- name: backendcli: claude # claude | codex | gemini | aider | goose | opencode | droidrole: "Backend engineer"constraints:
model: opustimeoutMs: 600000retries: 2
- name: testercli: codexrole: "Test engineer"interactive: false # Non-interactive: runs as subprocess, no PTY/messaging# A persona replaces cli + role. Its harness, model, standing instructions,# installed skills, MCP servers, and harness settings come from the spec.# Persona agents are interactive-only: do not set cli, preset, or# constraints.model, and do not use interactive: false.
- name: integrationspersona: nango-integrationsworkflows:
- name: build-and-testonError: retry # fail | skip | retrysteps:
- name: build-apiagent: backendtask: "Build the REST API endpoints for user management"verification:
type: file_existsvalue: "src/api/users.ts"retries: 1
- name: write-testsagent: testertask: "Write integration tests for: {{steps.build-api.output}}"dependsOn: [build-api]
- name: run-testsagent: testertask: "Run the test suite and report results"dependsOn: [write-tests]verification:
type: exit_codevalue: "0"errorHandling:
strategy: retrymaxRetries: 2retryDelayMs: 5000repairAgent: testerrepairRetries: 2onExhaustion: needs-humannotifyChannel: my-channelUse {{variable}} for user-provided values and {{steps.STEP_NAME.output}} for previous step outputs:
steps:
- name: planagent: plannertask: "Plan implementation for: {{task}}"# User variable
- name: implementagent: developerdependsOn: [plan]task: "Implement: {{steps.plan.output}}"# Previous step outputUser variables are passed via the CLI or programmatically:
awaitrunWorkflow("workflow.yaml",{vars: {task: "Add OAuth2 support"},});Workflows can pause on a Slack question by adding a Slack integration step with
action: askQuestion. The step posts a question, waits for the first human reply
in the message thread, exposes that answer through {{steps.<name>.output}}, and
can inject it into a running agent when injectToAgent is set.
workflows:
- name: defaultsteps:
- name: ask-humantype: integrationintegration: slackaction: askQuestionparams:
channel: "#engineering"text: "The implementer is blocked on migration strategy. Which path should it take?"waitTimeoutMs: "3600000"injectToAgent: "backend-runtime-name"injectTemplate: "HUMAN_ANSWER: {{answer.text}}"output: '{"format":"text","path":"answer.text"}'
- name: continue-with-answeragent: backenddependsOn: [ask-human]task: "Continue using this human guidance: {{steps.ask-human.output}}"askQuestion can use the local Slack API runtime, or Relayfile-backed Slack
writebacks when the workflow already has a Relayfile Slack integration.
Interactive agent steps can also opt into marker-driven assistance. With
humanAssistance.slack enabled, an agent can print a line beginning with
HUMAN_QUESTION:. The runner posts that question to Slack, blocks while waiting
for a human reply, then injects HUMAN_ANSWER: ... back into that same agent
session. If integrations.relayfile is present, Relayflows automatically uses
the existing Relayfile/Pear Slack connection; no Slack bot token, Relayfile
workspace id, or Relayfile token is required in the workflow.
Runnable TypeScript proof: examples/typescript/slack-human-assistance-e2e.ts.
swarm:
pattern: daghumanAssistance:
slack:
channel: proj-cloudtimeoutMs: 3600000integrations:
relayfile: {}workflows:
- name: defaultsteps:
- name: implementagent: backendtask: "Proceed, but ask for human guidance if the migration strategy is ambiguous."Relayflows can subscribe to Relayfile integration events and inject matching
events into active agents. Workflow-level subscriptions live under
integrations.subscriptions; agent-level subscriptions use Workforce-style
agents[].watch or Relayflows-style agents[].subscriptions.
integrations:
relayfile: {}subscriptions:
- name: pr-feedbackprovider: githubpath: /github/repos/acme/web/pulls/42/**events: [created, updated]agents: [pr-babysitter]agents:
- name: pr-babysittercli: codexwatch:
- paths: [/github/repos/acme/web/pulls/42/reviews/**]events: [created, updated]workflows:
- name: defaultsteps:
- name: babysit-pragent: pr-babysittertask: | Stay active and wait for INTEGRATION_EVENT messages about PR feedback. Read the Relayfile path from the event, address comments until no open feedback remains, then notify the user in Slack.Each step can include a verification check. Verification is one input to the runner's completion decision pipeline — when verification passes, the step completes even without a sentinel marker.
| Type | Description |
|---|---|
exit_code | Agent must exit with the specified code (preferred for code-editing steps) |
file_exists | A file must exist at the specified path after the step |
output_contains | Step output must contain the specified string (optional accelerator) |
custom | No-op in the runner; handled by external callers |
# Preferred — deterministic verificationverification:
type: exit_codevalue: "0"description: "Process exited successfully"# Also valid — output_contains as an optional acceleratorverification:
type: output_containsvalue: "IMPLEMENTATION_COMPLETE"description: "Agent confirms completion (optional fast-path)"The runner uses a multi-signal pipeline to decide step completion:
- Deterministic verification — if a verification check passes, the step completes immediately (
completed_verified) - Owner decision — the step owner can issue
OWNER_DECISION: COMPLETE|INCOMPLETE_RETRY|INCOMPLETE_FAIL(completed_by_owner_decision) - Evidence-based completion — channel messages, file artifacts, and exit codes are collected as evidence (
completed_by_evidence) - Marker fast-path —
STEP_COMPLETE:<step-name>still works as an accelerator but is never required
| Completion State | Meaning |
|---|---|
completed_verified | Deterministic verification passed |
completed_by_owner_decision | Owner approved the step |
completed_by_evidence | Evidence-based completion |
retry_requested_by_owner | Owner requested retry |
failed_verification | Verification explicitly failed |
failed_owner_decision | Owner rejected the step |
failed_no_evidence | No verification, no owner decision, no evidence |
Review parsing is tolerant: The runner accepts semantically equivalent outputs like "Approved", "Complete", "LGTM" — not just exact REVIEW_DECISION: APPROVE strings.
The swarm.pattern field controls how agents are coordinated:
| Pattern | Description |
|---|---|
dag | Directed acyclic graph — steps run based on dependency edges (default) |
fan-out | All agents run in parallel |
pipeline | Sequential chaining of steps |
hub-spoke | Central hub coordinates spoke agents |
consensus | Agents vote on decisions |
mesh | Full communication graph between agents |
handoff | Sequential handoff between agents |
cascade | Waterfall with phase gates |
debate | Agents propose and counter-argue |
hierarchical | Multi-level reporting structure |
| Pattern | Description |
|---|---|
map-reduce | Split work into chunks (mappers), process in parallel, aggregate results (reducers) |
scatter-gather | Fan out requests to workers, collect and synthesize responses |
| Pattern | Description |
|---|---|
supervisor | Monitor agent monitors workers, restarts on failure, manages health |
reflection | Agent produces output, critic reviews and provides feedback for iteration |
verifier | Producer agents submit work to verifier agents for validation |
| Pattern | Description |
|---|---|
red-team | Attacker agents probe for weaknesses, defender agents respond |
auction | Auctioneer broadcasts tasks, agents bid based on capability/cost |
| Pattern | Description |
|---|---|
escalation | Start with fast/cheap agents, escalate to more capable on failure |
saga | Distributed transactions with compensating actions on failure |
circuit-breaker | Primary agent with fallback chain, fail fast and recover |
| Pattern | Description |
|---|---|
blackboard | Shared workspace where agents contribute incrementally to a solution |
swarm | Emergent behavior from simple agent rules (neighbor communication) |
When swarm.pattern is omitted, the coordinator auto-selects based on agent roles. Patterns are checked in priority order below (first match wins):
| Priority | Pattern | Required Roles/Config |
|---|---|---|
| 1 | dag | Steps with dependsOn |
| 2 | consensus | Uses coordination.consensusStrategy config |
| 3 | map-reduce | mapper + reducer |
| 4 | red-team | (attacker OR red-team) + (defender OR blue-team) |
| 5 | reflection | critic |
| 6 | escalation | tier-1, tier-2, etc. |
| 7 | auction | auctioneer |
| 8 | saga | saga-orchestrator OR compensate-handler |
| 9 | circuit-breaker | fallback, backup, OR primary |
| 10 | blackboard | blackboard OR shared-workspace |
| 11 | swarm | hive-mind OR swarm-agent |
| 12 | verifier | verifier |
| 13 | supervisor | supervisor |
| 14 | hierarchical | lead (with 4+ agents) |
| 15 | hub-spoke | hub OR coordinator |
| 16 | pipeline | Unique agents per step, 3+ steps |
| 17 | fan-out | Default fallback |
steps:
- name: risky-stepagent: workertask: "Do something that might fail"retries: 3# Retry up to 3 times on failuretimeoutMs: 300000# 5 minute timeoutA deterministic gate can explicitly declare exit codes that mean “there is no work to do.” A matching code ends the run with the distinct completed_early status and skips every step that has not started:
steps:
- name: claim-worktype: deterministiccommand: node bin/claim-work.mjsterminalSuccessExitCodes: [78]
- name: process-claimagent: workertask: Process the claimed workdependsOn: [claim-work]Terminal-capable gates are scheduling barriers, so other ready work does not race the gate. The triggering step is completed with completion reason completed_early_exit; remaining steps are skipped, and the CLI exits 0 while clearly reporting COMPLETED EARLY. Verification still applies, so a verification failure remains a real failure.
This behavior is opt-in. Without terminalSuccessExitCodes, exit 78 and every other non-zero exit retain their existing failure behavior. The new completed_early run status is an additive public API value: consumers with exhaustive status switches, strict validators, database constraints, or terminal-status polling must handle it separately from completed.
The onError field on a workflow controls what happens when a step fails:
| Value | Behavior |
|---|---|
fail / fail-fast | Stop immediately, skip downstream steps |
skip / continue | Skip downstream dependents, continue independent steps |
retry | Retry the step; deterministic gates ask a workflow agent to repair before each retry when an agent is available |
errorHandling:
strategy: retrymaxRetries: 2retryDelayMs: 5000repairAgent: testerrepairRetries: 2notifyChannel: alertsRetry-mode workflows are repair-aware by default. Deterministic step failures, verification gate failures, and malformed agent artifacts are treated as repairable work before terminal failure. The runner chooses errorHandling.repairAgent when set, otherwise it uses the step's owning/upstream agent when possible, then falls back to the best available workflow agent. The selected agent gets the failed command or agent output, working directory, exit information, and captured evidence, then the failed gate or step is retried. Use repairRetries: 0, strategy: fail-fast, or strategy: continue when a workflow intentionally should not invoke repair agents. Set onExhaustion: needs-human to end an exhausted repairable run as needs_human instead of failed.
Six pre-built workflow templates are included:
| Template | Pattern | Description |
|---|---|---|
feature-dev | hub-spoke | Plan, implement, review, and finalize a feature |
bug-fix | hub-spoke | Investigate, patch, validate, and document a bug fix |
code-review | fan-out | Parallel multi-reviewer assessment with consolidated findings |
security-audit | pipeline | Scan, triage, remediate, and verify security issues |
refactor | hierarchical | Analyze, plan, execute, and validate a refactor |
documentation | handoff | Research, draft, review, and publish documentation |
import{TemplateRegistry,WorkflowRunner}from"@relayflows/core";constregistry=newTemplateRegistry();// List available templatesconsttemplates=awaitregistry.listTemplates();// Load and run a templateconstconfig=awaitregistry.loadTemplate("feature-dev");construnner=newWorkflowRunner();constresult=awaitrunner.execute(config,undefined,{task: "Add WebSocket support to the API",});// Install a custom template from a URLawaitregistry.installExternalTemplate("https://example.com/my-template.yaml","my-template");The builder constructs a RelayYamlConfig object and can run it, export it as YAML, or return the raw config.
import{workflow}from"@relayflows/core";// Build and runconstresult=awaitworkflow("my-workflow").pattern("dag").maxConcurrency(3).timeout(60*60*1000).channel("my-channel").agent("backend",{cli: "claude",role: "Backend engineer",model: "opus",retries: 2,}).agent("frontend",{cli: "codex",role: "Frontend engineer",interactive: false,// Non-interactive subprocess mode}).step("api",{agent: "backend",task: "Build REST API",verification: {type: "output_contains",value: "API_READY"},}).step("ui",{agent: "frontend",task: "Build the UI",dependsOn: ["api"],}).onError("retry",{maxRetries: 2,retryDelayMs: 5000}).run();// Or export to YAMLconstyaml=workflow("my-workflow").pattern("dag").agent("worker",{cli: "claude"}).step("task1",{agent: "worker",task: "Do something"}).toYaml();// Or get the raw config objectconstconfig=workflow("my-workflow").pattern("dag").agent("worker",{cli: "claude"}).step("task1",{agent: "worker",task: "Do something"}).toConfig();The Python builder ships with @agent-relay/sdk-py:
pip install agent-relayfromagent_relayimportworkflow, run_yaml# Build and runresult= (
workflow("my-workflow")
.pattern("dag")
.max_concurrency(3)
.timeout(3600000)
.agent("backend", cli="claude", role="Backend engineer")
.agent("frontend", cli="codex", role="Frontend engineer")
.step("api", agent="backend", task="Build REST API")
.step("ui", agent="frontend", task="Build the UI", depends_on=["api"])
.on_error("retry", max_retries=2, retry_delay_ms=5000)
.run()
)
# Run an existing YAML fileresult=run_yaml("workflows/my-workflow.yaml")
# Export to YAML stringyaml_str= (
workflow("my-workflow")
.pattern("dag")
.agent("worker", cli="claude")
.step("task1", agent="worker", task="Do something")
.to_yaml()
)
# Get the raw config dictconfig= (
workflow("my-workflow")
.pattern("dag")
.agent("worker", cli="claude")
.step("task1", agent="worker", task="Do something")
.to_config()
)For full control, use the WorkflowRunner directly:
import{WorkflowRunner}from"@relayflows/core";construnner=newWorkflowRunner({cwd: "/path/to/project",// Working directory (default: process.cwd())relay: {port: 3000},// AgentRelay options (optional)});// Listen to events (broker:event fires frequently — filter it out for cleaner output)runner.on((event)=>{if(event.type==='broker:event')return;console.log(event.type,event);});// Parse and executeconstconfig=awaitrunner.parseYamlFile("workflow.yaml");construn=awaitrunner.execute(config,"workflow-name",{task: "Build the feature",});// Pause / resume / abortrunner.pause();runner.unpause();runner.abort();// Resume a failed runconstresumed=awaitrunner.resume(run.id);import{runWorkflow}from"@relayflows/core";constresult=awaitrunWorkflow("workflow.yaml",{workflow: "deploy",vars: {environment: "staging"},onEvent: (event)=>{if(event.type!=='broker:event')console.log(event.type);},});Synchronization points that wait for specific steps to complete:
coordination:
barriers:
- name: all-reviews-donewaitFor: [review-arch, review-security, review-correctness]timeoutMs: 900000consensusStrategy: majority # majority | unanimous | quorumAgents can share state during execution:
state:
backend: memory # memory | redis | databasettlMs: 86400000namespace: my-workflow| CLI | Description |
|---|---|
claude | Claude Code (Anthropic) |
codex | Codex CLI (OpenAI) |
gemini | Gemini CLI (Google) |
aider | Aider coding assistant |
goose | Goose AI assistant |
opencode | OpenCode CLI |
droid | Droid CLI |
By default, agents run in interactive PTY mode with full relay messaging. For workers that just need to execute a task and return output — common in fan-out, map-reduce, and pipeline patterns — set interactive: false to run them as lightweight subprocesses.
agents:
- name: leadcli: clauderole: "Coordinates work"# interactive: true (default) — full PTY, relay messaging, /exit detection
- name: workercli: codexrole: "Executes tasks"interactive: false # Runs "codex exec <task>", captures stdoutworkflow("fan-out-analysis").pattern("fan-out").agent("lead",{cli: "claude",role: "Coordinator"}).agent("worker-1",{cli: "codex",interactive: false,role: "Analyst"}).agent("worker-2",{cli: "codex",interactive: false,role: "Analyst"}).step("analyze-1",{agent: "worker-1",task: "Analyze module A"}).step("analyze-2",{agent: "worker-2",task: "Analyze module B"}).step("synthesize",{agent: "lead",task: "Combine: {{steps.analyze-1.output}} + {{steps.analyze-2.output}}",dependsOn: ["analyze-1","analyze-2"],}).run();| Aspect | Interactive (default) | Non-Interactive |
|---|---|---|
| Execution | Full PTY with stdin/stdout | child_process.spawn() with piped stdio |
| CLI invocation | Standard interactive session | One-shot mode (claude -p, codex exec, etc.) |
| Relay messaging | Can send/receive messages | No messaging — excluded from topology edges |
| Self-termination | Must output /exit | Process exits naturally when done |
| Output capture | PTY output buffer | stdout capture |
| Overhead | Higher (PTY, echo verification, SIGWINCH) | Lower (simple subprocess) |
| CLI | Command | Notes |
|---|---|---|
claude | claude -p "<task>" | Print mode, exits after response |
codex | codex exec "<task>" | One-shot execution |
gemini | gemini -p "<task>" | Prompt mode |
opencode | opencode --prompt "<task>" | One-shot prompt |
droid | droid exec "<task>" | One-shot execution |
aider | aider --message "<task>" --yes-always --no-git | Auto-approve, skip git |
goose | goose run --text "<task>" --no-session | Text mode, no session file |
- Fan-out workers that process a task and return results
- Map-reduce mappers that don't need mid-task communication
- Pipeline stages that transform input to output
- Any agent that doesn't need turn-by-turn relay messaging
- Lead/coordinator agents that communicate with others
- Agents in debate, consensus, or reflection patterns
- Agents that need to receive messages during execution
Agents running inside a workflow can output slash commands to signal the broker. These are detected in the agent's PTY output at the broker level — the agent simply prints the command on its own line.
Signals that the agent has completed its current step and is ready to be released.
/exit
The workflow runner waits for each agent to /exit after delivering a step task. When the broker detects /exit in the agent's output (exact line match after ANSI stripping), it:
- Emits an
agent_exitframe withreason: "agent_requested" - Triggers graceful PTY shutdown
If an agent does not /exit within the step's timeoutMs, the runner treats the step as timed out. As a safety net, steps with file_exists verification will still pass if the expected file is present despite the timeout.
Best practice: Instruct agents to output /exit when done in your step task descriptions:
steps:
- name: build-apiagent: backendtask: | Build the REST API endpoints for user management. When finished, output /exit.Interactive agents sometimes finish their task but forget to /exit, sitting idle and blocking downstream steps. The runner can detect idle agents and take action automatically.
Add idleNudge to your swarm config:
swarm:
pattern: hub-spokeidleNudge:
nudgeAfterMs: 120000# 2 min before first nudge (default)escalateAfterMs: 120000# 2 min after nudge before force-release (default)maxNudges: 1# Nudges before escalation (default)All built-in templates include idle nudging with these defaults.
- Detection: The broker tracks agent output timestamps and emits
agent_idleevents when an agent goes silent for the configured threshold - Nudge: For hub patterns (hub-spoke, fan-out, hierarchical, etc.), the runner tells the hub agent to check on the idle agent. For non-hub patterns, a system message is injected directly into the agent's PTY
- Escalation: If the agent remains idle after
maxNudgesattempts, the runner force-releases it and captures whatever output was produced - No config: When
idleNudgeis omitted, the runner uses simplewaitForExit(backward compatible)
The runner emits two new events for idle nudging:
| Event | Description |
|---|---|
step:nudged | Fired when a nudge message is sent to an idle agent |
step:force-released | Fired when an agent is force-released after exhausting nudges |
For interactive agent steps, the runner uses a point-person-led completion model:
- Elects a step owner (prefers lead/coordinator-style agents, falls back to the step agent)
- Runs a completion decision pipeline — checks deterministic verification first, then owner judgment, then evidence
- Owner can issue structured decisions via
OWNER_DECISION: COMPLETE|INCOMPLETE_RETRY|INCOMPLETE_FAIL|NEEDS_CLARIFICATIONwith optionalREASON: <text> - Review parsing is tolerant — accepts "Approved", "Complete", "LGTM", not just exact
REVIEW_DECISION: APPROVE - Markers are optional accelerators —
STEP_COMPLETE:<step-name>still works as a fast-path but is never required - Stores primary output plus review output in the step artifact
Evidence-based completion: The runner collects channel messages, file artifacts, process exit codes, and coordination signals (e.g., WORKER_DONE posted in channel) as completion evidence. When sufficient evidence exists, the step completes without requiring any sentinel marker.
Deterministic and worktree steps are unchanged and do not require owner/review delegation.
By default the runner spawns steps as local child processes. To run them in isolated sandboxes instead, select a provider — the runner still owns command construction, env, cwd, timeout, and the whole DAG/retry/verification pipeline; the provider only supplies "where the command runs".
# Off by default. Unset the flag to get local child processes back.export RELAYFLOWS_SANDBOX_PROVIDER=daytona
export DAYTONA_API_KEY=...
export RELAYFLOWS_SANDBOX_HOME_DIR=/home/daytona # image-specific, requiredexport RELAYFLOWS_SANDBOX_SNAPSHOT=my-snapshot # optionalOr in code:
import{WorkflowRunner}from"@relayflows/core";construnner=newWorkflowRunner({sandbox: {provider: "daytona",homeDir: "/home/daytona"},});| Provider | What it gives you |
|---|---|
none (default) | No sandbox. Local child processes, exactly as before. |
daytona | Real remote sandboxes via @agent-relay/sandbox. Needs the optional peer @daytonaio/sdk. |
local-process | Real local processes in a private per-step directory with its own HOME. Isolates the filesystem root, not the machine — good for development and CI, not a security boundary. |
Reversibility.provider: "none" (or an unset RELAYFLOWS_SANDBOX_PROVIDER)
produces no backend at all, so nothing about the default path changes. An
explicit executor or processBackend still wins over sandbox config, so a
host that injects its own backend today keeps it.
Custom providers. Register a runtime under any name, or hand one in directly. This is the seam a host uses to plug in a runtime that does not live in this repo:
import{registerSandboxProvider,WorkflowRunner}from"@relayflows/core";registerSandboxProvider("my-runtime",(config)=>newMyRuntime(config));// ...or skip the registry entirely:newWorkflowRunner({sandbox: {runtime: myRuntime}});A runtime needs five methods — launch, exec, uploadFile, getHomeDir,
destroy — matching @agent-relay/sandbox's WorkflowRuntime.
A JSON Schema is available at packages/core/src/schema.json for editor autocompletion and validation of relay.yaml files.
npm install
npm run typecheck
npm run test- Node.js 22+
@relayflows/cliinstalled (npm install -g @relayflows/cli)- For Python: Python 3.10+ with
pip install agent-relay - For TypeScript workflow files:
tsxorts-nodeinstalled
Apache-2.0 — Copyright 2025 Agent Workforce Incorporated
