# Agent Relay Flows: agent-driven signup

You are setting up Agent Relay on behalf of the user who gave you this URL.
Do NOT use computer use, browser automation, screenshots, DOM inspection, or mouse/keyboard control. Fetch the guide over HTTP and perform setup through its documented APIs. Use terminal commands only for HTTP requests and the documented installation/CLI operations. The user owns the screen: open approval URLs with the OS URL opener (or give the user the link), then wait and poll the documented API. Never operate the approval page or the desktop app UI.
Do the API calls, installation, configuration, and verification yourself. Keep
working across user approvals; do not hand the user a checklist to execute.
The signup page is a live view for the user. Do not read or control it;
report progress and request choices only through the supplied Progress API URL.
For an approval URL, use the OS URL opener (for example macOS open with the
URL passed as a separate subprocess argument, never interpolated into shell
code). If no opener is available, give the link to the user. Do not launch a
browser-control tool. Verify approval by polling the API, never by inspecting
the browser. If an operation has no documented API or CLI, report the blocker
and ask the user for that specific action; never fall back to computer use.
For desktop installation, download the prebuilt binary the skill names. Never
clone the desktop repository, install build dependencies, run a build, compile
from source, or generate a DMG. A missing binary is a blocker, not a build task.
The user handles Google sign-in, device approval, and any provider or operating
system consent. Never approve access on their behalf or ask for their password.

Site: https://2342b4a2-agentrelay-web.agent-workforce.workers.dev
Cloud API base: https://2342b4a2-agentrelay-web.agent-workforce.workers.dev/cloud
Use this exact environment throughout; never fall back from local development
to production.

Send a User-Agent naming your tool (for example agent-relay-setup/1.0) on every
HTTP request, including the guide and the Cloud APIs. The edge rejects Python
urllib's default (Python-urllib/x.y) with HTTP 403 and a plain-text
"error code: 1010" body before the API sees the request. API errors are JSON
objects with an error code. For any response that is not JSON, report only the
HTTP status, never the body.

## How this guide is built

This header holds only what is specific to signup. The setup steps are the
canonical Agent Relay skills from https://github.com/AgentWorkforce/skills,
included below verbatim as published to the prpm registry:

- Part 1: Cloud account (@agent-relay/signing-in-to-agent-relay-cloud@0.1.0)
- Part 2: Flows (@agent-relay/setting-up-agent-relay-flows@0.1.0)
- Part 3: Custom flow authoring (@agent-relay/writing-relayflows@1.6.2)

Follow the parts in order (Part 3 is a reference, read only for a custom flow). Where a part says to ask the human, ask the user
through the web-input protocol below, not in chat.
Where a part offers to install itself as a skill or to use another agent,
continue with the text here instead. A part that requires another skill finds
it as another part of this guide; do not stop to install skills.

Part 1 creates the account with signup_source "flows". Part 2 uses its access
token and its user.id and currentWorkspace.id. Part 3 is writing-relayflows,
the authoring guide Part 2's custom-flow path defers to; skip it for a
prebuilt flow.

## Live progress (when the user's prompt includes a progress session)

The user is watching a setup page. Report real milestones using the Progress API
and Progress token supplied in their prompt. The token authorizes progress only;
it is NOT a Cloud access token. Never send account tokens, OAuth codes, passwords,
logs, approval URLs, or sensitive personal information to the progress endpoint.
The web-input protocol may carry a repository name or public GitHub approver handle when the user chooses it; do not request emails or secrets. Do not put
the progress token in URLs or output it in your final reply. Use the same site
and /cloud origin shown above; never forward it to another environment.

GET the supplied Progress API URL to obtain the current revision and product.
Check that the product matches this guide. Start by PATCHing that URL with
Authorization: Bearer <Progress token> and Content-Type: application/json:

~~~json
{"step":1,"state":"working","revision":0,"agent":"<your agent type>"}
~~~

Replace agent with your actual coding agent: codex, claude_code, grok, opencode,
cursor, gemini_cli, other, or unknown. Report the tool running this setup, not
its underlying model or the agents the user will run later. Use unknown if you
cannot determine it; never guess from the logos on the page. Include agent in
your first PATCH. It is stored once for signup funnel attribution; later PATCHes
may omit it. A different known agent returns 409 agent_conflict: preserve the
original attribution and omit agent when resuming from a different tool.

Use the revision returned by the latest GET/PATCH, not the example's literal 0.
PATCH before each numbered progress step below. A move to the next step marks
the previous step done; do not skip steps or report success before checking it.
Set state: waiting when you need the user to approve access or choose an option.
Set state: working at the same step when you resume, and state: failed if work
cannot continue. Only report complete after the actual checks succeed.

Progress steps for Flows:
1. Sign in: before Part 1.
2. Choose the flow/repository: before Part 2's section 1 (through section 2,
   and Part 3 for a custom flow).
3. Connect tools: before Part 2's section 3.
4. Activate the flow: before Part 2's section 4.
5. Verify the listening state: before Part 2's section 5. After its checks
   succeed, PATCH step: 5, state: complete.

On HTTP 409, GET current progress and reconcile; never overwrite newer progress
or regress a step. If a PATCH response is lost, GET before retrying. If a step is
already complete, verify the actual account/app/flow state before continuing;
progress reports alone are not proof that setup succeeded. On 429, honor
Retry-After. Retry transient network/5xx failures with bounded backoff. On 404,
stop reporting (the session expired or the token is invalid) and tell the user;
do not recreate or switch their session silently. A progress service outage
must not roll back working setup or cause duplicate installation/activation.

## Web input — ask on the signup page, never in chat

When a repository, workflow, trigger, approver, or confirmation is missing,
use the Progress API to show the question in the user's original signup tab.
Do not ask the user to answer in your chat. Do not operate the page yourself.
Only request non-secret choices; never ask for passwords, OAuth codes, API keys,
or sensitive personal data through this endpoint. Sign-in and provider consent stay on
their own approval pages, opened for the user as described above.

First PATCH the current progress step to state: waiting using its latest
revision. Then POST the exact Progress API URL with Authorization: Bearer
<Progress token> and Content-Type: application/json:

~~~json
{"key":"repository","label":"Which owner/repository should this flow use?","type":"text"}
~~~

For a choice list use type: select and options, for example:

~~~json
{"key":"flow_kind","label":"A prebuilt flow from the catalog, or a custom flow?","type":"select","options":["prebuilt","custom"]}
~~~

Keys are stable lowercase identifiers (up to 40 characters); labels are at
most 160 characters. Ask one question at a time. The response has
inputRequest.id, the current progress step, and status: pending. Repeating the same key is idempotent;
a different question while one is pending returns 409 input_pending.
The original browser tab can answer; a read-only watcher link cannot.
Do not include answers in progress PATCH bodies or in final chat output.

If the user explicitly chooses to save an inactive preview, verify the draft
through GET /api/v1/flows/listeners/<agentId>, then POST a non-interactive
notice to the same Progress API URL. Use type: notice, a concise label, and
actionHref set only to /dashboard/workflows/listeners/<agentId> (a UUID). The
page will show a preview link and say activation is still pending. Notices
cannot collect answers and must not be used to claim an inactive draft is a
completed signup:

~~~json
{"key":"draft_saved","label":"Your Flow is saved as an inactive preview. It will not run until activated.","type":"notice","actionHref":"/dashboard/workflows/listeners/537e4857-5590-42e8-8731-66441b466542"}
~~~

Poll GET on the same Progress API URL with Authorization: Bearer <Progress token>
every 3 seconds until inputRequest.id matches and status is answered. The
authenticated GET includes inputRequest.answer. An unauthenticated GET never
includes the answer. Check the answer against the catalog/repository contract,
then PATCH the current step back to working using the latest revision. If the
session expires or the page cannot accept input, report that blocker; do not
silently switch to chat questions or fabricate a choice.

The bearer token is shared only with the agent and the original browser tab;
keep it out of URLs and logs. Its authorization does not grant Cloud account
access. PUT is for the browser to submit a choice, not an agent shortcut.

---

# Part 1: Cloud account

# Sign In to Agent Relay Cloud

Use the existing OAuth device flow. No API key, invitation, dashboard wizard,
or pre-existing Agent Relay account is required. Only the human approves the
sign-in; never approve access on their behalf, ask for their password, or
operate the approval page yourself.

Paths below are relative to the Cloud API base, `https://2342b4a2-agentrelay-web.agent-workforce.workers.dev/cloud`,
including its `/cloud` prefix. When a guide names a different Cloud base (for
example a local development stack), use that exact base throughout and never
fall back to production.

## HTTP client

