From 8e97e796d2eed06316e62d71504d831e06fb45cf Mon Sep 17 00:00:00 2001 From: kjgbot Date: Thu, 17 Sep 2026 23:13:46 -0700 Subject: [PATCH 1/4] refresh(writing-relayflows): correct for @relayflows/surface@2.0.16 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill was verified against a pre-release source build (86a2ec2) and predates most of the v2 surface's current shape. Refreshed from the shipped 2.0.16 .d.ts files directly, plus real flows run/check evidence gathered while building AgentWorkforce/flows-cookbook: - f.done()'s closed set is 6 values, not 4 (needs_human/declined were missing; the human-decline example wrongly used done('canceled')). - Predicate .gate((v) => …) is refused at runtime (unsupported_gate) — reproduced on two real example flows. Only config-object gates (subprocess_gate, regex_match, word_count_bounds, references_input) actually run; the skill previously showed only the broken predicate form as the TypeScript gate API. - flows.json's real accepted keys are cli/executors/models/mcp/deploy — tools/budget/etc. there are config_invalid, not FlowHeader fields. - New: parallel agents via Promise.all (documented, first-class, with the process-wide Promise.all replacement disclosure), the transport: 'relay' dispatch option and what it actually is (task dispatch + durable receipt, not live agent-to-agent chat), the --data-dir gotcha when a flow's own git hygiene can delete the daemon's default data dir mid-run, flows deploy/deployments/undeploy/ schedule, and a currently-open flows run --cloud bug for TS flows (flows#461). - Helpers are a generated 40+ provider namespace, not just Slack; two unrelated things are both spelled "tools" depending on where you write it, documented explicitly since it's an easy first mistake. Verified: this file's own TypeScript and YAML examples pass flows check against @relayflows/surface@2.0.16 / @relayflows/sdk@2.0.16 for real. Co-Authored-By: Claude Sonnet 5 --- skills/writing-relayflows/SKILL.md | 365 +++++++++++++++++++---------- 1 file changed, 236 insertions(+), 129 deletions(-) diff --git a/skills/writing-relayflows/SKILL.md b/skills/writing-relayflows/SKILL.md index 55dba6d..a3bf96d 100644 --- a/skills/writing-relayflows/SKILL.md +++ b/skills/writing-relayflows/SKILL.md @@ -1,6 +1,6 @@ --- name: writing-relayflows -description: Use when authoring a Relayflows flow (@relayflows/surface / @relayflows/sdk, the journal-based v2 engine — the CLI is `flows`, package versions 2.0.x) in TypeScript or YAML/JSON. Covers the three-rung ladder (run/llm/agent), the resident verbs (human/dispatch/done), verification gates, TypeScript vs YAML authoring, per-step cli/model selection and its resolution order, flows.json, and `flows check`/`run`/`resume` with their real refusal shapes and exit codes. Not for the older, unrelated `@relayflows/core` WorkflowBuilder engine (`.pattern('dag')`/.agent()/.step() chains) that `writing-agent-relay-workflows` and `migrating-persona-to-relayflow` cover — that's a different product despite the similar name. +description: Use when authoring a Relayflows flow (@relayflows/surface / @relayflows/sdk, the journal-based v2 engine — the CLI is `flows`, package versions 2.0.x) in TypeScript or YAML/JSON. Covers the three-rung ladder (run/llm/agent), the resident verbs (human/dispatch/done), verification gates (including which ones actually run today), per-step cli/model selection, flows.json, parallel agents, the agent-relay dispatch transport, and `flows check`/`run`/`deploy`/`schedule` with their real refusal shapes and exit codes. Not for the older, unrelated `@relayflows/core` WorkflowBuilder engine (`.pattern('dag')`/.agent()/.step() chains) that `writing-agent-relay-workflows` and `migrating-persona-to-relayflow` cover — that's a different product despite the similar name. --- # Writing Relayflows @@ -15,19 +15,20 @@ Relayflows turns a coding-agent task into steps a journal can inspect, verify, a - 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`. -- Choosing between TypeScript and YAML for a given flow. +- 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 — never more (`packages/sdk/src/spec.ts`, `export type StepType = 'deterministic' | 'llm' | 'agent';`): +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 four resident verbs that aren't ladder rungs: `human` (durable approval), `dispatch` (hand off to a child flow), `done` (typed finish), and in YAML, `on`/triggers (event entry points — out of scope for this skill). +Plus resident verbs that aren't ladder rungs: `human` (durable approval), `dispatch` (hand off to a child flow), `done` (typed finish), the generated **helper** namespace (`f.slack`, `f.github`, `f.linear`, `f.notion`, `f.jira`, and 30+ others — see **Helpers**), and in YAML, `on`/triggers (event entry points — out of scope for this skill, see the [Cloud docs](https://agentrelay.com/docs/relayflows/cloud)). Most flows only need `run` and `llm`. Climb to `agent` once a step needs hands on a real workspace. @@ -40,7 +41,7 @@ Most flows only need `run` and `llm`. Climb to `agent` once a step needs hands o ```ts import { flow } from '@relayflows/surface'; -export default flow('hello', async (f) => { +export default flow('hello', { budget: { wallclock: '10m' } }, async (f) => { const greeting = await f.run('echo "Hello from Relayflows"'); console.log(greeting.trim()); @@ -72,11 +73,11 @@ steps: model: claude-sonnet-4-6 ``` -**Version note:** the published `@relayflows/surface@2.0.8` on npm predates `AgentOptions.cli`/`.model` (flows#310) — it does not define them, and using them against that installed version throws at authoring time. These examples require `AgentWorkforce/flows@86a2ec2` or a later/compatible published release; if your installed `@relayflows/surface` still reads `2.0.8` and doesn't export these fields, omit `cli`/`model` from `f.agent`/`f.llm` calls until it's updated. Both examples above were run for real against a source build of that commit (`flows check`, exit 0 both) — see **Verified against**. +**Version note.** This skill was refreshed against `@relayflows/surface@2.0.16` and `@relayflows/sdk@2.0.16` — the current published npm packages, not a source build. Both examples above pass `flows check` for real against those versions (see **Verified against**). ## The real `Ctx` contract (TypeScript) -`packages/surface/src/context.ts`, current as of `origin/main@86a2ec2`: +From the shipped `@relayflows/surface@2.0.16` `.d.ts` files (`dist/context.d.ts`, `dist/step.d.ts`, `dist/flow.d.ts`, `dist/completion.d.ts`): ```ts export interface AgentResult { @@ -89,82 +90,61 @@ export interface AgentOptions { workspace?: string; 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 Ctx { - run(command: string): Step; +export interface LlmOptions { + output: Record; // JSON Schema, checked before the journal accepts the result + cli?: string; + model?: string; +} + +export interface Ctx extends Helpers { // Helpers = f.slack, f.github, f.linear, f.notion, ... — see Helpers + readonly mcp: Readonly Step>>>>; + run(command: string, options?: { timeout?: string | number }): Step; llm(strings: TemplateStringsArray, ...values: unknown[]): Step; - llm(prompt: string, options: { output: Record; cli?: string; model?: string }): Step; + llm(prompt: string, options: LlmOptions): Step; agent(name: string, options: AgentOptions): Step; human(question: string, options: { to: string }): Promise; dispatch(flow: string, input: unknown): Promise; - done(reason: RunCompletionReason): void; + done(reason: FlowCompletionReason): void; cloud: CloudHelper; - slack: SlackHelper; + 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 `cli`, no named-agent map, no `recoveryMode`/`permissions`/`surfaces`/`budget`** on the TypeScript side. Those are YAML/JSON-only today (see **What this skill does NOT cover**). `FlowHeader` (`packages/surface/src/flow.ts:4-9`) only allows `identity`, `memory`, `budget`, `tools`, `workspace` — no `agents` key. Passing an unknown header field or an unknown `AgentOptions` field throws a `TypeError` at authoring time, before anything is journaled. - -`Step` (`packages/surface/src/step.ts`) is a `PromiseLike` with one extra method: `.gate(predicate, because?)`, which fails the step `verification_failed` when the predicate returns false. Every awaited step must actually be awaited — an unawaited or manually-`.then()`-chained step is refused (`unawaited_step` / `unsupported_verb`), not silently dropped. +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`/`permissions`/`surfaces`** on the TypeScript call site. Those are YAML/JSON step fields today. `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**). Passing an unknown header field or an unknown `AgentOptions` field throws a `TypeError` at authoring time, before anything is journaled. -`done(reason)` takes exactly one of the closed run-completion set (`packages/surface/src/completion.ts`): `'success' | 'step_failed' | 'canceled' | 'budget_exceeded'`. There's no fifth option — don't invent one (e.g. `'partial'`, `'skipped'`). +`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. -## The real step shapes (YAML/JSON, `packages/sdk/src/spec.ts`) +### `f.done()`'s closed set is six values, not four ```ts -interface DeterministicStepSpec { - type: 'deterministic'; - id: string; - command: string; - dependsOn?: string[]; - timeoutMs?: number; - verification?: VerificationSpec; // omit for implicit exit_code -} +export const FLOW_COMPLETION_REASONS = ['success', 'step_failed', 'canceled', 'budget_exceeded', 'needs_human', 'declined'] as const; +``` -interface LlmStepSpec { - type: 'llm'; - id: string; - prompt: string; - dependsOn?: string[]; - verification?: OutputVerificationSpec; - model?: string; - cli?: string; -} +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. If a human answers "no" to `f.human(...)`, the correct verdict is `f.done('declined')`, not `f.done('canceled')` — a human declining is a decision, not the kernel calling off the run. -interface AgentStepSpec { - type: 'agent'; - id: string; - instruction: string; - dependsOn?: string[]; - verification?: OutputVerificationSpec; - agent?: string; // selects a named FlowSpec.agents entry - cli?: string; - model?: string; - surfaces?: { workspace?: { surface: string }[]; streams?: { stream: string }[]; external?: string[] }; - recoveryMode?: 'reset' | 'inspect' | 'manual'; // default 'reset' - permissions?: { fileGlobs?: string[]; networkAllowlist?: string[]; accessPreset?: 'readonly' | 'readwrite' }; -} +`Step` (`dist/step.d.ts`) is a `PromiseLike` with one extra method, `.gate(...)` — see **Verification gates**, which has a critical caveat for the predicate form. 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. -interface FlowSpec { - version: string; // required, e.g. '0.1.0' — not optional - name?: string; - cli?: string; // flow-level CLI default - agents?: Record; // both fields required - steps: StepSpec[]; - budget?: { maxTokensIn?: number; maxTokensOut?: number; maxDollars?: string }; -} -``` +## Verification gates -`version` is required on every `FlowSpec` — omitting it is a real, common mistake; `flows check` refuses it. +Verification is control flow, not decoration — a gate decides whether a step actually completed, not just whether the process exited cleanly. -## Verification gates +### YAML/JSON (`@relayflows/sdk`'s `VerificationSpec`) -Verification is control flow, not decoration — a gate decides whether a step actually completed, not just whether the process exited cleanly (`packages/sdk/src/spec.ts`, `VerificationGateType`): +```ts +export type VerificationSpec = ExitCodeGate | OutputContainsGate | JsonSchemaGate | NamedDataGate; +// NamedDataGate = ReferencesInputGate | SubprocessGate | WordCountBoundsGate | RegexMatchGate +``` -- `exit_code` — implicit default for `deterministic` steps. `exit_code == 0`. Not configurable in v0. -- `output_contains` — step output (stdout tail or LLM value, stringified) contains `value`. -- `json_schema` — step output validates against a JSON Schema (`boolean | Record`). Used for structured LLM/agent output. +- `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`). Used for structured LLM/agent output; `output?: JsonOutputSchema` on an `llm`/`agent` step is sugar that compiles to this. +- `subprocess_gate` — runs `command`, judged on its exit code, seeing the step's output via `FLOWS_INPUT`/`from_output`. The gate that actually works for "check a file the agent wrote," e.g. `{ type: 'subprocess_gate', command: 'test -s review.md' }`. +- `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). +- `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 @@ -177,46 +157,168 @@ Verification is control flow, not decoration — a gate decides whether a step a value: bug ``` -Verified for real: `flows check` on exactly this step passed (`CHECK PASSED`, exit 0) — see **Verified against**. +### TypeScript (`Step.gate(...)`) — read this before you write one + +`Step` has **two** overloads, and only one of them works today: -An agent step rarely fails by crashing; it fails by returning something plausible and wrong, which a plain retry-on-error never catches. Always give an `agent`/`llm` step a real `verification`, not just the default. +```ts +gate(config: NamedGate): Step; +// NamedGate = ReferencesInputNamedGate | SubprocessNamedGate | WordCountBoundsNamedGate | RegexMatchNamedGate +// — the surface-visible subset of NamedDataGate above. This one is journal-honest and runs. + +gate(predicate: (value: T) => boolean, because?: string): Step; +// The type itself documents why this is dangerous — straight from the shipped .d.ts: +// "Predicate gates cannot be journaled — the JavaScript closure would not survive +// replay — so the executor refuses this branch with unsupported_gate." +``` + +**Verified for real, this session, against `flows@main` (post-v2.0.16, current as of 2026-09-17):** any flow using the predicate form — `.gate((out) => out.trim().length > 0, "some reason")` — fails at `flows run` time with: + +``` +FAILED [protocol_error] relayflowd could not complete the run request: unsupported_gate: +postfix .gate(predicate) closures cannot be journaled; use .gate({type: "…", …}) with a +slice-P named gate instead. +``` + +This reproduced identically on two different real example flows (`examples/pr-review-pipeline`, `examples/dependency-upgrade-bot` in `AgentWorkforce/flows`) that both used the predicate form — neither got past their first gate. **Always use the config-object form**: + +```ts +const outdated = await f.run('npm outdated --json 2>/dev/null || true') + .gate({ type: 'subprocess_gate', command: 'test -s <(cat)' }); // not .gate((out) => out.length > 0, '…') + +const review = await f.agent('review', { task: '…', cli: 'claude' }) + .gate({ type: 'subprocess_gate', command: 'test -s review.md' }); +``` + +[flows#449](https://github.com/AgentWorkforce/flows/pull/449) (open at time of writing) adds journaled `artifact_exists` gates and real predicate-gate support (the executor runs the closure once, on the journaled value, and journals the verdict) — check whether it's merged before assuming predicate gates still don't work. Until it lands, `subprocess_gate`/`regex_match`/`word_count_bounds`/`references_input` are the only gates that actually run in TypeScript. + +An agent step rarely fails by crashing; it fails by returning something plausible and wrong, which a plain retry-on-error never catches. Always give an `agent`/`llm` step a real, config-object gate — never the default, never a predicate. + +## 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> & { 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 (TypeScript since flows#310, `AgentOptions.cli?`/`.model?`). Resolution order for `cli` — checked once per step by `preflight.ts`'s `resolveCli` (`packages/sdk/src/preflight.ts:265-282`), identical regardless of authoring language because both compile to the same `StepSpec`: +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: ` (YAML/JSON only — TypeScript has no named-agent map yet, see below) -3. **flow** — `FlowSpec.cli` +2. **named** — the flow's `agents[step.agent]` entry, if the step selects one via `agent: ` (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. Verified for real: +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. ``` -(exit 2 — see **Verified against**) `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 (`packages/sdk/src/cli/check.ts`). Exact schema — unknown keys fail closed as `config_invalid`: +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: -```json -{ "cli": "claude", "executors": ["cron"], "models": ["claude-sonnet-4-6"] } +```ts +interface FlowsJson { + cli?: string; + executors?: string[]; + models?: string[]; + mcp?: Record; + deploy?: { bucket: string }; +} ``` -- `cli` (optional, non-empty string) — the project-wide CLI default (resolution rung 4 above). -- `executors` (optional, string array) — trigger executors this project has registered. A trigger whose `executor` isn't in this list is `no_executor`. -- `models` (optional, string array) — **an allowlist, not a default.** Any model declared on any step or named agent anywhere in the flow must appear here, or `flows check` refuses `model_unknown`. Setting `models` does not select a model for anything; there's still no flow/project model default. +- `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 @sha256: --to `. +**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: '…' ``` -$ flows check hello.flow.yaml # model set on the step, but not in flows.json's models[] -REFUSED [model_unknown] Step "greeter" declares model "claude-sonnet-4-6" for CLI "claude", but it is not listed in project model registry "..."; add the exact model only after verifying that project is allowed to use it. + +**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, which the map doesn't give you, is `FlowHeader.use?: string[]` — "relative paths to reusable authored flows composed by this body" ([flows#300](https://github.com/AgentWorkforce/flows/issues/300), closed). This is a real field in the shipped `2.0.16` type; this skill hasn't independently run a flow that exercises it, so treat its exact runtime semantics as a pointer to verify against `docs/SURFACE.md` and the flows repo, not as tested guidance the way the rest of this document is. + +## 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.', +}).gate({ type: 'subprocess_gate', command: 'test -s review/consensus.json' }); ``` -(Verified for real — see **Verified against**.) + +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 dispatch (TypeScript resident verbs) @@ -226,97 +328,102 @@ import { flow } from '@relayflows/surface'; export default flow('ship-feature', async (f) => { const plan = await f.agent('planner', { task: 'Research and plan: add OAuth2 support', - workspace: 'acme/api: readonly', // compiles to relayauth path scopes + workspace: 'acme/api: readonly', }); const ok = await f.human(`Ship this?\n${plan.summary}`, { to: 'khaliq' }); - if (!ok) return f.done('canceled'); + if (!ok) return f.done('declined'); // a decision not to act, not a kernel cancellation - const pr = await f.dispatch('garden/implement', plan); // hands off to a child flow + const pr = await f.dispatch('garden/implement', plan); f.done('success'); }); ``` -`f.human` is a durable, journaled approval gate — the run parks until the human answers, and resumes exactly where it left off. `f.dispatch` hands input to a named child flow and returns its typed result; the parent doesn't inline the child's steps. (Source: `docs/SURFACE.md` §2 rule 6 region, lines ~40-53 as of `origin/main@86a2ec2` — this snippet is cited, not independently re-run, since it needs a live daemon.) - -`f.slack` is a separate helper namespace, and its real calling convention doesn't fit a plain `flow(name, async (f) => ...)` body: in the real source, `f.slack.reply(event, ...)` only appears inside a trigger handler registered via `.on(slack.mention('#exec'), async (f, event) => { ... })`, where `event` is the trigger's second callback argument — not something a step-based flow like the one above ever has in scope. Triggers and `f.slack` are out of this skill's scope (see **What this skill does NOT cover**); don't copy a bare `f.slack.reply(event, ...)` call into a `flow()` body like the one above, it will throw `event is not defined`. +`f.human` is a durable, journaled approval gate — the run parks (`needs_human` is the run-level signal a caller sees while waiting) until the human answers, and resumes exactly where it left off; nothing sits there blocking a thread, and the wait survives a restart exactly like a crash mid-step does. `f.dispatch` hands input to a named child flow and returns its typed result; the parent doesn't inline the child's steps. -## Running it: `flows check` / `run` / `resume` - -Real usage (`packages/sdk/src/cli.ts`): +## Running it: `flows check` / `run` / `deploy` / `schedule` ``` -flows check [--json] +flows check [--json] [--watch] flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir ] [--local-agent] flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir ] [--local-agent] --input -flows resume [--json] [--no-spawn] [--no-observer-link] [--data-dir ] +flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--data-dir ] [--local-agent] +flows deploy --repo --on [:key=value,...] [--on ...] --approver [--agents claude[,codex]] [--name ] [--draft] [--json] +flows deployments [--json] +flows undeploy [--json] +flows schedule --cron "" --tz --input ``` -`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. `--json` works on `check`/`run`/`resume`; it does **not** exist on `tick start`, `hn-monitor start`, or `observer`. The printed usage above only lists `` for `check`, but it accepts `.flow.ts` too — verified in this skill's own **Verified against** section (`flows check hello.flow.ts` passes); the tool's own `--help` text is just incomplete on this point. +`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 ` even when the flow body ignores its input argument. -A `.flow.ts` run via `flows run` requires `--input ` even when the flow body ignores its input argument — `flows run hello.flow.ts` alone refuses `REFUSED [input_missing]`. +**`--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 '{...}' +``` + +**`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 ` 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. + +**Known current bug, filed and open at time of writing** ([flows#461](https://github.com/AgentWorkforce/flows/issues/461)): the one-shot `flows run --cloud [--sync-code] --input ` form is broken for authored TypeScript flows — it either misroutes into the declarative-spec loader (`invalid_input: Cannot read or compile the declarative flow`) or refuses with a bare `http_error: HTTP 400`, regardless of flag order or a real git remote. `flows deploy` (the persistent-listener form) works correctly. If you need one-shot cloud execution of a TS flow today, expect this to fail and check whether #461 has landed before spending time on flag combinations. ### Exit codes and refusal shapes -Every refusal before a journal write is exit **2**, printed as `REFUSED [] `. The `` differs by *how* the flow was checked, not just *what* was wrong — don't assume one canonical string for "no CLI": +Every refusal before a journal write is exit **2**, printed as `REFUSED [] `. The `` 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 → `preflight.ts`'s own kind directly: `cli_unresolved`, `model_unknown`, `cli_missing`, `cli_unauthenticated`, `no_executor`, etc. (`packages/sdk/src/failure-kinds.ts`, `PREFLIGHT_FAILURE_KINDS`). -- `flows run` on a **TypeScript** `.flow.ts` whose authored `f.agent`/`f.llm` call can't resolve a CLI at runtime → the executor's internal `agent_cli_unresolved` gets wrapped and printed as `REFUSED [invalid_spec] ` (`packages/sdk/src/cli/direct-run.ts:97-119`). The internal `agent_cli_unresolved` string is never itself the printed kind a user sees. +- `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] ` 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 — just from different code paths, so don't be surprised if the same underlying problem prints a different `[kind]` depending on whether you hit it via `flows check` on YAML or `flows run` on TypeScript. +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`.** It's required, not optional — `flows check` refuses a spec without it. -- **Adding `agents:` to a TypeScript `flow()` header.** `FlowHeader` has no such field; it throws `TypeError: flow header has unknown fields: agents` at authoring time. Named-agent maps + `agent:` selector are YAML/JSON-only (flows#300 tracks TypeScript composition via `use:`, not yet shipped). +- **Forgetting `version` in a YAML/JSON `FlowSpec`.** Required, not optional. +- **Using a predicate `.gate((v) => …, reason)` in TypeScript.** Refused at runtime (`unsupported_gate`) until [flows#449](https://github.com/AgentWorkforce/flows/pull/449) lands. Use a config-object gate — see **Verification gates**. +- **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. -- **Not awaiting a step, or manually `.then()`-chaining one.** Both are refused (`unawaited_step` / `unsupported_verb`) rather than silently ignored — the executor closes every root operation's lifecycle explicitly. +- **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. -- **Expecting a fifth `done()` reason.** The set is closed: `success | step_failed | canceled | budget_exceeded`. Don't invent `partial` or `skipped`. +- **Trying `flows run --cloud` for a one-shot TypeScript flow run.** Broken today — see [flows#461](https://github.com/AgentWorkforce/flows/issues/461). Use `flows deploy` for a persistent listener instead. ## What this skill does NOT cover -- **Named-agent maps in TypeScript** (`agents: { reviewer: { cli, model } }` + reuse across steps by name) — YAML/JSON only today. Tracked for TS composition via `use:` at [flows#300](https://github.com/AgentWorkforce/flows/issues/300). -- **`recoveryMode`, `permissions`, `surfaces`, `budget`, `memory`** on agent steps — real YAML/JSON fields with no TypeScript equivalent. Author that step in YAML and reach it from TypeScript with `f.dispatch` if you need them. -- **Cloud execution** (`flows run --cloud`), **triggers/webhooks**, **memory retrieval**, and the **`f.mcp`**/**`f.slack`** helper namespaces — each is its own surface with its own gotchas; see the [Relayflows product docs](https://agentrelay.com/docs/relayflows) for what's shipped versus designed-but-not-yet-implemented. +- **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://agentrelay.com/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:`** — `FlowHeader.use` exists in the shipped type but this skill hasn't independently run a flow that exercises it; verify current semantics against `docs/SURFACE.md` before relying on this section alone. +- **YAML-only agent step fields with no TypeScript equivalent**: `recoveryMode`, `permissions`, `surfaces`. Author that step in YAML and reach it from TypeScript with `f.dispatch` if you need them. - The **older `@relayflows/core` `WorkflowBuilder`** engine — see `writing-agent-relay-workflows` and `migrating-persona-to-relayflow` in this repo. ## Quick reference | Verb / field | Language | Notes | |---|---|---| -| `f.run(command)` / `type: deterministic` | both | shell command, implicit `exit_code` gate | +| `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 | durable approval; YAML has no equivalent yet | +| `f.human(question, {to})` | TS only | durable approval; declined → `f.done('declined')`, not `'canceled'` | | `f.dispatch(flow, input)` | TS only | hand off to a named child flow | -| `f.done(reason)` / — | TS / kernel | one of `success \| step_failed \| canceled \| budget_exceeded` | -| `options.cli` / `step.cli` | both | per-call/step CLI override (TS: flows#310) | -| `options.model` / `step.model` | both | per-call/step model; no flow/project default | -| `agent: ` + `agents: {...}` | YAML/JSON only | named cli/model pair, reused by selector | +| `f.done(reason)` | TS / kernel | one of `success \| step_failed \| needs_human \| declined` from an authored body; `canceled \| budget_exceeded` are kernel-only | +| `.gate({type: '...', ...})` | TS | the only `.gate()` form that runs today — config object, never a predicate | +| `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 ` | CLI | pure validate + preflight, no daemon | -| `flows run [--input ...]` | CLI | actually executes; `.flow.ts` needs `--input` | -| `flows resume ` | CLI | resume a parked/crashed run | +| `flows run --local-agent [--input ...]` | CLI | actually executes; `.flow.ts` needs `--input`, agent/llm steps need `--local-agent` | +| `flows deploy --repo ... --on ...` | CLI | persistent trigger-based listener; works today | +| `flows run --cloud --input ...` | CLI | broken for TS today, [flows#461](https://github.com/AgentWorkforce/flows/issues/461) | +| `flows schedule --cron ...` | CLI | cron-based cloud run | ## Verified against -`AgentWorkforce/flows@86a2ec2` (origin/main). Built `packages/surface` and `packages/sdk` from source in a clean worktree (published npm `@relayflows/surface@2.0.8` is stale — it predates flows#310 and lacks `cli`/`model` on `AgentOptions`; local build was symlinked in instead), then ran the real CLI: - -``` -$ flows check hello.flow.yaml # this skill's YAML example, cli/model added, flows.json models allowlist set -CHECK PASSED hello.flow.yaml # exit 0 - -$ flows check hello.flow.ts # this skill's TypeScript example -CHECK PASSED hello.flow.ts # exit 0 - -$ flows check extract.flow.yaml # this skill's output_contains example -CHECK PASSED extract.flow.yaml # exit 0 - -$ flows check hello.flow.yaml # same YAML, no flows.json anywhere -REFUSED [cli_unresolved] Step "greeter" has no CLI at step, flow, or project level. ... # exit 2 - -$ flows check hello.flow.yaml # step model not in flows.json's models[] -REFUSED [model_unknown] Step "greeter" declares model "claude-sonnet-4-6" ... not listed in project model registry ... # exit 2 -``` +`@relayflows/surface@2.0.16` and `@relayflows/sdk@2.0.16` (the published npm packages) — types read directly from the shipped `.d.ts` files, not from documentation or memory. Beyond static type-checking, the claims in this refresh that carry a **real run**, not just `flows check`, are backed by the recipes in [`AgentWorkforce/flows-cookbook`](https://github.com/AgentWorkforce/flows-cookbook) — each recipe's own README states exactly what was run, when, and what the result was (a real local run, a real Cloud deploy, or both), rather than duplicating that evidence here where it will go stale. If a claim in this skill and a cookbook recipe's README disagree, trust whichever was verified more recently — check the README's date. -The `f.human`/`f.dispatch`/`f.slack` snippet and the TypeScript `REFUSED [invalid_spec]` wrapping claim are source-cited (exact file:line above), not independently re-run — both need a live `relayflowd` daemon (and Slack mount, for the former), which this pass didn't build. +The predicate-gate failure, the `flows.json` schema refusal, the `--data-dir` git-hygiene interaction, and the `flows run --cloud` bug were all reproduced directly while building that cookbook, on `AgentWorkforce/flows@main` past the `v2.0.16` tag (2026-09-17). From b8a383b391d2f7d25142636f4096940138716b63 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 18 Sep 2026 04:34:32 -0700 Subject: [PATCH 2/4] fix: correct f.human/f.dispatch, workspace, and subprocess_gate claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devin's review on this PR flagged three issues; verified each directly against the real CLI rather than trusting or dismissing the report: - f.human and f.dispatch both typecheck and pass `flows check`, but the shipped 2.0.16 executor throws `unsupported_verb` for both at `flows run` time. Confirmed. Rewrote the "Human approval and dispatch" section to state this plainly instead of presenting a broken example as working guidance. - workspace: 'acme/api: readonly' is refused (unsupported_workspace_permission) under --local-agent. Confirmed — but the review's suggested fix (drop the `: readonly` suffix) does NOT work either: a bare workspace value fails identically, because the local-agent worker holds no revision pins at all, not because of the permission annotation syntax. Corrected to say omit `workspace` entirely under --local-agent. - subprocess_gate commands see the extracted value as $INPUT, never $FLOWS_INPUT (that's the SDK's own internal wrapper's env var, never exposed to the author's command) — confirmed by reading named-gate-lowering.js directly. Fixed the claim and the broken `test -s <(cat)` placeholder example with a real, run-verified one. Co-Authored-By: Claude Sonnet 5 --- skills/writing-relayflows/SKILL.md | 59 +++++++++++++++++++----------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/skills/writing-relayflows/SKILL.md b/skills/writing-relayflows/SKILL.md index a3bf96d..2f85da3 100644 --- a/skills/writing-relayflows/SKILL.md +++ b/skills/writing-relayflows/SKILL.md @@ -28,7 +28,7 @@ Three step verbs, one per rung (`@relayflows/sdk`'s `StepType = 'deterministic' 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` (hand off to a child flow), `done` (typed finish), the generated **helper** namespace (`f.slack`, `f.github`, `f.linear`, `f.notion`, `f.jira`, and 30+ others — see **Helpers**), and in YAML, `on`/triggers (event entry points — out of scope for this skill, see the [Cloud docs](https://agentrelay.com/docs/relayflows/cloud)). +Plus resident verbs that aren't ladder rungs: `human` (durable approval) and `dispatch` (hand off to a child flow) — both declared in the type, **neither executable yet**, see **Human approval and dispatch** — `done` (typed finish, works), the generated **helper** namespace (`f.slack`, `f.github`, `f.linear`, `f.notion`, `f.jira`, and 30+ others — see **Helpers**), and in YAML, `on`/triggers (event entry points — out of scope for this skill, see the [Cloud docs](https://agentrelay.com/docs/relayflows/cloud)). Most flows only need `run` and `llm`. Climb to `agent` once a step needs hands on a real workspace. @@ -124,7 +124,7 @@ Do not add fields to `AgentOptions`/`Ctx` that aren't in this list — they don' 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. If a human answers "no" to `f.human(...)`, the correct verdict is `f.done('declined')`, not `f.done('canceled')` — a human declining is a decision, not the kernel calling off the run. +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. (Correct verdict *if* the approval call reaches that point — `f.human` itself doesn't execute yet; see **Human approval and dispatch**.) `Step` (`dist/step.d.ts`) is a `PromiseLike` with one extra method, `.gate(...)` — see **Verification gates**, which has a critical caveat for the predicate form. 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. @@ -142,7 +142,7 @@ export type VerificationSpec = ExitCodeGate | OutputContainsGate | JsonSchemaGat - `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`). Used for structured LLM/agent output; `output?: JsonOutputSchema` on an `llm`/`agent` step is sugar that compiles to this. -- `subprocess_gate` — runs `command`, judged on its exit code, seeing the step's output via `FLOWS_INPUT`/`from_output`. The gate that actually works for "check a file the agent wrote," e.g. `{ type: 'subprocess_gate', command: 'test -s review.md' }`. +- `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). - `word_count_bounds`, `references_input` — narrower named gates; see `packages/sdk/src/named-gates.ts` in the flows repo for the exact contract. @@ -184,12 +184,14 @@ This reproduced identically on two different real example flows (`examples/pr-re ```ts const outdated = await f.run('npm outdated --json 2>/dev/null || true') - .gate({ type: 'subprocess_gate', command: 'test -s <(cat)' }); // not .gate((out) => out.length > 0, '…') + .gate({ type: 'subprocess_gate', command: 'test -n "$INPUT"' }); // not .gate((out) => out.length > 0, '…') const review = await f.agent('review', { task: '…', cli: 'claude' }) - .gate({ type: 'subprocess_gate', command: 'test -s review.md' }); + .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.) + [flows#449](https://github.com/AgentWorkforce/flows/pull/449) (open at time of writing) adds journaled `artifact_exists` gates and real predicate-gate support (the executor runs the closure once, on the journaled value, and journals the verdict) — check whether it's merged before assuming predicate gates still don't work. Until it lands, `subprocess_gate`/`regex_match`/`word_count_bounds`/`references_input` are the only gates that actually run in TypeScript. An agent step rarely fails by crashing; it fails by returning something plausible and wrong, which a plain retry-on-error never catches. Always give an `agent`/`llm` step a real, config-object gate — never the default, never a predicate. @@ -320,26 +322,39 @@ What this actually is, per `docs/AGENT-RELAY-TRANSPORT.md`: the step calls `POST 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 dispatch (TypeScript resident verbs) +## Human approval and dispatch (declared, not yet runnable) + +`Ctx` declares `human(question, {to})` and `dispatch(flow, input)`, and both typecheck and pass `flows check`. **Neither actually runs today.** Verified for real against `@relayflows/surface@2.0.16`: + +``` +$ flows run human.flow.ts --local-agent --input '{}' +FAILED [protocol_error] relayflowd could not complete the run request: +unsupported_verb: the initial authored executor does not lower f.human + +$ flows run dispatch.flow.ts --local-agent --input '{}' +FAILED [protocol_error] relayflowd could not complete the run request: +unsupported_verb: the initial authored executor does not lower f.dispatch +``` + +Type availability does not establish executable support for either verb — the shipped executor throws `unsupported_verb` for both, identically, regardless of what the rest of the flow does. [flows#400](https://github.com/AgentWorkforce/flows/issues/400) tracks wiring up durable human approval; there's no dedicated tracking issue found yet for `f.dispatch`. Until one of these lands, treat both as authoring-surface-only: ```ts -import { flow } from '@relayflows/surface'; +// This typechecks and passes `flows check` — it will FAIL at `flows run` time. +// Do not ship a flow whose only path to done() runs through f.human or f.dispatch. +const ok = await f.human(`Ship this?\n${plan.summary}`, { to: 'khaliq' }); +if (!ok) return f.done('declined'); // correct verdict, if this call ever executes +``` -export default flow('ship-feature', async (f) => { - const plan = await f.agent('planner', { - task: 'Research and plan: add OAuth2 support', - workspace: 'acme/api: readonly', - }); +If you need durable approval today, do it in YAML with `permissions`/`recoveryMode: manual` (parks the run as `needs_human` with a diff for a person to resolve), or split the decision out to a separate, human-triggered flow run rather than a mid-flow `f.human` call. - const ok = await f.human(`Ship this?\n${plan.summary}`, { to: 'khaliq' }); - if (!ok) return f.done('declined'); // a decision not to act, not a kernel cancellation +**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: - const pr = await f.dispatch('garden/implement', plan); - f.done('success'); -}); +``` +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. ``` -`f.human` is a durable, journaled approval gate — the run parks (`needs_human` is the run-level signal a caller sees while waiting) until the human answers, and resumes exactly where it left off; nothing sits there blocking a thread, and the wait survives a restart exactly like a crash mid-step does. `f.dispatch` hands input to a named child flow and returns its typed result; the parent doesn't inline the child's steps. +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). ## Running it: `flows check` / `run` / `deploy` / `schedule` @@ -399,7 +414,7 @@ Both are real, both are exit 2 — the same underlying problem can print a diffe - **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://agentrelay.com/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:`** — `FlowHeader.use` exists in the shipped type but this skill hasn't independently run a flow that exercises it; verify current semantics against `docs/SURFACE.md` before relying on this section alone. -- **YAML-only agent step fields with no TypeScript equivalent**: `recoveryMode`, `permissions`, `surfaces`. Author that step in YAML and reach it from TypeScript with `f.dispatch` if you need them. +- **YAML-only agent step fields with no TypeScript equivalent**: `recoveryMode`, `permissions`, `surfaces`, and enforced `workspace` (TypeScript's `workspace` string is accepted by the type but rejected by the local-agent worker — see **Human approval and dispatch**). Author that step in YAML if you need them; `f.dispatch` is not a working escape hatch for this today (see below). - The **older `@relayflows/core` `WorkflowBuilder`** engine — see `writing-agent-relay-workflows` and `migrating-persona-to-relayflow` in this repo. ## Quick reference @@ -409,8 +424,8 @@ Both are real, both are exit 2 — the same underlying problem can print a diffe | `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 | durable approval; declined → `f.done('declined')`, not `'canceled'` | -| `f.dispatch(flow, input)` | TS only | hand off to a named child flow | +| `f.human(question, {to})` | TS only | **not executable yet** — `unsupported_verb`; typechecks, fails at `flows run`. [flows#400](https://github.com/AgentWorkforce/flows/issues/400) | +| `f.dispatch(flow, input)` | TS only | **not executable yet** — `unsupported_verb`; typechecks, fails at `flows run` | | `f.done(reason)` | TS / kernel | one of `success \| step_failed \| needs_human \| declined` from an authored body; `canceled \| budget_exceeded` are kernel-only | | `.gate({type: '...', ...})` | TS | the only `.gate()` form that runs today — config object, never a predicate | | `options.transport: 'relay'` | TS/YAML `agent` step | dispatch to Agent Relay's task infra; request+receipt, not live chat | @@ -426,4 +441,4 @@ Both are real, both are exit 2 — the same underlying problem can print a diffe `@relayflows/surface@2.0.16` and `@relayflows/sdk@2.0.16` (the published npm packages) — types read directly from the shipped `.d.ts` files, not from documentation or memory. Beyond static type-checking, the claims in this refresh that carry a **real run**, not just `flows check`, are backed by the recipes in [`AgentWorkforce/flows-cookbook`](https://github.com/AgentWorkforce/flows-cookbook) — each recipe's own README states exactly what was run, when, and what the result was (a real local run, a real Cloud deploy, or both), rather than duplicating that evidence here where it will go stale. If a claim in this skill and a cookbook recipe's README disagree, trust whichever was verified more recently — check the README's date. -The predicate-gate failure, the `flows.json` schema refusal, the `--data-dir` git-hygiene interaction, and the `flows run --cloud` bug were all reproduced directly while building that cookbook, on `AgentWorkforce/flows@main` past the `v2.0.16` tag (2026-09-17). +The predicate-gate failure, the `flows.json` schema refusal, the `--data-dir` git-hygiene interaction, and the `flows run --cloud` bug were all reproduced directly while building that cookbook, on `AgentWorkforce/flows@main` past the `v2.0.16` tag (2026-09-17). A subsequent review pass (Devin, on this PR) flagged three more claims worth checking rather than trusting on sight — `f.human`/`f.dispatch` executability, the `workspace: readonly` annotation, and the `subprocess_gate` env var name — all three were independently re-run against the real CLI, confirmed as real breaks (one of the review's own suggested fixes, for `workspace`, turned out not to work either — the bare form fails identically to the annotated one), and corrected in the sections above rather than taken on the reviewer's word alone. From d4c786d3aee5061aa0bfdead31f3001d9657c3bb Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 18 Sep 2026 05:02:07 -0700 Subject: [PATCH 3/4] docs: add deploy prerequisites (login, GitHub App, connected integrations) Answers a real gap: the skill documented flows deploy's syntax but not what has to already be true for it to succeed. An agent following just the syntax would hit flow_repository_not_connected with no lead on what to fix. Added: agent-relay cloud login is required first (shared with the whole Cloud session, no separate flows login); the workspace needs a GitHub App installation covering --repo and the --on provider must be a connected integration, both checked before flows deploy activates a listener (prepare-flow-deploy.ts in AgentWorkforce/cloud); and this is a different concern from a local git/gh push credential, which matters only for testing a flow's own git steps locally. Co-Authored-By: Claude Sonnet 5 --- skills/writing-relayflows/SKILL.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/skills/writing-relayflows/SKILL.md b/skills/writing-relayflows/SKILL.md index 2f85da3..1f7fc30 100644 --- a/skills/writing-relayflows/SKILL.md +++ b/skills/writing-relayflows/SKILL.md @@ -381,6 +381,11 @@ A `.flow.ts` run via `flows run` requires `--input ` even w 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 ` — 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 ` 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. @@ -433,7 +438,7 @@ Both are real, both are exit 2 — the same underlying problem can print a diffe | `flows.json`: `cli, executors, models, mcp, deploy` | project config | exact accepted key set — nothing else | | `flows check ` | CLI | pure validate + preflight, no daemon | | `flows run --local-agent [--input ...]` | CLI | actually executes; `.flow.ts` needs `--input`, agent/llm steps need `--local-agent` | -| `flows deploy --repo ... --on ...` | CLI | persistent trigger-based listener; works today | +| `flows deploy --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 --input ...` | CLI | broken for TS today, [flows#461](https://github.com/AgentWorkforce/flows/issues/461) | | `flows schedule --cron ...` | CLI | cron-based cloud run | From 1a742e8410c8fc5e20ae24ade38210b5eae01e70 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 18 Sep 2026 06:19:06 -0700 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20flows#461=20is=20closed,=20not=20ope?= =?UTF-8?q?n=20=E2=80=94=20correct=20before=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught right before merging: flows#461 (the flows run --cloud bug for authored TS flows) was closed today by flows#462, a real CLI fix. Re-verified directly against relayflows@2.0.17 rather than trust the issue tracker alone: flows run --cloud --wait --input now submits successfully and returns a real run id, where 2.0.16 gave an immediate HTTP 400 or misrouted into the declarative loader. Root cause was a Surface version mismatch between Cloud and the CLI, badly reported; the CLI side is fixed, so update rather than work around it. Co-Authored-By: Claude Sonnet 5 --- skills/writing-relayflows/SKILL.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/skills/writing-relayflows/SKILL.md b/skills/writing-relayflows/SKILL.md index 1f7fc30..3df7474 100644 --- a/skills/writing-relayflows/SKILL.md +++ b/skills/writing-relayflows/SKILL.md @@ -73,7 +73,7 @@ steps: model: claude-sonnet-4-6 ``` -**Version note.** This skill was refreshed against `@relayflows/surface@2.0.16` and `@relayflows/sdk@2.0.16` — the current published npm packages, not a source build. Both examples above pass `flows check` for real against those versions (see **Verified against**). +**Version note.** This skill was refreshed against `@relayflows/surface@2.0.16` and `@relayflows/sdk@2.0.16` — the current published npm packages, not a source build. Both examples above pass `flows check` for real against those versions (see **Verified against**). `2.0.17` shipped since, fixing the `flows run --cloud` bug documented below (**Running it**) — that one claim is re-verified against `2.0.17`; everything else in this document was checked against `2.0.16` and hasn't been independently re-run on `2.0.17`, though nothing in its release notes suggests the other claims changed. ## The real `Ctx` contract (TypeScript) @@ -390,7 +390,7 @@ flows run pr-reviewer.flow.ts --local-agent --data-dir /somewhere/outside/the/re **`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. -**Known current bug, filed and open at time of writing** ([flows#461](https://github.com/AgentWorkforce/flows/issues/461)): the one-shot `flows run --cloud [--sync-code] --input ` form is broken for authored TypeScript flows — it either misroutes into the declarative-spec loader (`invalid_input: Cannot read or compile the declarative flow`) or refuses with a bare `http_error: HTTP 400`, regardless of flag order or a real git remote. `flows deploy` (the persistent-listener form) works correctly. If you need one-shot cloud execution of a TS flow today, expect this to fail and check whether #461 has landed before spending time on flag combinations. +**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] --input ` 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 --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.) ### Exit codes and refusal shapes @@ -413,7 +413,7 @@ Both are real, both are exit 2 — the same underlying problem can print a diffe - **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. -- **Trying `flows run --cloud` for a one-shot TypeScript flow run.** Broken today — see [flows#461](https://github.com/AgentWorkforce/flows/issues/461). Use `flows deploy` for a persistent listener instead. +- **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 @@ -439,7 +439,7 @@ Both are real, both are exit 2 — the same underlying problem can print a diffe | `flows check ` | CLI | pure validate + preflight, no daemon | | `flows run --local-agent [--input ...]` | CLI | actually executes; `.flow.ts` needs `--input`, agent/llm steps need `--local-agent` | | `flows deploy --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 --input ...` | CLI | broken for TS today, [flows#461](https://github.com/AgentWorkforce/flows/issues/461) | +| `flows run --cloud --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 schedule --cron ...` | CLI | cron-based cloud run | ## Verified against