Use `curl` or `fetch`. Avoid Python's `urllib` or `requests` with their
default User-Agent: the device-token endpoint answers them with HTTP 403 and no
JSON body (tracked in AgentWorkforce/cloud#4254). Never print a raw auth
response while debugging; on success it contains tokens. Send JSON request
bodies with `Content-Type: application/json`, set a 30-second request timeout,
and check every response status before proceeding.

## 1. Start the device grant

`POST /api/v1/auth/device/start` with:

```json
{"client_name":"My agent setup"}
```

An agent-driven signup guide also passes its product as `signup_source`
(`"teams"` or `"flows"`); keep the value the guide names. Omit it otherwise.

Expect HTTP 201 with `device_code`, `user_code`, `verification_uri_complete`,
`verification_uri`, `interval` (seconds), and `expires_in` (seconds). Keep
`device_code` private. Open `verification_uri_complete` in the person's
browser with the OS URL opener (for example macOS `open`, with the URL passed as
a separate argument, never interpolated into shell code), or give them the link
if no opener is available. Show the `user_code` so they can compare it. The page
lets them sign in with Google, review the requesting device, and Approve or
Deny. A signup marker in the returned URL creates the right account type;
preserve it through sign-in. Do not call `/auth/device/approve` yourself.

For a fresh signup, you can open `/api/auth/google/start?next=<encoded-return-path>`
on the same Cloud base first (in production,
`https://2342b4a2-agentrelay-web.agent-workforce.workers.dev/cloud/api/auth/google/start?next=<encoded-return-path>`), where `encoded-return-path` is the URL-encoded pathname plus query of
`verification_uri_complete`. This opens Google immediately and returns to the
same device approval with its code and signup marker intact. Never open the
production URL for a non-production base: that would sign the human in to
production instead.

## 2. Poll for tokens

While the browser is open, wait `interval` seconds between POSTs to
`/api/v1/auth/device/token` with this JSON (substitute the private
`device_code`):

```json
{"grant_type":"urn:ietf:params:oauth:grant-type:device_code","device_code":"<device_code>"}
```

- `authorization_pending`: keep waiting; respect a returned interval.
- `slow_down`: increase the interval by at least 5 seconds.
- HTTP 429: respect `Retry-After` and increase the interval.
- HTTP 5xx or request timeout: retry with backoff, bounded by `expires_in`.
- `access_denied`: stop. `expired_token` or `invalid_grant`: explain and start
  a new grant only if the person still wants to continue. Never poll past
  expiry.

HTTP 200 returns `access_token`, `refresh_token`, `access_token_expires_at`,
`refresh_token_expires_at`, `api_url` and `token_type`. Keep credentials in
memory or a private file (directory 0700, file 0600) outside repositories.
Never echo tokens, put them in chat or URLs, or dump full authentication
responses. Use `Authorization: Bearer <access_token>` for subsequent Cloud
requests. Reject an `api_url` pointing at another origin; keep using the Cloud
base above.

## 3. Refresh before expiry

Before expiry, `POST /api/v1/auth/token/refresh` with
`{"refreshToken":"<refresh_token>"}`. The response uses camelCase:
`accessToken`, `refreshToken`, `accessTokenExpiresAt`, `refreshTokenExpiresAt`,
`apiUrl`. Replace both stored tokens atomically. Serialize refreshes: the
refresh token rotates and must not be shared between machines. An
invalid/expired refresh requires a new device login, not an endless retry.

## 4. Read the account and workspace

`GET /api/v1/auth/whoami`. Require `authenticated: true` and read `user.id`,
`user.email`, `currentWorkspace.id`, and `currentOrganization.id`. New signups
create a workspace automatically. Reuse it; do not create duplicate
accounts/workspaces. If `currentWorkspace` is missing, or an existing account
is in the wrong workspace, resolve that with the person before connecting or
activating anything. Never silently replace an existing connection to another
account.

## 5. Hand the session to the official CLI

When running a child process, pass these through its environment from your
private session object (never interpolate their values into logged commands):

```text
CLOUD_API_URL=<the Cloud base above; https://2342b4a2-agentrelay-web.agent-workforce.workers.dev/cloud in production>
CLOUD_API_ACCESS_TOKEN=<access_token>
CLOUD_API_REFRESH_TOKEN=<refresh_token>
CLOUD_API_ACCESS_TOKEN_EXPIRES_AT=<access_token_expires_at>
CLOUD_API_REFRESH_TOKEN_EXPIRES_AT=<refresh_token_expires_at>
```

The official CLI consumes this session. Refresh the parent session before
starting a long command; do not concurrently refresh it from parent and child
processes. Do not overwrite an existing CLI auth file or copy a session to
another machine. Delete temporary authentication files after the work is
complete.

---

# Part 2: Flows

# Set Up Agent Relay Flows

Drive the setup through the documented Cloud APIs and the official CLI. Do not
use computer use, browser automation, or the dashboard UI. The human approves
sign-in, tool consent, and provider logins on their own approval pages; open
those links with the OS URL opener (or give them the link), then verify by
polling the API, never by inspecting the browser.

Paths below are relative to the Cloud API base, `https://2342b4a2-agentrelay-web.agent-workforce.workers.dev/cloud`,
including its `/cloud` prefix; the site is `https://2342b4a2-agentrelay-web.agent-workforce.workers.dev`. The flow
catalog is the one exception: it is a site API, served from the site origin
without `/cloud` (`https://2342b4a2-agentrelay-web.agent-workforce.workers.dev/api/v1/flows/catalog`). When a
guide names a different site and Cloud base (for example a local development
stack), use those exact origins throughout, including for the catalog, the
CLI's `--api-url` and the dashboard links, and never fall back to production.
Flows can be configured from any machine with HTTPS and Node.js 22+ for the CLI.

## Requires

A signed-in Cloud session: an access token and the `user.id` and
`currentWorkspace.id` from whoami. Get them with `signing-in-to-agent-relay-cloud`
first. Send `Authorization: Bearer <access_token>` only to the Cloud API
endpoints below. Never send it to the site's flow catalog or to a catalog
`source.rawUrl`; fetch those without credentials.

## API map

- Flow catalog (site origin, not under `/cloud`):
  `GET https://2342b4a2-agentrelay-web.agent-workforce.workers.dev/api/v1/flows/catalog` and `/<id>`.
- Tool consent links: `POST /api/v1/integrations/connect-link`; poll
  `GET /api/v1/workspaces/<workspaceId>/integrations/<provider>/status`.
- Coding-agent credentials: the official CLI's `cloud connect` (section 3);
  `GET /api/v1/cloud-agents` inspects existing connections.
- Activation: `POST /api/v1/flows/deploy` with the body in section 4.
- Custom-flow prompt generator (optional): `GET /api/v1/flows/prompt/generate`
  reports whether it is available.
- Verification: `GET /api/v1/flows/listeners/<agentId>`.

## 1. Prebuilt or custom?

Ask the human first, unless their request already says: a **prebuilt** flow
from the Agent Relay catalogue (https://2342b4a2-agentrelay-web.agent-workforce.workers.dev/flows), or a **custom**
flow of their own? In an agent-driven signup, ask through that guide's
web-input protocol on the signup page, not in chat.

- Prebuilt: continue with section 2.
- Custom: follow **Custom flow: deploy journey** below. A custom flow is
  activated by the same `POST /api/v1/flows/deploy` API as a prebuilt one and
  as the deploy builder at https://2342b4a2-agentrelay-web.agent-workforce.workers.dev/cloud/flows/deploy; there is
  no separate custom API.

## 2. Choose the flow and repository

Ask the human for missing product choices: repository, desired
workflow/trigger, and approver. In an agent-driven signup, ask through that
guide's web-input protocol on the signup page, not in chat. Ask for the approver's
GitHub username in plain language (for example, "octocat" or "@octocat"),
not an internal provider-address format or their Google email. Normalize the
answer to github:@handle when constructing the deploy API request. Human-gate
replies are matched to that provider identity. Infer choices from the user's request and current
repository where clear.
Do not invent a repository or enable automation on an unrelated project.

GET https://2342b4a2-agentrelay-web.agent-workforce.workers.dev/api/v1/flows/catalog and select a matching entry from flows.
GET https://2342b4a2-agentrelay-web.agent-workforce.workers.dev/api/v1/flows/catalog/<id> for its full contract. Use the catalog's
kind before doing anything executable: only an entry with kind: flow (or a
legacy entry with kind omitted) is deployable through the direct-source API
below. An entry with kind: extension is discoverable metadata, not a standalone
listener. Do not download or deploy its source, even when it declares trigger
and input fields. Inspect baseFlowId and extension.activation instead. If the
extension is blocked, report the unmet dependencies and that it can only be
added to its base flow after a supported extension activation path is available;
never substitute a standalone flow deployment.

For a deployable flow, use the catalog's
supportedRepositoryHosts, defaultTrigger, inputs.required, inputs.defaults, and
inputs.allowedAgents. Name a model per harness in inputs.models when the house
default is wrong (for example {"claude": "claude-sonnet-4"}); omit it to fund
the house default for each declared agent. Download source.rawUrl, verify its bytes against
source.sha256, and use that source text unchanged for a recommended flow.
The source is TypeScript, not the source URL. Do not guess a template or hash.

## Custom flow: deploy journey

There is no journey API. A `journey_id` (for example in a
`next=/flows/deploy?journey_id=...` link) is only an analytics tag: pass it
through unchanged where you received it, and never call an endpoint with it or
wait on it. A custom flow is activated by the same `POST /api/v1/flows/deploy`
as a prebuilt one; only where the source comes from differs.

1. Author the flow with `writing-relayflows` (the Relayflows v2 engine,
   `@relayflows/surface` / `@relayflows/sdk`, CLI `flows`). Defer to that
   skill for the flow's shape and checks; Flows are always v2. Cloud's
   prompt generator is an optional shortcut: use it only when
   `GET /api/v1/flows/prompt/generate` returns `available: true`. If it
   reports unavailable, refuses your session (403 `session_required`), or
   fails, author with `writing-relayflows` instead; do not retry into it.
2. Choose its repository, trigger and approver as in section 2.
3. Continue with sections 3 to 5 unchanged. In section 4, `source` is the
   custom TypeScript text, `workflow` is its label, and `promptSpec` is sent
   only when the prompt generator produced one. `base` and `extensions` are
   optional and only for a flow that names them.

The human-only steps are the same for both paths: Google sign-in and device
approval, GitHub and trigger-app OAuth including the repository grants,
model-provider login when section 3 calls for one (not for an ordinary first
activation), and confirming the repository and trigger before activation. Do every other step yourself through the APIs.

## 3. Connect the required tools and coding agents

Use bearer-authenticated POST /api/v1/integrations/connect-link:

```json
{"provider":"github","workspaceId":"<currentWorkspace.id>"}
```

Open the returned connectUrl for the user to approve. Keep token/sessionToken
private. Connect only the repository and tools the chosen flow requires.
For GitHub the user must grant repository access. Repeat with the chosen trigger
provider if different. Reuse existing ready connections rather than relinking.
Check GET /api/v1/workspaces/<workspaceId>/integrations/<provider>/status
until oauth.connected is true (poll with backoff and a bounded timeout); a
returned connect link or a closed popup alone does not prove the connection.
For the GitHub-triggered Software Garden flow, activation separately checks
that the connected GitHub App covers the selected repository. Its trigger
does not require background data indexing, so do not block solely because
the broader status.ready is false from queued syncs.
If a different flow declares Relayfile data that requires synced records,
verify that readiness separately before activating it.

Do not connect a Claude or Codex subscription yet. The first three runs use
Cloud's own model key, so no provider login is needed to activate. If the chosen
flow declares more than one coding agent (the catalog's `inputs.defaults.agents`,
which becomes `inputs.agents` in the deploy body), included Cloud runs
can fund only one of them per run — connect your own subscription for at least
one declared agent before activating a multi-agent flow. After the included
runs, activation and launches will ask for your own subscription; only then use
the official Relay CLI with the private credential environment from
`signing-in-to-agent-relay-cloud` (its section 5) and a PTY:

This promotion depends on Cloud's internal house-key proxy and account
enrollment. If local activation returns flow_credentials_unavailable or
flow_model_not_connected before the three promotional runs, treat it as an
internal configuration or eligibility issue. Do not ask the user to connect
Claude/Codex, provide an API key, or choose an inactive draft as a workaround.
Report the blocker (in a signup, through its Progress API) and have an internal developer
verify the proxy, promotion flag, provider readiness, and enrollment. Never
copy a house key into the agent environment or expose it in this guide.

```sh
npx --yes agent-relay@13 cloud connect anthropic --api-url 'https://2342b4a2-agentrelay-web.agent-workforce.workers.dev/cloud'
```

Use anthropic for Claude or openai for Codex, according to the selected flow.
The command drives provider login; open its authorization URL for the user,
and keep the process alive until it confirms the credential is connected.
Google approval does not grant GitHub or model-provider access: those services
may require their own consent. Never fabricate credentials or claim consent
happened. GET /api/v1/cloud-agents lets you inspect the account's credential
state without reconnecting.

## 4. Activate through the same API as web onboarding

POST /api/v1/flows/deploy with Content-Type: application/json and the bearer
session. The token must be the device-flow session from
`signing-in-to-agent-relay-cloud` (scope `cli:auth`) or a token with
`flows:listeners:write`, and `workspaceId` must be that token's workspace. This is the direct-source listener API used by flows deploy, not the
browser onboarding handoff: source is TypeScript text, repository is singular,
and sources contains provider/settings objects. The catalog supplies the source
reference and defaults; it is not itself a deploy request. The current endpoint
does not accept a flowId/repositories-only catalog activation request or fetch
the source for you. For multiple repositories, submit one deployment per
repository with a distinct name and handoffId.

For the catalog's Software Garden entry (id `software-factory`), construct this body, substituting the
workspace, verified source, repository, GitHub approver and a new UUID:

```json
{
  "workspaceId": "<currentWorkspace.id>",
  "name": "Software Garden",
  "workflow": "software-factory",
  "source": "<verified TypeScript source text>",
  "handoffId": "<one UUID generated for this setup>",
  "inputs": {"approver": "github:@octocat", "agents": ["claude"]},
  "mode": "activate",
  "repository": {"owner": "acme", "name": "api"},
  "sources": [{"provider": "github", "settings": {"repository": "acme/api"}}]
}
```

For another deployable kind: flow catalog entry use its id as workflow, allowed agents, and
defaultTrigger for sources, then apply the user's trigger settings. Scope a
GitHub issue trigger with settings.repository set to the chosen owner/name;
for GitLab use settings.project. Cloud does not derive this filter from the
deployment repository. An empty filter can trigger on other repositories in
the workspace. Only use a different trigger scope when explicitly requested.
Give each repository its own trigger filter for multi-repository setup. Do not send
the example acme repository or octocat approver unchanged. The workflow field
is a label, not a source lookup; keep the verified source in the request.
For GitLab set repository.host to gitlab and use the namespace path
as owner. Reuse the handoffId on retry. Before retrying an ambiguous network
failure, GET /api/v1/flows/listeners and check whether the flow already exists;
do not create a new ID/name on every retry. Activation subscribes to matching
future events and can run work; confirm the intended repository and trigger
with the user if they have not specified them.

HTTP 201 must contain agentId and status: listening. A draft is not completion.
For a 409 workspace_mismatch, verify the active workspace; for connection
preflight failures, fix the indicated connection before retrying. If the user
wants to save incomplete work, use mode: draft explicitly and report that it
is inactive. Never mask activation failures by silently falling back to draft.

## 5. Verify

GET /api/v1/flows/listeners/<agentId>. Require listener.status: listening and
verify its repository and sources match the request. Open
https://2342b4a2-agentrelay-web.agent-workforce.workers.dev/cloud/dashboard/workflows/listeners/<agentId> for the user. Report the flow name,
workspace, repository, trigger, and verified listening state. This proves
activation; only an actual completed run proves execution. Do not create a
real issue or launch paid work merely to make the onboarding check turn green.
Delete temporary authentication files after the work is complete.

The desktop app is optional for Flows. If the user also wants local session
sharing, use `setting-up-agent-relay-desktop` and `setting-up-agent-relay-sessions`
with the same account (an agent-driven signup can follow
https://2342b4a2-agentrelay-web.agent-workforce.workers.dev/signup/agent/teams).

---

# Part 3: Custom flow authoring

# Writing Relayflows

## Overview

Relayflows turns a coding-agent task into steps a journal can inspect, verify, and resume. A flow is data (YAML/JSON) or code (TypeScript) that compiles to the same journal-backed kernel spec. Every effect is journaled before it's treated as real — a journal write that fails fails the step, with no silent fallback.

**Name collision warning.** An older, unrelated engine is also casually called "Relayflow" (singular): `@relayflows/core`'s `WorkflowBuilder`, a chained builder (`workflow('name').pattern('dag').agent(...).step(...).run()`). This skill is the **v2** engine: `@relayflows/surface`'s `flow()` function and the YAML/JSON dialect compiled by `@relayflows/sdk`. If you see `.pattern(`, `.agent(` as a chained builder call, or `ctx.workflow.run()`, you are looking at legacy code. Do not use that syntax for new work; keep v2 guidance inside this skill.

## When to use this skill

- Writing a new `.flow.ts` or `.flow.yaml`/`.flow.json` for the `flows` CLI (package `@relayflows/sdk`, binary name `flows`).
- Deciding whether a step needs `run` (shell), `llm` (bare model call), or `agent` (harnessed coding agent in a workspace).
- Choosing a verification gate that will actually run — a wrong choice here compiles fine and fails at `flows run` time, not authoring time.
- Wiring up `cli`/`model` for an `agent` or `llm` step, in either language.
- Debugging a `REFUSED [...]` message from `flows check` or `flows run`.
- Deciding between running a flow locally, deploying it to listen for tickets, or scheduling it on a cron.

## The ladder

Three step verbs, one per rung (`@relayflows/sdk`'s `StepType = 'deterministic' | 'llm' | 'agent'`):

1. **`run` / `deterministic`** — a shell command. No model. Implicit gate is `exit_code == 0`.
2. **`llm` / `llm`** — a bare model call. Prompt in, verified output out. No workspace, no tool use.
3. **`agent` / `agent`** — a harnessed coding agent in a workspace. Returns `{ summary, artifacts }`, not raw text.

Plus resident verbs that aren't ladder rungs: `human` (durable approval), `dispatch` (a direct child flow declared in `use`), and `done` (typed finish). `dispatch` executes in `2.0.35` and newer; see **Human approval and direct child flows**. The generated **helper** namespace (`f.slack`, `f.github`, `f.linear`, `f.notion`, `f.jira`, and 30+ others — see **Helpers**) and YAML `on`/triggers are separate surfaces.

Most flows only need `run` and `llm`. Climb to `agent` once a step needs hands on a real workspace.

## Two ways to author the same thing

**YAML/JSON** is data: `flows check` or CI can validate it without running anything. **TypeScript** calls the same primitives imperatively as ordinary async code. Both compile to the same journal.

### TypeScript

```ts
import { flow } from '@relayflows/surface';

export default flow('hello', { budget: { wallclock: '10m' } }, async (f) => {
  const greeting = await f.run('echo "Hello from Relayflows"');
  console.log(greeting.trim());

  const answer = await f.agent('greeter', {
    task: 'Reply with one short hello sentence. Do not use tools or modify files.',
    cli: 'claude',
    model: 'claude-sonnet-4-6',
  });
  console.log(answer.summary);

  f.done('success');
});
```

### YAML

```yaml
version: '0.1.0'
name: hello
steps:
  - id: greeting
    type: deterministic
    command: 'echo "Hello from Relayflows"'
  - id: greeter
    type: agent
    dependsOn: [greeting]
    instruction: 'Reply with one short hello sentence. Do not use tools or modify files.'
    cli: claude
    model: claude-sonnet-4-6
```

**Version note.** The contracts below were checked against the published `@relayflows/surface@2.0.35` / `@relayflows/sdk@2.0.35` declarations. Direct `use`/`f.dispatch` composition requires `2.0.35` or newer. `AgentOptions.permissions` landed in `2.0.17`; predicate `.gate()` and `artifact_exists` landed with [flows#449](https://github.com/AgentWorkforce/flows/pull/449); `f.human` was already executable in `2.0.22`.

## The real `Ctx` contract (TypeScript)

From the shipped `@relayflows/surface@2.0.35` `.d.ts` files (`dist/context.d.ts`, `dist/step.d.ts`, `dist/flow.d.ts`, `dist/completion.d.ts`):

```ts
export interface AgentResult {
  summary: string;
  artifacts: string[];
}

export interface PermissionsSpec {     // exported from the package root
  fileGlobs?: string[];
  networkAllowlist?: string[];
  accessPreset?: 'readonly' | 'readwrite';
}

export interface AgentOptions {
  task: string;
  workspace?: string;
  permissions?: PermissionsSpec;         // since 2.0.17 — declared and journaled, NOT enforced
  cli?: string;
  model?: string;
  cwd?: string;                          // working dir for the CLI subprocess; defaults to the flow-runner's cwd
  transport?: 'direct' | 'relay';        // see Agent-relay transport, below
}

export interface LlmOptions {
  output: Record<string, unknown>;       // JSON Schema, checked before the journal accepts the result
  cli?: string;
  model?: string;
}

export interface DispatchResult {
  name: string;
  completionReason: 'success';
  completionDetail?: string;
}

export interface Ctx extends Helpers {   // Helpers = f.slack, f.github, f.linear, f.notion, ... — see Helpers
  readonly mcp: Readonly<Record<string, Readonly<Record<string, (args: unknown) => Step<unknown>>>>>;
  run(command: string, options?: { timeout?: string | number }): Step<string>;
  llm(strings: TemplateStringsArray, ...values: unknown[]): Step<string>;
  llm(prompt: string, options: LlmOptions): Step<unknown>;
  agent(name: string, options: AgentOptions): Step<AgentResult>;
  human(question: string, options: { to: string }): Step<boolean>;
  dispatch(flow: string, input: unknown): Step<DispatchResult>;
  done(reason: FlowCompletionReason): void;
  cloud: CloudHelper;
  memory: MemoryHelper;
}
```

Do not add fields to `AgentOptions`/`Ctx` that aren't in this list — they don't exist in the shipped SDK. In particular: **no flow-level named-agent selector on `f.agent`, no `recoveryMode`/`surfaces`/`output`** on the TypeScript call site. Those stay YAML/JSON step fields. `FlowHeader` (`dist/flow.d.ts`) allows `use`, `identity`, `memory`, `budget`, `tools`, `workspace` — no bare `agents` map key (TypeScript composes reusable flows via `use: string[]` instead — see **Named agents and flow composition**). An unknown *header* field throws a `TypeError` at authoring time, before anything is journaled; an unknown `AgentOptions` field is caught by `tsc`, not at runtime.

**`permissions` is a real TypeScript option and it does not sandbox anything.** Since `2.0.17` it is on `AgentOptions`, validated by the SDK, lowered to `file_globs`/`network_allowlist`/`access_preset` and journaled with the step — and nothing reads it to gate a file or a network call. `flows check` warns `permissions_unenforced` on any declaration, including `{}`. Enforcement is [flows#442](https://github.com/AgentWorkforce/flows/issues/442). Declare it to record intent; do not rely on it to contain an agent.

`run()`'s second argument is a real, current option: `{ timeout?: string | number }` — a duration string (`"15m"`) or milliseconds. Every real cookbook example that runs something slower than a few seconds sets this explicitly.

### `f.done()`'s closed set is six values, not four

```ts
export const FLOW_COMPLETION_REASONS = ['success', 'step_failed', 'canceled', 'budget_exceeded', 'needs_human', 'declined'] as const;
```

An authored body can meaningfully call `f.done('success')`, `f.done('step_failed')`, `f.done('needs_human')` (parks the run — it's not a kernel cancellation), or `f.done('declined')` (a deliberate decision not to act — also not a cancellation). `'canceled'` and `'budget_exceeded'` exist in the same closed set but are the **kernel's** to record; a body calling them itself is calling the wrong verdict for what actually happened. The verdict for a human declining a durable approval is `f.done('declined')`, not `f.done('canceled')` — a human declining is a decision, not the kernel calling off the run.

`Step<T>` (`dist/step.d.ts`) is a `PromiseLike<T>` with one extra method, `.gate(...)` — see **Verification gates**. Every awaited step must actually be awaited — an unawaited or manually-`.then()`-chained step is refused (`unawaited_step` / a manual `.then()` is not an await), not silently dropped.

## Verification gates

Verification is control flow, not decoration — a gate decides whether a step actually completed, not just whether the process exited cleanly.

### YAML/JSON (`@relayflows/sdk`'s `VerificationSpec`)

```ts
export type VerificationSpec = ExitCodeGate | OutputContainsGate | JsonSchemaGate | NamedDataGate;
// NamedDataGate = ReferencesInputGate | SubprocessGate | WordCountBoundsGate | RegexMatchGate
//              | ArtifactExistsGate
```

- `exit_code` — implicit default for `deterministic` steps. Not configurable; writing it explicitly is allowed and compiles to the same thing as omitting it.
- `output_contains` — step output (stdout tail, or the LLM value stringified) contains `value`.
- `json_schema` — step output validates against a JSON Schema (`boolean | Record<string, unknown>`). Used for structured LLM/agent output; `output?: JsonOutputSchema` on an `llm`/`agent` step is sugar that compiles to this.
- `subprocess_gate` — runs `command` under `/bin/sh`, judged on its exit code. The gate that actually works for "check a file the agent wrote": `{ type: 'subprocess_gate', command: 'test -s review.md' }`. If you also need to inspect the step's own output (via `from_output`), the SDK's generated wrapper reads its internal `FLOWS_INPUT` env var, extracts the value, and passes it to *your* command as `$INPUT` — `FLOWS_INPUT` itself is never visible to the author's command, only `$INPUT` is: `{ type: 'subprocess_gate', command: 'echo "$INPUT" | grep -q PASSED', from_output: ['summary'] }`.
- `regex_match` — the step's output (or a value at `in_output_at`) matches `pattern`, evaluated by a non-backtracking RE2 engine (only `i`, `m`, `s` flags).
- `artifact_exists` — passes when the step's journaled `artifacts` list contains `path` (working-directory-relative POSIX). It reads the journal, not the disk, so it sees exactly what the worker measured at `step.completed`; a path the worker never journaled reads as missing even when the file is there.
- `word_count_bounds`, `references_input` — narrower named gates; see `packages/sdk/src/named-gates.ts` in the flows repo for the exact contract.

```yaml
- id: classify
  type: llm
  prompt: 'Classify this ticket as bug, feature, or question: "the export button does nothing"'
  cli: claude
  model: claude-sonnet-4-6
  verification:
    type: output_contains
    value: bug
```

### TypeScript (`Step<T>.gate(...)`) — read this before you write one

`Step<T>` has **two** overloads, and **one `.gate()` per step** — a second throws `unsupported_gate`:

```ts
gate(config: NamedGate): Step<T>;
// NamedGate = ReferencesInputNamedGate | SubprocessNamedGate | WordCountBoundsNamedGate
//           | RegexMatchNamedGate | ArtifactExistsNamedGate
// — the surface-visible subset of NamedDataGate above. Data, not code: `flows check` can prove it.

gate(predicate: (value: T) => boolean, because?: string): Step<T>;
// Runs since flows#449. The executor calls the closure once, on the journaled value, and
// journals the VERDICT as a lowered `<step>.gate` step — so resume and replay read the
// recorded verdict and never re-run the function. A false verdict fails the run as
// `gate_failed`, carrying `because`.
```

The predicate form was refused with `unsupported_gate` through `2.0.16`; on `2.0.22` it runs. **Prefer the config-object form anyway** — `flows check` cannot prove a predicate (docs/SURFACE.md §6), so a named gate is the only kind a preflight can tell you about before you spend a run:

```ts
const outdated = await f.run('npm outdated --json 2>/dev/null || true')
  .gate({ type: 'subprocess_gate', command: 'test -n "$INPUT"' });   // provable by `flows check`

const review = await f.agent('review', { task: '…', cli: 'claude' })
  .gate({ type: 'subprocess_gate', command: 'test -s review.md' });  // checks a file the agent wrote, ignores $INPUT
```

(Verified for real: `.gate({ type: 'subprocess_gate', command: 'test -n "$INPUT" && echo "$INPUT" | grep -q pkg' })` against a step outputting `{"pkg":"left-pad"}` passes — `$INPUT`, not `$FLOWS_INPUT`, is what the author's command sees. `FLOWS_INPUT` is an env var the SDK's own generated wrapper reads internally to extract the value before invoking your command; it is never visible to `command` itself.)

An agent step rarely fails by crashing; it fails by returning something plausible and wrong, which a plain retry-on-error never catches. Validate every `agent`/`llm` result, either directly with a supported gate or in a following deterministic verifier.

**Where to put that check depends on what you want a red result to do.** A config-object or predicate gate directly on the agent/LLM step ends the run when it fails; use it when that failure should be terminal. For repairable failures, the following deterministic verifier must explicitly read the preceding output or workspace, produce its own verification result, and record it for repair. Output-dependent gates inspect the step they guard: moving the same gate unchanged would inspect the verifier's output, not the agent's. Use the record/repair/re-record/assert pattern in `relay-80-100-workflow`, gating the final verification result after repair.

## Helpers: journaled effects, not just Slack

`Ctx extends Helpers`, and `Helpers` is generated from 40+ provider adapters shipped in `@relayflows/surface` — `slack`, `github`, `linear`, `jira`, `notion`, `salesforce`, `postgres`, `s3`, `redis`, `daytona`, `gmail`, `google-calendar`, `hubspot`, and more (`dist/helpers/index.d.ts` lists them all). A helper call — `f.slack.post(channel, text)`, `f.github.comment(...)` — compiles to a journaled effect: the receipt the helper returns *is* the journal record, so a retried step that already posted doesn't double-post; the second attempt is deduped by `(step_id, idempotency_key, surface_path)`.

Two things every helper call needs:

- **A relayfile mount for that provider**, locally — `flows check` refuses `helper_slack.credential_missing` (or the equivalent for another provider) without one. Cloud provides a mount for every connected provider automatically.
- **A declared intent** on the flow header: `tools: { slack: true }` in TypeScript, or the equivalent in YAML. Forgetting this is a separate, earlier failure than the mount check.

For local development without standing up a real provider mount, prefix the run with the provider's mock env var — `RELAYFLOWS_SLACK_MOCK=1 flows run ... --local-agent` records the effect (a real file under the daemon's data dir) instead of sending it. This does not exercise live delivery, and should be called out as such wherever you cite it as verification.

**`tools` means two different things depending on where you write it — do not confuse them:**

- `FlowHeader.tools` (TypeScript) / `FlowSpec` top-level `tools` you're declaring intent on: `Partial<Record<keyof Helpers, boolean>> & { relayfile?: string[]; mcp?: string[] }` — e.g. `{ slack: true }`. This is "which helpers this flow uses."
- `FlowSpec.tools` in the compiled YAML/JSON spec is instead `{ fs?: string | string[] }` — path-scoped **filesystem** grants, mirroring `workspace` for shell/deterministic steps. Not the same field, despite the identical name.
- `flows.json` (the project config file) has **no** `tools` field at all — see **`flows.json`**, below. Putting a `tools` key there is refused as `config_invalid`; this is an easy, first-try mistake (made once, personally, writing this refresh).

## `cli` / `model`: what a step actually runs on

Both YAML and TypeScript agent/llm steps can set `cli` and `model` directly. Resolution order for `cli`, identical regardless of authoring language because both compile to the same `StepSpec`:

1. **step** — `step.cli` (or TS's `options.cli`)
2. **named** — the flow's `agents[step.agent]` entry, if the step selects one via `agent: <name>` (YAML/JSON only — TypeScript composes reusable flows via `use:` instead, not a per-step named-agent selector; see **Named agents and flow composition**)
3. **flow** — `FlowSpec.cli` (YAML) — there is no equivalent flow-level `cli` field on the TypeScript `FlowHeader`
4. **project** — nearest `flows.json`'s `cli`, found by walking up from the flow file's directory

No resolution found at any level → `REFUSED [cli_unresolved]`, before anything is journaled, e.g.:

```
$ flows check hello.flow.yaml   # agent step, no cli anywhere
REFUSED [cli_unresolved] Step "greeter" has no CLI at step, flow, or project level. No flows.json was found from "..." to the filesystem root.
```

`model` has **no** flow or project default — only step or named-agent. Omitting it just runs whatever model the resolved CLI defaults to.

### `flows.json`

Nearest-wins project config, walking from the flow file's directory to the filesystem root. The shipped SDK type (`@relayflows/sdk`'s `FlowsJson`) is exactly:

```ts
interface FlowsJson {
  cli?: string;
  executors?: string[];
  models?: string[];
  mcp?: Record<string, McpServerConfig>;
  deploy?: { bucket: string };
}
```

- `cli` — the project-wide CLI default (resolution rung 4 above).
- `executors` — trigger executors this project has registered. A trigger whose `executor` isn't in this list is `no_executor`; a flow that declares `.on(...)` triggers but is invoked directly (not via a webhook) still needs its provider named here — e.g. `{ "executors": ["github"] }` for a flow with `.on(github.pull_request(...))` handlers, even when you never hit that code path.
- `models` — **an allowlist, not a default.** Any model declared on any step or named agent anywhere in the flow must appear here, or `flows check`/`flows run` refuses `model_unknown` / `llm_cli_unresolved`. Setting `models` does not select a model for anything.
- `mcp` — project-owned MCP server connections.
- `deploy` — a file-bucket target for `flows deploy <flow>@sha256:<digest> --to <bucket-uri>`.

**Verified for real:**

```
$ flows run hello.flow.ts --local-agent --input '{}'   # step declares model "claude-opus-5", flows.json has no models[]
REFUSED [invalid_spec] llm_cli_unresolved: Step "llm-1" declares model "claude-opus-5" for CLI "claude",
but it is not listed in project model registry "…/flows.json"; add the exact model only after verifying
that project is allowed to use it.

$ flows run pr-reviewer.flow.ts --local-agent --input '{...}'   # flow declares .on(github....) handlers, no executors[]
REFUSED [no_executor] webhook trigger "github" is not registered in flows.json
```

Any other top-level key — `tools`, `budget`, `identity`, `agents` — is refused as `config_invalid`. Those are `FlowHeader`/`FlowSpec` fields on the *flow itself*, not on the project config.

## Named agents and flow composition

**YAML/JSON**: a named-agent map declares each `{ cli, model }` pair once; a step's `agent:` selector resolves to it at compile time, and `flows check` flags a named agent nobody selects, or one a step overrides without using.

```yaml
agents:
  planner: { cli: claude, model: claude-opus-5 }
  implementer: { cli: codex, model: gpt-5.6-codex }
steps:
  - id: plan
    type: agent
    agent: planner
    instruction: '…'
```

**TypeScript** has no equivalent `agents:` map key on `FlowHeader` — the closest idiom is a plain object spread reused across calls:

```ts
const planner = { cli: 'claude', model: 'claude-opus-5' };
await f.agent('plan', { ...planner, task: '…' });
```

What TypeScript *does* have is `FlowHeader.use?: string[]`: relative `.flow.ts` paths to direct child flows the body may call with `f.dispatch`. This is composition of complete flows, not a named-agent map. It executes in `2.0.35` and newer; see **Human approval and direct child flows**.

## Parallel agents

Running several agent (or any) steps concurrently is first-class, documented authoring — not a workaround:

> "…`Promise.resolve`, `Promise.all`, `Promise.allSettled`, `Promise.any` and `Promise.race` over authored steps" are "ordinary, supported authoring." — `docs/SURFACE.md`, the authored operation lifecycle

```ts
const LENSES = ['security', 'correctness', 'performance'] as const;

await Promise.all(
  LENSES.map((lens) =>
    f.agent(`${lens}-reviewer`, {
      task: `Review this diff for ${lens} issues ONLY. Write findings to review/${lens}.json.`,
      cli: 'claude',
    }).gate({ type: 'subprocess_gate', command: `test -s review/${lens}.json` }),
  ),
);

const consensus = await f.agent('consensus', {
  task: 'Read review/*.json. Resolve disagreement between lenses. Write review/consensus.json.',
  cli: 'claude', // TypeScript has no flow-level cli; without it (or a flows.json default), flows run refuses with REFUSED [invalid_spec] and the unresolved CLI in the message
}).gate({ type: 'subprocess_gate', command: 'test -s review/consensus.json' });
```

This is the exact shape `examples/pr-review-pipeline` in `AgentWorkforce/flows` (and the cookbook's would-be equivalent) uses: fan out, each lane writes its own file, a reconciliation step reads them all. **Disclosure worth knowing before you rely on it**: the flows package replaces the global `Promise.all` intrinsic, process-wide, for the lifetime of any authored flow execution, so the kernel can prove every concurrent step was actually awaited (rather than inferring group membership from callback identity). It delegates to the real intrinsic and restores it when the last concurrent flow closes — but any other code sharing that process sees the replacement during that window.

## Agent-relay transport: dispatch, not live chat

`AgentOptions.transport?: 'direct' | 'relay'` (default `'direct'`, a local subprocess). Setting `transport: 'relay'` dispatches the step to Agent Relay's own task infrastructure instead — per the shipped type's own doc comment: *"posts to agent-relay so the agent registers as a first-class workspace participant that DMs can steer."*

What this actually is, per `docs/AGENT-RELAY-TRANSPORT.md`: the step calls `POST /v1/actions/task.run/invoke` against relaycast, then waits for a durable final receipt — spawn readiness and invocation acceptance do **not** complete the step, only a terminal receipt does. It needs a pre-provisioned `RELAY_AGENT_TOKEN` (a workspace API key cannot substitute) and the Relay provider running with `AGENT_RELAY_TASK_PROVIDER=1`. The dispatched worker submits its result via an injected `agent_result` tool.

Don't describe this as "agents talking to each other" — there's no documented pattern in this skill's scope for two flow steps to hold a live back-and-forth over Relay channels while both are running. What's real: the step's agent becomes a genuine Agent Relay workspace participant while it runs (so a human, or another agent with the right access, can DM it and potentially steer it — per the type's own wording), and the flow still only sees a request-and-durable-receipt shape, not an open channel. Multi-agent *coordination* inside a flow today means sequential handoff (one step's return value feeds the next) or parallel fan-out (**Parallel agents**, above) — not live messaging.

## Human approval and direct child flows

`f.human` parks a root run on a durable approval. `f.dispatch` executes a statically declared direct child in the same durable tree on `2.0.35` and newer. Both must be awaited.

`f.human` parks the run durably on a kernel `wait.human`, keyed `human-<n>` in the order the body asked. A person answers, and `flows resume` continues the body from that line:

```ts
const ok = await f.human(`Ship this?\n${plan.summary}`, { to: 'khaliq' });
if (!ok) return f.done('declined');   // a person's "no" is declined, never canceled
```

```
$ flows answer <run-id> human-1 yes --by khaliq --note 'reviewed the diff'
$ flows resume <run-id>
```

`to` names who is asked and is recorded with the question — **it is not a delivery address**; nothing notifies that person for you. The kernel closes a wait once, so the first answer wins, and it records `attribution: "client_asserted"` because the daemon socket, not the kernel, authenticated whoever `--by` names. The answer step takes a `.gate(config)` like any other step. One caveat: `f.human` needs a durable root run to park in — under a runner that has none it refuses with `unsupported_verb`, so run it with `flows run`.

For a composed local run, declare each direct child in the parent's static `use` header, then dispatch by the child's declared `flow(...)` name—not by its file path:

```ts
// release.flow.ts
import { flow } from '@relayflows/surface';

export default flow('release', { use: ['./implement.flow.ts'] }, async (f) => {
  await f.run("printf '%s' prepare");
  const child = await f.dispatch('implement', { issue: 123 });
  await f.run("printf '%s' publish");
  f.done(child.completionReason);
});
```

```ts
// implement.flow.ts
import { flow } from '@relayflows/surface';

export default flow('implement', async (f, input: { issue: number }) => {
  // The type is not a runtime check: dispatch input is JSON, so validate it
  // before it reaches a shell command.
  if (!Number.isSafeInteger(input.issue) || input.issue < 1) throw new Error('issue must be a positive safe integer');
  await f.run(`printf '%s' ${input.issue}`);
  f.done('success');
});
```

Interpolating step input into `f.run` is shell code: validate it (or quote it) first, because the TypeScript type of `input` is not checked at runtime.

`use` resolves relative `.flow.ts` files before a body runs. Missing or repeated files, duplicate direct-child names, cycles, and undeclared or transitive-only dispatch targets are refused. The child's steps receive qualified IDs such as `dispatch-2--run-1`; the dispatch receipt joins child leaves back to the parent's next step. Parent and child share one root budget and worker-capacity pool, and resume reuses journaled identities rather than repeating completed effects. A successful dispatch returns `{ name, completionReason: 'success', completionDetail? }`; any other child verdict fails the dispatch.

Authority narrows: a child cannot declare a second budget, require a helper/MCP capability its parent did not grant, or call `f.human`. Static cycles are refused and runtime child depth is capped at three. This is direct authored composition, not arbitrary public-flow invocation.

To put the **whole local tree** on one Cloud dashboard page, run `flows run --cloud-mirror release.flow.ts --input '{}'` (or affirmatively set `FLOWS_CLOUD_MIRROR=1`). The root, qualified child steps, dispatch receipts, dependency edges, transcripts, source, and output are projected into one connected graph; the local journal remains authoritative. Mirroring uploads that data and is opt-in. A mirror failure cannot fail the local run. The default observer link is separate and does not add the run to Cloud history.

Do not confuse this with **hosted execution**. `flows run --cloud [--sync-code] release.flow.ts --input '{}'` still rejects `use` dependencies with `unsupported_source`: Cloud accepts one self-contained `.flow.ts` source. Use `--cloud-mirror` for the connected child-flow dashboard today; do not promise that Cloud can itself execute a multi-file `use` tree.

**The same "types accept it, the executor doesn't" trap applies to `workspace` on an agent step under `--local-agent`.** Neither an annotated (`'acme/api: readonly'`) nor a bare (`'acme/api'`) value works — both fail identically:

```
REFUSED [invalid_spec] unsupported_workspace_permission: The local agent worker accepts
stream-only steps. Remove workspace or attach a worker that holds its revision pins.
```

It isn't the `: readonly`/`: readwrite` annotation syntax that's rejected — it's that the local-agent worker doesn't hold workspace revision pins at all. Omit `workspace` entirely for a step you intend to run with `--local-agent`; it's a real, working field only against a worker that supports it (Cloud's). Against a worker that does hold pins, the suffix is separately refused, and the remedy the SDK's own message names is `f.agent`'s `permissions` option — which records the scope and does not enforce it.

## Running it: `flows check` / `run` / `deploy` / `schedule`

```
flows check [--json] [--watch] <flow.ts|flow.yaml|spec.json>
flows run [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--data-dir <dir>] [--local-agent] <flow.yaml|spec.json>
flows run [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--data-dir <dir>] [--local-agent] <flow.ts> --input <inline-json-or-file>
flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--data-dir <dir>] [--local-agent] <run-id>
flows deploy <flow.ts> --repo <owner/name> --on <provider>[:key=value,...] [--on ...] --approver <handle> [--agents claude[,codex]] [--name <name>] [--draft] [--json]
flows deployments [--json]
flows undeploy [--json] <deployment-id>
flows schedule <flow.ts> --cron "<expr>" --tz <tz> --input <inline-json-or-file>
```

`check` is a pure compile-and-preflight — no daemon, no socket, no data dir. It's the fast, safe way to validate a flow before ever running it, and it does accept `.flow.ts` despite older `--help` text implying only YAML/JSON.

**`--local-agent` is not optional decoration.** Without it, `agent`/`llm` steps have no worker to dispatch to and the run parks indefinitely rather than executing. Every local run in this skill's examples, and every real run in [`AgentWorkforce/flows-cookbook`](https://github.com/AgentWorkforce/flows-cookbook), passes it.

A `.flow.ts` run via `flows run` requires `--input <inline-json-or-file>` even when the flow body ignores its input argument.

**`--data-dir` gotcha, learned the hard way.** If a flow does its own git hygiene on the directory you invoke it from — `pr-reviewer`'s and similar examples' `git clean -fdq -e .workforce` when there's nothing to push — that cleanup deletes *anything else untracked in that directory*, including the daemon's own default `.relayflowd/` state if you didn't move it. The symptom is a bizarre `SQLite journal failed: unable to open database file` on the *next* run in the same checkout, because the previous run's own cleanup step deleted the very journal directory the daemon needed. Fix: put the daemon's data outside the checkout:

```bash
flows run pr-reviewer.flow.ts --local-agent --data-dir /somewhere/outside/the/repo --input '{...}'
```

**Before `flows deploy` will do anything, two things have to already be true — a correct command still fails without them:**

1. **You (or whoever's driving this) are logged in.** `agent-relay cloud login` once; check with `agent-relay cloud whoami`. There's no separate `flows`-specific login — it shares the `agent-relay` Cloud session.
2. **The workspace has a GitHub App installation covering `--repo`, and the `--on` provider is actually a connected integration** — not just declared in the command. `flows deploy` looks up the workspace's GitHub installation before it will activate a listener; with none, it refuses `flow_repository_not_connected` rather than deploying a broken one (`prepare-flow-deploy.ts` in `AgentWorkforce/cloud`). Check what's connected with `agent-relay cloud integration connections --workspace <id>` — a provider showing `degraded` instead of `ready` is not deploy-safe, even though the CLI won't tell you that until you try. This is a separate concern from your own local git push access (a personal git/`gh` credential thing, needed only for testing a flow's own `git push`/`gh pr create` steps locally) — don't conflate the two when debugging a refusal.

**`flows deploy`** creates a persistent listener: each matching ticket (`--on github:labels=agent`, `linear:team=ENG`, `jira:project=OPS`, `shortcut:workspace=…`, `slack:channel=#eng`) launches one Cloud run. `flows deployments` lists what's listening; `flows undeploy <id>` stops it — verified for real this session (deployed `software-factory` against a real repo with `--on linear:team=…`, got back `{"status":"listening", ...}`, then cleanly undeployed).

**`flows schedule`** puts a flow on a cron instead of a ticket trigger — newer (shipped after the `2.0.16` release line) and not yet reflected everywhere in older examples that instead show `flows run --cloud` for the same use case.

**Now fixed, was broken through `2.0.16`** ([flows#461](https://github.com/AgentWorkforce/flows/issues/461), closed): the one-shot `flows run --cloud [--sync-code] <flow.ts> --input <json>` form used to misroute authored TypeScript flows into the declarative-spec loader (`invalid_input: Cannot read or compile the declarative flow`) or refuse with a bare `http_error: HTTP 400`. Root cause: Cloud pinned an older `@relayflows/surface` than the CLI authored against, and refused the version mismatch with those opaque errors instead of naming it. Fixed in `2.0.17` (CLI side) — verified for real: `flows run --cloud --wait <flow.ts> --input '{}'` now submits successfully and returns a real run ID instead of refusing immediately. If you still hit a bare `HTTP 400`/`invalid_input` on this path, you're likely on a CLI older than `2.0.17` — update first before assuming something else is wrong. (Cloud-side reporting of the exact version mismatch, when one still exists, was tracked as a separate follow-up PR at the time of writing — the CLI's own refusal is fixed either way.)

### Watching a local run: observer link vs `--cloud-mirror`

New in **`2.0.32`**. A local run has two ways to be watched, and they are not the same thing:

- **The observer link is the default.** Every `flows run` mints a read-only `ot_live_` link scoped to that run's own `wf-<runId>` channel and prints `Observer: <url>`. It is free, needs only a workspace key, and carries a step projection. `--no-observer-link` suppresses it.
- **`--cloud-mirror` additionally puts the run on the Cloud dashboard** — the richer hosted view: the flow source, **every agent step's transcript**, the run graph, the run's own log, and the run sitting in the same history as your hosted ones. `FLOWS_CLOUD_MIRROR=1` turns it on for a whole shell.

```bash
flows run review.flow.ts --local-agent --input '{}'                  # observer link only
flows run --cloud-mirror review.flow.ts --local-agent --input '{}'   # ...and the dashboard
```

The run prints both, and the dashboard line names Cloud's run id as well as the page:

```
Observer:  https://2342b4a2-agentrelay-web.agent-workforce.workers.dev/observer?key=ot_live_...
Dashboard: https://.../dashboard/workflow/<cloud-run-id>/runner  ·  flows status --cloud --watch <cloud-run-id>
```

**That second id matters and is easy to get wrong.** The report's own `runId` is the *journal's* ULID (`01M3B9...`), while the two hosted verbs that take an id — `flows status --cloud <run-id>` and `flows logs <run-id>` — want Cloud's UUID. Do not pass a journal id to either. Under `--json` both ride in the report as `cloudRunId` and `dashboardUrl`, beside `observerUrl`.

`flows runs` is the odd one out and the way *out* of this problem: it takes no id at all (`flows runs [--limit <n>] [--json]`) and lists the runs the credential can see, newest first, with each one's Cloud UUID — so it is how you find the id the other two want when you no longer have the terminal that printed it.

**Why it is opt-in, not on by default.** Because it is the richer view, it is also the one that *stores* all of that: source, step metadata, agent transcripts, and the CLI's own stderr. Transcripts are whatever the agent printed, including file contents and command output. Everything goes through the same redactor `flows status` uses — but redaction is pattern matching, and pattern matching has a false-negative rate. So the trigger is an explicit request and never the mere presence of a Cloud login. Only an affirmative counts for the env var (`1`/`true`/`on`/`yes`); `0`, empty, and anything nobody meant as a switch all leave the run local.

**What it buys you, concretely:** the three read verbs start answering for *local* runs, which until `2.0.32` only worked for runs Cloud had launched.

```
$ flows status --cloud <cloud-run-id>
RUN 27ab5702-...  local-mirror-agent-confirmation  completed  spend 13,088 in / 61 out / $0.01836
  ✓ ask-claude  agent  1 attempt  3.3s  claude-haiku-4-5-20251001 · 1 turn · $0.01836
  ✓ ask-codex   agent  1 attempt  7.9s
$ flows logs <cloud-run-id> --step ask-claude    # that step's transcript, out of Cloud storage
```

Three behaviours worth knowing before you rely on it:

- **It cannot fail a run.** Every push collapses to a boolean; each poll and the whole finish are bounded. A Cloud outage costs the run its dashboard page and nothing else. If the mirror is refused you get one line naming which switch asked for it, and the run's exit code is untouched.
- **The dashboard says the run was local.** The row is `dispatchType: "local"`, and the run page shows **Ran on: Your machine** instead of a sandbox tile. Cancel is refused for it — Cloud mirrors a local run, it does not control it, so stop it where it is running.
- **A `--cloud-mirror` resume is a second dashboard row, linked to the first.** Cloud refuses to move a terminal run back to `running` (its own hosted resume mints a fresh id too), so the resumed attempt carries `resumedFromRunId` and the run page links the two — the parked "Needs review" row shows what continued it. The mapping lives in `<data-dir>/cloud-runs/`: the Cloud run id and the deployment, no credential, mode 0600, ageing out at 30 days.

**Requires a Cloud login** (`agent-relay cloud login`, or `FLOWS_CLOUD_TOKEN`) — the same credential every other hosted verb uses. Without one, `--cloud-mirror` prints one line saying the run stays local and the run proceeds normally. A local run that joins no workspace is not a defect (RFC-0001 settled decision 7: the journal is the record, the workspace is one view onto it).

### Exit codes and refusal shapes

Every refusal before a journal write is exit **2**, printed as `REFUSED [<kind>] <message>`. The `<kind>` differs by *how* the flow was checked, not just *what* was wrong:

- `flows check` on a YAML/JSON spec, or the declarative-compiler path in general → the preflight's own kind directly: `cli_unresolved`, `model_unknown`, `cli_missing`, `cli_unauthenticated`, `no_executor`, `config_invalid`, etc.
- `flows run` on a **TypeScript** `.flow.ts` whose authored `f.agent`/`f.llm` call can't resolve a CLI or model at runtime → wrapped and printed as `REFUSED [invalid_spec] <message>` with the specific reason (`llm_cli_unresolved`, etc.) inside the message text, not as the printed `[kind]` itself.

Both are real, both are exit 2 — the same underlying problem can print a different `[kind]` depending on whether you hit it via `flows check` on YAML or `flows run` on TypeScript.

## Common mistakes

- **Forgetting `version` in a YAML/JSON `FlowSpec`.** Required, not optional.
- **Attaching two `.gate()` calls to one step.** `unsupported_gate`: a step takes one. Also note a predicate gate is invisible to `flows check` — prefer a config object when preflight should be able to prove it.
- **Putting `tools`, `budget`, or any other `FlowHeader` field into `flows.json`.** `flows.json` only accepts `cli`, `executors`, `models`, `mcp`, `deploy` — anything else is `config_invalid`.
- **Assuming `flows.json`'s `models` sets a default model.** It only validates models already declared elsewhere; it never selects one.
- **Declaring `.on(...)` trigger handlers without registering the provider in `flows.json`'s `executors`.** Even a flow you only ever run directly (never via a real webhook) needs this, or it's refused `no_executor` before your input is even read.
- **Calling `f.done('canceled')` for a human's "no."** That's `f.done('declined')` — `canceled` is the kernel's verdict, not an authored body's.
- **Running an agent/llm-bearing flow without `--local-agent`.** The run parks with nothing attached to do the work.
- **Letting a git-hygiene flow's cleanup step delete your daemon's own data dir.** Pass `--data-dir` outside the checkout for any flow that runs `git clean`/`git checkout --force` on its own working tree.
- **Not awaiting a step, or manually `.then()`-chaining one.** Refused (`unawaited_step`) rather than silently ignored.
- **Running a `.flow.ts` without `--input`.** Required even for flows that don't use their input argument.
- **Assuming `flows run --cloud` for a one-shot TypeScript flow run is still broken.** It was through `2.0.16` ([flows#461](https://github.com/AgentWorkforce/flows/issues/461), now closed) — fixed in `2.0.17`. If you're on an older CLI, update rather than reach for a workaround; `flows deploy` remains the right choice for a persistent listener regardless.

## What this skill does NOT cover

- **Triggers/webhooks** (`.on(github.pull_request(...), ...)`), **memory retrieval**, and the full **helper** catalog's per-provider quirks (`f.mcp`, provider-specific settings) — each is its own surface; see the [Relayflows product docs](https://2342b4a2-agentrelay-web.agent-workforce.workers.dev/docs/relayflows) and the [flows-cookbook](https://github.com/AgentWorkforce/flows-cookbook) for real, run-verified examples of each.
- **Named-agent map equivalent in TypeScript beyond `use:`** — `use` composes complete child flows, not named agent configurations.
- **YAML-only agent step fields with no TypeScript equivalent**: `recoveryMode`, `surfaces`, `output`, the `agent:` named-agent selector, and enforced `workspace` (TypeScript's `workspace` string is accepted by the type but rejected by the local-agent worker — see **Human approval and direct child flows**). `permissions` is **not** on this list — it is a TypeScript option too (see **The real `Ctx` contract**).
- **Hosted multi-file child-flow execution** — `flows run --cloud` refuses `use` dependencies even with `--sync-code`; `--cloud-mirror` is the supported route for a local child-flow tree on the Cloud dashboard.
- The **older `@relayflows/core` `WorkflowBuilder`** engine. Do not use it for new work; this skill intentionally provides no legacy authoring route.

## Quick reference

| Verb / field | Language | Notes |
|---|---|---|
| `f.run(command, {timeout?})` / `type: deterministic` | both | shell command, implicit `exit_code` gate |
| `f.llm(...)` / `type: llm` | both | bare model call, no workspace |
| `f.agent(name, opts)` / `type: agent` | both | harnessed coding agent, returns `{summary, artifacts}` |
| `f.human(question, {to})` | TS only | parks the run on a durable `wait.human`; `flows answer <run> human-<n> yes\|no` then `flows resume` |
| `use: ['./child.flow.ts']` / `f.dispatch(name, input)` | TS only | `2.0.35`+ direct child in one durable tree; dispatch by declared name |
| `f.done(reason)` | TS / kernel | one of `success \| step_failed \| needs_human \| declined` from an authored body; `canceled \| budget_exceeded` are kernel-only |
| `.gate(config)` / `.gate(predicate, because)` | TS | one per step; both run since flows#449, but only a config object is provable by `flows check` |
| `options.transport: 'relay'` | TS/YAML `agent` step | dispatch to Agent Relay's task infra; request+receipt, not live chat |
| `Promise.all([...steps])` | TS | first-class supported concurrent fan-out |
| `flows.json`: `cli, executors, models, mcp, deploy` | project config | exact accepted key set — nothing else |
| `flows check <file>` | CLI | pure validate + preflight, no daemon |
| `flows run <file> --local-agent [--input ...]` | CLI | actually executes; `.flow.ts` needs `--input`, agent/llm steps need `--local-agent` |
| `flows deploy <flow.ts> --repo ... --on ...` | CLI | persistent trigger-based listener; needs `agent-relay cloud login` plus a connected GitHub App + `--on` provider first, or `flow_repository_not_connected` |
| `flows run --cloud <flow.ts> --input ...` | CLI | fixed in `2.0.17` ([flows#461](https://github.com/AgentWorkforce/flows/issues/461)); update if you still see `http_error`/`invalid_input` |
| `flows run --cloud-mirror <file> --local-agent` | CLI | `2.0.32`+; local run also on the Cloud dashboard, transcripts included. Opt-in; `FLOWS_CLOUD_MIRROR=1` for a shell. `--json` gains `cloudRunId`/`dashboardUrl` |
| `flows run --cloud` with `use` | CLI | unsupported: one self-contained `.flow.ts` source only, even with `--sync-code` |
| `flows status --cloud <run-id>` / `flows logs <run-id>` | CLI | take Cloud's **UUID**, not the journal ULID. Answer for mirrored local runs since `2.0.32` |
| `flows runs [--limit <n>]` | CLI | takes **no id** — lists runs newest-first, which is how you find the UUID the two above want |
| `flows schedule <flow.ts> --cron ...` | CLI | cron-based cloud run |

## Verified against

**The `Ctx` and child-flow contracts were checked against the published `@relayflows/surface@2.0.35` / `@relayflows/sdk@2.0.35` declarations.** Earlier gate, helper, and agent-option findings below were established on `2.0.22`; the original `--cloud-mirror` verification was on `2.0.32`.

A three-level root → child → grandchild fixture was run locally from published `2.0.35` packages, then mirrored to the production Cloud dashboard. The [mirrored run](https://2342b4a2-agentrelay-web.agent-workforce.workers.dev/cloud/dashboard/workflow/0d502725-e4c2-4cfd-aed2-ed0dd49bd6ac/runner) completed with `completionReason: success`; its Cloud graph contained seven succeeded work/dispatch nodes, seven runtime dependency edges, both child labels, and no warnings. A separate `flows run --cloud --sync-code` attempt refused the same tree before admission with `unsupported_source`, which is why this skill does not promise hosted child-flow execution. This checks the graph API used by the dashboard, not an authenticated browser screenshot.

**The `--cloud-mirror` section was verified on `2.0.32`, against production, with the published artifact.** `relayflows@2.0.32` was installed from npm into a scratch project — not run from a source tree — and two real local runs were mirrored to `agentrelay.com`:

- a two-step deterministic flow, read back with `flows status --cloud`, `flows logs` (the `runner.log` round-trip) and `flows runs` (the run appearing in history beside hosted ones);
- a two-agent flow, one `cli: claude` step and one `cli: codex` step, spending a real `$0.01836` — whose **per-step transcripts were fetched back out of Cloud storage** and rendered in each provider's own frame vocabulary. That is the claim worth having evidence for: an echo-only flow exercises none of the transcript path.

Two things that verification established and that are easy to assume otherwise. **Flows drives exactly two agent CLIs**: `adapters/index.ts` registers `claude` and `codex`, and anything else resolves to `relayflows-wrapper-v1`, which requires the executable to answer `--relayflows-adapter-v1` with a flows-specific token. A real `devin` CLI on `PATH` rejects that flag outright (`error: unexpected argument`), so it cannot be used as a `cli:` for an agent step no matter what is installed — adding one is a new `adapters/<name>.ts` plus a registry entry. And the **observer projection has no retry**: `run-projection.ts` sets `failed = true` on the first error and every later publish is a no-op, so a transient `429 workspace_busy` — observed for real during this verification, while another run was launching in the same workspace — permanently loses the observer view for that run. The dashboard mirror survived the same window because it classifies `429` as transient and retries on its next poll. If an observer link opens an empty channel, that asymmetry is the first thing to check.

The earlier `2.0.22` refresh settled several contradictions against the published package: `AgentOptions.permissions` exists on the TypeScript call site (added in `2.0.17`, validated and journaled, not enforced — preflight warns `permissions_unenforced`); `run(command, options?)` takes a second `{timeout}` argument; `f.done` takes six `FLOW_COMPLETION_REASONS`; there is no `budget.maxWallclockMs`; predicate `.gate()` and `artifact_exists` ship; and `f.human` executes. Its then-true finding that `f.dispatch` was unsupported was superseded by `2.0.35`. Older real-run recipes are in [`AgentWorkforce/flows-cookbook`](https://github.com/AgentWorkforce/flows-cookbook); check their dates when comparing behavior.

The `flows.json` schema refusal, the `--data-dir` git-hygiene interaction, and the `flows run --cloud` bug were reproduced while building that cookbook past `v2.0.16` (2026-09-17). The `2.0.22` refresh also checked the `workspace: readonly` annotation and the `subprocess_gate` env var name against the real CLI. Child-flow claims above instead rest on the published `2.0.35` run and its production Cloud graph.
