diff --git a/web/app/docs/relayflows/[slug]/og.png/route.tsx b/web/app/docs/relayflows/[slug]/og.png/route.tsx new file mode 100644 index 00000000..acdb29d8 --- /dev/null +++ b/web/app/docs/relayflows/[slug]/og.png/route.tsx @@ -0,0 +1,42 @@ +import { ImageResponse } from 'next/og'; +import { notFound } from 'next/navigation'; + +import { DefaultVariant, loadBrandFonts, OG_SIZE } from '../../../../../lib/og/template'; +import { getProductDoc, getProductDocSlugs } from '../../../../../lib/product-docs'; +import { relayflowsSection } from '../../../../../lib/product-docs-nav'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-static'; + +type RouteContext = { + params: Promise<{ slug: string }>; +}; + +export function generateStaticParams() { + return getProductDocSlugs(relayflowsSection).map((slug) => ({ slug })); +} + +export async function GET(_request: Request, { params }: RouteContext) { + const { slug } = await params; + const doc = getProductDoc(relayflowsSection.id, slug); + + if (!doc) { + notFound(); + } + + const { fonts, headingFamily, bodyFamily } = await loadBrandFonts(); + + return new ImageResponse( + , + { + ...OG_SIZE, + ...(fonts.length > 0 ? { fonts } : {}), + } + ); +} diff --git a/web/app/docs/relayflows/[slug]/page.tsx b/web/app/docs/relayflows/[slug]/page.tsx new file mode 100644 index 00000000..7b672b5d --- /dev/null +++ b/web/app/docs/relayflows/[slug]/page.tsx @@ -0,0 +1,59 @@ +import type { Metadata } from 'next'; + +import { ProductDocPage } from '../../../../components/docs/ProductDocPage'; +import { ogImage } from '../../../../lib/og-meta'; +import { + relayflowsSection, + getProductDoc, + getProductDocMarkdownUrl, + getProductDocSlugs, +} from '../../../../lib/product-docs'; +import { absoluteUrl } from '../../../../lib/site'; + +const SECTION_ID = relayflowsSection.id; + +type PageProps = { + params: Promise<{ slug: string }>; +}; + +export function generateStaticParams() { + return getProductDocSlugs(relayflowsSection).map((slug) => ({ slug })); +} + +export async function generateMetadata({ params }: PageProps): Promise { + const { slug } = await params; + const doc = getProductDoc(SECTION_ID, slug); + + if (!doc) { + return { title: 'Not Found' }; + } + + const ogPath = `/docs/${SECTION_ID}/${slug}/og.png`; + + return { + title: `${doc.frontmatter.title} — Relayflows`, + description: doc.frontmatter.description, + alternates: { + canonical: absoluteUrl(`/docs/${SECTION_ID}/${slug}`), + types: { 'text/markdown': getProductDocMarkdownUrl(SECTION_ID, slug) }, + }, + openGraph: { + title: `${doc.frontmatter.title} — Relayflows`, + description: doc.frontmatter.description, + url: absoluteUrl(`/docs/${SECTION_ID}/${slug}`), + type: 'article', + images: [ogImage(ogPath, `${doc.frontmatter.title} — Relayflows docs`)], + }, + twitter: { + card: 'summary_large_image', + title: `${doc.frontmatter.title} — Relayflows`, + description: doc.frontmatter.description, + images: [absoluteUrl(ogPath)], + }, + }; +} + +export default async function RelayflowsDocPage({ params }: PageProps) { + const { slug } = await params; + return ; +} diff --git a/web/app/docs/relayflows/markdown/[slug]/route.ts b/web/app/docs/relayflows/markdown/[slug]/route.ts new file mode 100644 index 00000000..a9bd1a4b --- /dev/null +++ b/web/app/docs/relayflows/markdown/[slug]/route.ts @@ -0,0 +1,30 @@ +import { relayflowsSection, getProductDocMarkdown, getProductDocSlugs } from '../../../../../lib/product-docs'; + +export const revalidate = 86400; +export const dynamicParams = false; + +export function generateStaticParams() { + return getProductDocSlugs(relayflowsSection).map((slug) => ({ slug: `${slug}.md` })); +} + +type RouteProps = { + params: Promise<{ slug: string }>; +}; + +export async function GET(_request: Request, { params }: RouteProps) { + const { slug: rawSlug } = await params; + const slug = rawSlug.replace(/\.md$/, ''); + + const doc = getProductDocMarkdown(relayflowsSection.id, slug); + + if (!doc) { + return new Response('Not found', { status: 404 }); + } + + return new Response(doc.markdown, { + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': 'public, max-age=86400', + }, + }); +} diff --git a/web/app/docs/relayflows/page.tsx b/web/app/docs/relayflows/page.tsx new file mode 100644 index 00000000..461a14c6 --- /dev/null +++ b/web/app/docs/relayflows/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function RelayflowsDocsIndexPage() { + redirect('/docs/relayflows/introduction'); +} diff --git a/web/components/docs/DocsLanguageContext.tsx b/web/components/docs/DocsLanguageContext.tsx index b50a36db..c6afb5f8 100644 --- a/web/components/docs/DocsLanguageContext.tsx +++ b/web/components/docs/DocsLanguageContext.tsx @@ -10,7 +10,7 @@ import { type ReactNode, } from 'react'; -export type DocsLanguage = 'typescript' | 'python'; +export type DocsLanguage = 'typescript' | 'python' | 'yaml'; type DocsLanguageContextValue = { language: DocsLanguage; @@ -32,6 +32,10 @@ export function normalizeDocsLanguageLabel(label: string): DocsLanguage | null { return 'python'; } + if (normalized === 'yaml' || normalized === 'yml') { + return 'yaml'; + } + return null; } @@ -40,7 +44,7 @@ export function DocsLanguageProvider({ children }: { children: ReactNode }) { useEffect(() => { const stored = window.localStorage.getItem(STORAGE_KEY); - if (stored === 'typescript' || stored === 'python') { + if (stored === 'typescript' || stored === 'python' || stored === 'yaml') { setLanguageState(stored); } }, []); diff --git a/web/components/docs/DocsNav.tsx b/web/components/docs/DocsNav.tsx index 31d42125..1d6042d4 100644 --- a/web/components/docs/DocsNav.tsx +++ b/web/components/docs/DocsNav.tsx @@ -25,6 +25,7 @@ import { Smile, Terminal, Users, + Workflow, Zap, } from 'lucide-react'; import { BsChatRightText } from 'react-icons/bs'; @@ -53,6 +54,7 @@ const productSectionIcons: Record = { agents: Bot, file: FolderOpenIcon, loop: Repeat, + relayflows: Workflow, }; const currentRelayDocsVersion = docsVersions.find((version) => version.id === currentDocsVersion)?.shortLabel; diff --git a/web/components/docs/LegacySpawnOptionsTable.tsx b/web/components/docs/LegacySpawnOptionsTable.tsx index fc44006b..7b149e28 100644 --- a/web/components/docs/LegacySpawnOptionsTable.tsx +++ b/web/components/docs/LegacySpawnOptionsTable.tsx @@ -155,7 +155,8 @@ function getDescription(row: SpawnOptionRow, language: SpawnOptionsLanguage): st } export function LegacySpawnOptionsTable({ variant }: { variant: SpawnOptionsTableVariant }) { - const { language } = useDocsLanguage(); + const { language: docsLanguage } = useDocsLanguage(); + const language: SpawnOptionsLanguage = docsLanguage === 'python' ? 'python' : 'typescript'; const rows = getRows(variant).filter((row) => getOptionNames(row, language).length > 0); return ( diff --git a/web/components/docs/TableOfContents.tsx b/web/components/docs/TableOfContents.tsx index b52d38ca..481b6834 100644 --- a/web/components/docs/TableOfContents.tsx +++ b/web/components/docs/TableOfContents.tsx @@ -14,6 +14,21 @@ export function TableOfContents({ items }: { items: TocItem[] }) { const { language, setLanguage } = useDocsLanguage(); const pathname = usePathname() ?? '/docs'; const productSection = getProductSectionForPath(pathname); + const secondaryLanguage = productSection?.id === 'relayflows' ? 'yaml' : 'python'; + const secondaryLanguageLabel = secondaryLanguage === 'yaml' ? 'YAML' : 'Python'; + + // The select can only show 'typescript' or this section's secondaryLanguage. + // A stored preference from a different section (e.g. 'python' while viewing + // a relayflows page) falls back to displaying 'typescript' without this + // effect, but the underlying language state stays unchanged — so the + // dropdown and the actual CodeGroup selection drift apart, and reselecting + // the already-displayed option produces no onChange to correct it. Write + // the fallback back to real state so display and state always agree. + useEffect(() => { + if (language !== 'typescript' && language !== secondaryLanguage) { + setLanguage('typescript'); + } + }, [language, secondaryLanguage, setLanguage]); useEffect(() => { const headings = items.map((item) => document.getElementById(item.id)).filter(Boolean) as HTMLElement[]; @@ -43,11 +58,11 @@ export function TableOfContents({ items }: { items: TocItem[] }) { diff --git a/web/content/docs/relayflows/build.mdx b/web/content/docs/relayflows/build.mdx new file mode 100644 index 00000000..13567997 --- /dev/null +++ b/web/content/docs/relayflows/build.mdx @@ -0,0 +1,144 @@ +--- +title: 'Build a flow' +description: 'Write your own steps, verification, and permissions — TypeScript or YAML, same journal underneath.' +--- + +Start with the job in one sentence: what runs, in what order, and what proves each step did its job. If you can't state that proof yet, figure it out first, before wiring the step up. + +## Use the skill + +Don't paste this page into your agent's context — install the [writing-relayflows](https://github.com/AgentWorkforce/skills/blob/main/skills/writing-relayflows/SKILL.md) skill and let it author flows directly: + +```bash +# with prpm +npx prpm install @agent-relay/writing-relayflows + +# with skills.sh +npx skills add https://github.com/agentworkforce/skills --skill writing-relayflows +``` + +The rest of this page is what the skill encodes — worth reading so you can tell whether what it wrote is right. + +## Two ways to author the same thing + +**YAML** describes a fixed set of steps and their dependencies as data, so `flows check` or a CI gate can read and validate it without running anything. **TypeScript** calls the same primitives imperatively, as ordinary code. Both compile down to the same journal. + + +```typescript TypeScript +import { flow } from '@relayflows/surface'; + +export default flow('hello-agent', async (f) => { + const greeting = await f.run('printf hello'); + const edit = await f.agent('edit', { + task: 'Produce the hello artifact.', + cli: 'claude', + model: 'claude-sonnet-4-6', + }); + const finish = await f.run('printf done'); + f.done('success'); +}); +``` +```yaml YAML +version: '0.1.0' +name: hello-agent +steps: + - id: greet + type: deterministic + command: 'printf hello' + verification: + type: output_contains + value: hello + + - id: edit + type: agent + dependsOn: [greet] + instruction: 'Produce the hello artifact.' + cli: claude + model: claude-sonnet-4-6 + verification: + type: output_contains + value: agent-ok + + - id: finish + type: deterministic + dependsOn: [edit] + command: 'printf done' +``` + + +Reach for YAML when you want the whole flow readable at a glance and checkable in CI. Reach for TypeScript when a step's next move depends on what a previous one returned: an `f.human` approval, ordinary `if`/`for` logic, an `f.dispatch` to a child flow. + +Both `edit` steps above name their own `cli` and `model` directly, the same way in either language ([flows#310](https://github.com/AgentWorkforce/flows/issues/310)). Neither is required in TypeScript: omit them and the step falls back to the flow's `cli`, then the nearest `flows.json`'s project-wide default, the same resolution [Introduction](/docs/relayflows/introduction#a-whole-flow) covers. + + + `recoveryMode`, `permissions`, `surfaces`, and `budget`, the richer + fields covered further down, are YAML/JSON fields only today. + TypeScript's `Ctx.agent` doesn't have them. If a step needs them, + author it in YAML and either run it directly or have a TypeScript + flow reach it with `f.dispatch`. + + +## The context a flow body gets + +```ts +interface Ctx { + run(command: string): Step; + llm(strings: TemplateStringsArray, ...values: unknown[]): Step; + agent(name: string, options: { task: string; workspace?: string; cli?: string; model?: string }): Step<{ summary: string; artifacts: string[] }>; + human(question: string, options: { to: string }): Promise; + dispatch(flow: string, input: unknown): Promise; + done(reason: 'success' | 'step_failed' | 'canceled' | 'budget_exceeded'): void; +} +``` + +This is the whole kernel-level vocabulary a step body speaks: `run`, `llm`, `agent` for work, `human`, `dispatch`, `done` for control. `human` parks the run on a durable await instead of a blocking call — it's a wait the journal can survive a restart across. `dispatch` hands work to a named child flow and returns its typed result. + +## Verification + +Every step's exit code is checked automatically. On top of that: + +- **`output_contains`** — an opt-in string match against the step's output. Fast to write, good for a smoke check. +- **`json_schema`** on an `llm` step — the model's reply must parse and validate against the schema before anything downstream sees it. + +A step that fails its check is recorded as `verification_failed`, with exactly which check failed. The agent insisting it went fine doesn't override that. + +## Permissions and recovery + +An `agent` step declares what it's allowed to touch — `fileGlobs`, `accessPreset` — and what happens if it crashes mid-edit: + +```yaml +- id: edit + type: agent + dependsOn: [greet] + instruction: 'Produce the hello artifact through the deterministic test stub.' + recoveryMode: reset + maxIterations: 2 + surfaces: + workspace: + - surface: repo + streams: + - stream: agent-notes + permissions: + fileGlobs: ['artifacts/**'] + accessPreset: readwrite + verification: + type: output_contains + value: agent-ok +``` + +- **`reset`** (the default) — the next attempt starts fresh from the pinned workspace revision, with no half-finished edit left behind. +- **`inspect`** — the next attempt starts inside the dirty workspace, with the failed attempt's trajectory tail injected as context, and decides whether to continue or redo. +- **`manual`** — parks the run as `needs_human` with a diff of the pinned revision against whatever's actually there. + +`maxIterations` caps how many attempts a step gets before one of those three outcomes has to happen. A run-level `budget` (`maxTokensIn`, `maxTokensOut`, `maxDollars`) caps the whole flow the same way, declared once and enforced by the kernel instead of tracked by hand. + +## Next + + + + Scaffold one and run it end to end. + + + check, run, resume — every flag. + + diff --git a/web/content/docs/relayflows/cli.mdx b/web/content/docs/relayflows/cli.mdx new file mode 100644 index 00000000..9d9a90e3 --- /dev/null +++ b/web/content/docs/relayflows/cli.mdx @@ -0,0 +1,68 @@ +--- +title: 'CLI' +description: 'Check a spec, run it, resume it, watch it — the flows command surface.' +--- + +```text +flows check [--json] +flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir ] +flows run --cloud [--json] [--wait] +flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir ] [--local-agent] --input +flows resume [--json] [--no-spawn] [--no-observer-link] [--data-dir ] +flows tick start --schedule-id --interval-ms [--epoch-ms ] [--max-catch-up ] [--poll-interval-ms ] [--data-dir ] +flows hn-monitor start [--data-dir ] [--poll-interval-ms ] +flows observer [--data-dir ] +``` + +## Check + +```bash +flows check my-flow.flow.yaml +``` + +Validates the spec: schema, step graph, verification blocks, and whether every declared CLI actually exists and is authenticated. Nothing runs and nothing is spawned. This is the same preflight a run does before its first step, exposed on its own so a broken spec fails in CI instead of at minute 27 of a real run. + +`check` takes a declarative `flow.yaml` or `spec.json` — it validates data, so an authored `.flow.ts` file isn't a valid argument here. A TypeScript flow gets the same preflight automatically, run inline at the top of `flows run`. + +## Run + +```bash +flows run my-flow.flow.ts --local-agent --input '{}' # TypeScript flow, local CLI +flows run workflow.yaml # YAML/spec.json flow +flows run --cloud --wait workflow.yaml # dispatch to the hosted engine, block for the result +``` + +`--local-agent` runs each declared CLI (`claude`, `codex`, …) using whatever login it already has on your machine — no separate credentials to configure. It's what makes an authored TypeScript flow's `agent` steps runnable at all. + +By default `run` spawns a daemon for the run if one isn't already up. `--no-spawn` (or `FLOWS_NO_SPAWN=1` for a whole environment) asserts a daemon is already present instead — the lever CI uses to fail loudly on a missing daemon rather than silently start one. + +`--data-dir ` points at the journal's storage directory; it defaults to `.relayflowd` in the current project. + +## Resume + +```bash +flows resume +``` + +Picks a run back up from its journal. Completed steps aren't re-executed; only the work that never finished, or never got a recorded outcome, runs again. + +## Observer links + +```bash +flows observer +``` + +Mints a read-only link for watching a run's activity in real time. Minting is best-effort — a failed mint never fails the run — so `--no-observer-link` is there for anyone who wants to skip it outright (CI, for instance). + +## Triggers + +```bash +flows tick start --schedule-id daily --interval-ms 86400000 spec.json +flows hn-monitor start spec.json +``` + +`tick` is a durable schedule. Each interval gets a unique ID, so a restart can't fire the same interval twice, and `--max-catch-up` bounds how many missed intervals get replayed if the process was down. `hn-monitor` is a narrower, named trigger built the same way. Both report whether they're still alive, instead of quietly going dark for weeks without anyone noticing. + +## `--json` + +`check`, `run`, and `resume` accept `--json` for structured output instead of the human-readable progress line — the shape a CI step or another program should read, not the terminal renderer. `tick start`, `hn-monitor start`, and `observer` don't take it. diff --git a/web/content/docs/relayflows/cloud.mdx b/web/content/docs/relayflows/cloud.mdx new file mode 100644 index 00000000..4ebfbea8 --- /dev/null +++ b/web/content/docs/relayflows/cloud.mdx @@ -0,0 +1,67 @@ +--- +title: 'Cloud' +description: 'Run a flow on hosted infrastructure instead of your own machine — the same verification, the same journal.' +--- + +A flow doesn't need your laptop up to run. `flows run --cloud` submits the exact same spec to hosted infrastructure. The Rust runtime still executes and verifies every step; only where it runs is different. + +## Run it + +```bash +flows run --cloud examples/ship-feature.flow.yaml +flows run --cloud --wait --json examples/ship-feature.flow.yaml +``` + +Without `--wait`, exit `0` means the run was **accepted**. You get a run ID back and the run continues on its own; it hasn't completed yet. With `--wait`, exit `0` means Cloud reported the run `completed` with a validated `success` reason. A failed or cancelled run, or an observation failure, exits `1`. + +## From the SDK + +```ts +import { runInCloud, waitForCloudFlowRun } from '@relayflows/sdk'; + +const accepted = await runInCloud( + { path: './flow.yaml' }, + { token: process.env.FLOWS_CLOUD_TOKEN } +); +console.log(accepted.runId); // accepted, not completed + +const finished = await waitForCloudFlowRun(accepted.runId); +console.log(finished.status, finished.completionReason); +``` + +`FLOWS_CLOUD_TOKEN` needs a Cloud API token; the `flows` CLI never logs in for you. Get one with the `agent-relay` CLI, a separate tool from `flows`: + +```bash +agent-relay cloud login # opens a browser; add --device on a headless/SSH host +agent-relay cloud session --json --reveal-token +``` + +`session --json --reveal-token` prints the session as JSON, including the real `accessToken` (masked without `--reveal-token`, the same convention as `agent-relay workspace key`). Export that value: + +```bash +export FLOWS_CLOUD_TOKEN="$(agent-relay cloud session --json --reveal-token | jq -r .accessToken)" +``` + +This token carries a `cli:auth` scope, not the literal `workflow:invoke:write` / `workflow:runs:read` strings — but Cloud's workflow-run endpoints accept `cli:auth` for both submitting and polling a run, so it works for everything on this page. It's tied to your own login and one Cloud workspace; anyone with their own Cloud account can mint their own this way, no special access needed. + +A separate, narrower-scoped token — one that literally carries only `workflow:invoke:write` and `workflow:runs:read`, nothing else `cli:auth` would also unlock — exists for unattended CI and requires direct database access to mint (`cloud`'s `npm run mint-ci-token` with `CI_TOKEN_PROFILE=workflow-invoke`; see that repo's `docs/runbooks/relay-ci-workflow-credential.md`). That path is for Agent Relay's own CI, not something a typical `flows` user needs — `agent-relay cloud login` above is the one to use. + +`FLOWS_CLOUD_URL` points at a different Cloud deployment if you're not using the default. + +## What's different about a cloud run + +- **Only declarative flows.** `--cloud` accepts `flow.yaml` / `spec.json`, not an authored `.flow.ts` file — the SDK refuses those before making an HTTP call rather than uploading code that can't run there. +- **Accepted isn't completed.** An interruption after submission but before the acceptance receipt reports `admission_unknown` — the server may already have started a non-idempotent run. Don't resubmit blindly; check the run ID you already have first. +- **One-hour execution ceiling.** Cloud's current executor has a one-hour deadline per run, independent of any local timeout you'd otherwise configure. +- **You get the completion reason, not the step-by-step journal.** It's validated against the same closed vocabulary as a local run, but this API doesn't expose per-step detail yet. + +## Next + + + + Named agents, different CLIs, one spec. + + + What a completion reason actually guarantees. + + diff --git a/web/content/docs/relayflows/introduction.mdx b/web/content/docs/relayflows/introduction.mdx new file mode 100644 index 00000000..7455cd74 --- /dev/null +++ b/web/content/docs/relayflows/introduction.mdx @@ -0,0 +1,127 @@ +--- +title: 'Relayflows' +description: 'A deterministic script over agentic primitives: steps you can inspect, verify, and resume.' +--- + +Relayflows turns a coding-agent task into steps you can inspect and verify. A flow combines shell commands, model calls, and coding agents with a journal that records what each step did and why it completed. + +Every effect is journaled before it's treated as real. If a step's journal write fails, the step fails. There's no silent fallback and no "it probably worked." Relayflows makes that trade everywhere: less magic, more evidence. + +## The ladder + +Every flow is built from the same small set of rungs, and you only climb as high as the task needs: + +1. **`run`** — a shell command. No model involved. +2. **`llm`** — a bare model call. Prompt in, verified output out, no workspace, no tool use. +3. **`agent`** — a harnessed coding agent in a workspace. Returns an artifact, not just text. +4. **`human` / `dispatch` / `done`** — durable approval, handing work to a child flow, and a typed finish. + +## Simple Example Flow + + +```typescript TypeScript +import { flow } from '@relayflows/surface'; + +export default flow('hello', 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 +``` + + +`f.run` (a `deterministic` step in YAML) executes a shell command and returns its output. `f.agent` (an `agent` step) hands a task to a coding agent and returns a summary rather than a raw transcript. `f.done` finishes the run with one reason from a closed set: `success`, `step_failed`, `canceled`, or `budget_exceeded`. There's no fifth option to guess about. + +Both steps above name their own `cli` and `model` directly — `Ctx.agent`'s options are `{ task, workspace?, cli?, model? }`, matching the YAML step's fields ([flows#310](https://github.com/AgentWorkforce/flows/issues/310)). Neither is required: omit `cli` and a step falls back to its flow's `cli`, then the nearest `flows.json`'s project-wide default; omit `model` and it just runs whatever model its resolved CLI defaults to, since there's no flow or project default for that. Without a `cli` at step, flow, or project level, `flows run` refuses before anything is journaled — exit `2`, `REFUSED [invalid_spec]`. + +## Verification + +An agent rarely fails by crashing. It fails by returning something plausible and wrong, which a plain retry-on-error never catches. So a step's completion is decided by a check the kernel runs against the real output, not by the agent's own account of what happened. + +```yaml +version: '0.1.0' +name: hello-agent +steps: + - id: greet + type: deterministic + command: 'printf hello' + verification: + type: output_contains + value: hello + + - id: edit + type: agent + dependsOn: [greet] + instruction: 'Produce the hello artifact.' + recoveryMode: reset + maxIterations: 2 + permissions: + fileGlobs: ['artifacts/**'] + accessPreset: readwrite + verification: + type: output_contains + value: agent-ok +``` + +The exit code is always checked. On top of that, `output_contains` adds an opt-in string match, and an `llm` step can require its output to match a `json_schema` instead. A step that fails verification is recorded as `verification_failed` in the journal — it doesn't get to report success on its own say. + +`recoveryMode: reset` governs what happens if this step dies mid-edit: the next attempt starts over from the pinned workspace revision instead of picking up whatever half-finished state got left behind. `permissions` limits what that attempt is allowed to touch while it runs. + +## Resumable by construction + +Kill the process mid-run and start it again. Steps that already completed don't run a second time, and their effects don't get resent. + +```text +$ npx --no-install flows run hello.flow.ts --local-agent --input '{}' +○ run-1 (deterministic) 0.00s +✓ run-1 (deterministic) 1.02s completionReason: success +Hello from Relayflows +○ agent-2 (agent) [agent: preparing] 0.00s +↻ agent-2 (agent) [agent: running] 12.51s +✓ agent-2 (agent) [agent: completed] 28.95s completionReason: success +Hello! How can I help you today? + +RUN 01M20JYM4T9FNGNFX4NQCK34JK completed (3 steps) completionReason: success +``` + +This is a real run, captured directly from the terminal. Every step carries a `completionReason` from a closed, nine-value vocabulary, so the journal always says exactly which one applied — no raw stack trace has to stand in for an answer. + +## Start here + + + + From nothing to a resumed run in a few commands. + + + TypeScript or YAML — the same journal underneath. + + + check, run, resume, observe. + + + + + See how far the ladder goes: named agents, cloud dispatch, memory, and integrations. + diff --git a/web/content/docs/relayflows/memory-and-integrations.mdx b/web/content/docs/relayflows/memory-and-integrations.mdx new file mode 100644 index 00000000..53c3d320 --- /dev/null +++ b/web/content/docs/relayflows/memory-and-integrations.mdx @@ -0,0 +1,62 @@ +--- +title: 'Memory & integrations' +description: 'Relayhistory-backed memory and Relayfile-backed integrations — the two substrates a flow reaches without hand-rolling either.' +--- + +A flow doesn't carry its own database or its own Slack client. Two existing systems back it instead: [Relayloop](/docs/loop) for memory across runs, and [Relayfile](/docs/file) for everything a flow reads from or writes to outside itself. + +## Memory + +A step declares what it needs from prior runs, and whatever comes back is charged to that step's own budget, itemized in its own journal entry. There's no shared pool it draws from: + +```ts +export default flow('triage', async (f) => { + const priorArt = await f.memory.recall('similar bugs in the billing module'); + const why = await f.memory.why('fix the billing race condition'); + + const fix = await f.agent('fixer', { + task: `Fix the race condition.\n\nRelevant history:\n${priorArt}`, + }); + + await f.memory.learn(`Root cause: ${fix.summary}`); + f.done('success'); +}); +``` + +Under the hood this is [Relayloop](/docs/loop)'s `ai-hist pack` — the same searchable, local-first history your team already builds just by using Claude Code, Codex, and Agent Relay. A flow's memory queries hit that same corpus, so an agent step can cite a fix an engineer already worked out by hand, instead of rediscovering it from scratch. + + + What this feature is judged on is retrieval quality, not the plumbing: + an agent visibly avoiding a mistake a prior run already made, citation + included. Today's shipped provider is a fixed stub while real `ai-hist + pack` retrieval lands. The `memory.injected` journal entry and its + per-step budget accounting are real regardless of which provider answers + the query. + + +## Integrations + +Reaching Slack, GitHub, or Linear from a flow doesn't mean writing a provider SDK call and hoping the token scope is right. [Relayfile](/docs/file) mounts each provider as a directory a flow reads and writes: + +```ts +export default flow('release-note', async (f) => { + const diff = await f.run('git diff main'); + const note = await f.llm`One-line release note for: ${diff}`; + + const receipt = await f.slack.post('#releases', note); + f.done('success'); +}); +``` + +`f.slack`, `f.github`, `f.linear`, and every other generated helper compile to a write against Relayfile's [adapter catalog](/docs/file/adapters-and-providers) — the same 50 providers Relayfile ships today, each turned into a flow-native verb. The receipt a helper returns *is* the journaled effect record, so if a step retries and posts the same Slack message twice, the second write gets deduped instead of double-sending. + +## Next + + + + The full integration catalog a flow can reach. + + + How an effect stays exactly-once across a crash. + + diff --git a/web/content/docs/relayflows/multi-agent.mdx b/web/content/docs/relayflows/multi-agent.mdx new file mode 100644 index 00000000..63dc8e7b --- /dev/null +++ b/web/content/docs/relayflows/multi-agent.mdx @@ -0,0 +1,85 @@ +--- +title: 'Multi-agent flows' +description: 'Several named agents, different CLIs and models, one flow — plus durable human approval and handoff to child flows.' +--- + +A flow isn't locked to one CLI or one model. Name as many agents as the task needs, give each one its own `cli` and `model`, and assign them to steps — the planning step can run on Opus, the edit can go to Codex, and the review can come back to Claude, all in one run, each step's identity and cost accounted separately. + +## Named agents + +```yaml +version: '0.1.0' +name: ship-feature +agents: + planner: { cli: claude, model: claude-opus-4-6 } + implementer: { cli: codex, model: gpt-5.6-codex } + reviewer: { cli: claude, model: claude-sonnet-4-6 } + +steps: + - id: plan + type: agent + agent: planner + instruction: 'Plan the implementation for: add OAuth2 support' + + - id: implement + type: agent + agent: implementer + dependsOn: [plan] + instruction: 'Implement the plan from the previous step.' + + - id: review + type: agent + agent: reviewer + dependsOn: [implement] + instruction: 'Review the diff for correctness and security.' + verification: + type: output_contains + value: 'APPROVED' +``` + +Each step's `agent:` selector resolves to that named `{ cli, model }` pair at compile time, from an explicit declaration. `flows check` also flags a named agent nobody ever selects, and one that a step overrides without using, so a stale declaration doesn't quietly rot in the spec. + + + The named-agent **map** (`agents:` plus a step's `agent:` selector, + reused across steps by name) is YAML/JSON authoring only today. In + TypeScript, `f.agent(name, options)` takes `{ task, workspace?, cli?, + model? }` ([flows#310](https://github.com/AgentWorkforce/flows/issues/310)) + — so a call can set its own `cli`/`model` directly, but the `name` + argument still just labels the call in the journal; there's no map to + select a declared pair from, or to reuse across multiple calls, the + way YAML's `agent:` selector does ([flows#300](https://github.com/AgentWorkforce/flows/issues/300)). + + +## Asking a human, then handing off + +```ts +import { flow } from '@relayflows/surface'; + +export default flow('ship-feature', async (f) => { + const plan = await f.agent('planner', { + task: 'Plan implementation for: add OAuth2 support', + }); + + const ok = await f.human(`Ship this?\n${plan.summary}`, { to: 'khaliq' }); + if (!ok) return f.done('canceled'); + + const pr = await f.dispatch('garden/implement', plan); + f.done('success'); +}); +``` + +`f.human` parks the run on a durable wait — nothing sits there blocking a thread, and the wait survives a restart exactly like a crash mid-step does. `f.dispatch` hands the plan to a named child flow and returns its typed result, so one large flow decomposes into several smaller ones instead of a script that tries to do everything. + +## Next + + + + Run the same flow on hosted infrastructure. + + + Recall past runs; reach Slack, GitHub, and Linear. + + + The crash-resume contract and the closed failure vocabulary. + + diff --git a/web/content/docs/relayflows/quickstart.mdx b/web/content/docs/relayflows/quickstart.mdx new file mode 100644 index 00000000..110233ef --- /dev/null +++ b/web/content/docs/relayflows/quickstart.mdx @@ -0,0 +1,104 @@ +--- +title: 'Quickstart' +description: 'From zero to a verified, resumable run: write a flow, run it, and pick it back up after a crash.' +--- + +This walks you from a fresh install to a real run in a few commands, using nothing but the published `relayflows` CLI. No scaffolder, no project template. + +## 1. Install + + +```bash TypeScript +npm init -y +npm install @relayflows/surface relayflows +``` +```bash YAML +npm install -g relayflows +``` + + +TypeScript flows import `@relayflows/surface` at runtime, so it's a project dependency. YAML is data — the global `relayflows` install is all you need. + +## 2. Write a flow + + +```typescript TypeScript +// hello.flow.ts +import { flow } from '@relayflows/surface'; + +export default flow('hello', async (f) => { + const greeting = await f.run('printf "Hello from Relayflows"'); + console.log(greeting.trim()); + f.done('success'); +}); +``` +```yaml YAML +# hello.flow.yaml +version: '0.1.0' +name: hello +steps: + - id: greeting + type: deterministic + command: "printf 'Hello from Relayflows'" + verification: + type: output_contains + value: Hello + - id: finish + type: deterministic + dependsOn: [greeting] + command: "printf done" +``` + + +YAML is canonical — the compiler can check it without running anything. TypeScript is the power tool: the same primitives, called imperatively, with ordinary `if`/`for` control flow around each `await`. + +## 3. Run it + + +```bash TypeScript +npx flows run hello.flow.ts --input '{}' +``` +```bash YAML +npx flows check hello.flow.yaml # preflight only — nothing runs +npx flows run hello.flow.yaml +``` + + +This is a real, captured run: + + +```text TypeScript +Hello from Relayflows +RUN 01M26JC2VPAGFVTCVWFT3GSCXQ completed (2 steps) completionReason: success +``` +```text YAML +CHECK PASSED hello.flow.yaml +RUN 01M26HYJNS6A4K5Q64FH6D0SG9 completed (2 steps) completionReason: success +``` + + +Every step's outcome is in the journal now, keyed by that run ID. `flows check` only takes a `flow.yaml` or `spec.json` — a TypeScript flow gets the same preflight for free, as the first thing `flows run` does. + +## 4. If it gets interrupted + +`flows run` starts a background daemon, `relayflowd`. The journal lives there, not in the CLI process you typed the command into. Close the terminal, lose power, or kill the daemon itself mid-run: nothing is lost. + +```bash +flows resume 01M26JC2VPAGFVTCVWFT3GSCXQ +``` + +`resume` starts a fresh daemon if none is up, reads the journal from disk, and continues from the first step that never recorded a `completionReason` — the step already marked done doesn't run again. + +## Next + + + + Write your own steps, verification, and permissions — YAML or TypeScript. + + + Several named agents, different CLIs, one flow. + + + Every command: check, run, resume, tick, observe. + + diff --git a/web/content/docs/relayflows/reliability.mdx b/web/content/docs/relayflows/reliability.mdx new file mode 100644 index 00000000..a1e53f19 --- /dev/null +++ b/web/content/docs/relayflows/reliability.mdx @@ -0,0 +1,65 @@ +--- +title: 'Reliability' +description: 'The crash-resume contract, the closed failure vocabulary, and the rules that make a flow finishing mean something.' +--- + +This page covers what makes a run's completion trustworthy: not an agent's own report, but something the kernel checks and can prove, whether the run finished cleanly or got killed and picked back up partway through. + +## A run's exit code is a contract + +```text +0 completionReason: success +1 a declared completionReason failure, or an unknown outcome from a transport/runtime/protocol error +2 refused before any journal write: invalid input, failed preflight, unreachable daemon +3 parked — waiting on an llm/agent worker or a needs_human recovery wait +``` + +Exit `2` is the important one to build on: a bad spec, a missing CLI, or an unauthenticated agent fails before a journal entry is ever written, at `flows check` time or at the top of `flows run` — never at minute 27 of a real run. Preflight checks what it can check up front, so a run either starts clean or doesn't start. + +## The closed completion vocabulary + +Every step, and every run, ends in one reason from a fixed set: + +```text +Step: success · verification_failed · retries_exhausted · lease_expired · + crashed · timeout · worker_error · budget_exceeded · canceled + +Run: success · step_failed · canceled · budget_exceeded +``` + +Handle every value in that list and there's no case left over to surprise you later. A step that fails verification is recorded as `verification_failed`, with exactly which check failed. The agent reporting that it went fine doesn't change the outcome. + +## Crashing mid-step + +If an agent step crashes or its lease expires, the workspace is left dirty. The kernel doesn't roll back an edit that already happened. What runs next depends on the step's declared recovery mode: + +- **`reset`** (default) — the next attempt restores the pinned workspace revision and starts clean. +- **`inspect`** — the next attempt starts inside the dirty workspace, with the failed attempt's trajectory tail injected as context, and decides whether to continue or redo. +- **`manual`** — the run parks as `needs_human` with a diff of the pinned revision against whatever's actually there. + +Effects are deduped on a separate track from all of that. A writeback — a Slack post, a PR, a file write — is journaled as a fact keyed by `(step id, idempotency key, surface path)`, so two attempts at the same writeback still produce one provider call. A step can run more than once. What it can't do is have an effect land twice on the other end. + +## A flow can't fake finishing + +An authored TypeScript body runs under three rules, enforced by the runtime rather than left to convention: + +1. **Await every step you create.** An `f.run(...)` call that's created and never awaited is `unawaited_step`. If it might still be running when the flow reports done, the journal has no way to vouch for it. +2. **A step's failure is yours whether or not you catch it.** A root failure is recorded before your code can even see it, so a `catch` can't quietly swallow one. +3. **Finish your derived work before `done()`.** If something chained onto a step is still in flight when the body returns, the run is refused as `unsettled_derived_work` rather than recorded as a success nobody can actually verify. + +Together, these rules mean a run reporting `completionReason: success` really did finish everything it started, rather than everything that happened to settle before the process moved on. + +## The daemon + +`flows run` and `flows resume` attach to the daemon already serving your data directory, or start one if there isn't one. Run two at once and that's fine — the daemon holds an exclusive lock, so the second one just attaches to the first instead of starting a competing one. `--no-spawn` (or `FLOWS_NO_SPAWN=1` for a whole CI environment) turns that convenience off: it fails loudly if no daemon is already up, instead of quietly starting one for you. + +## Next + + + + check, run, resume — every exit code in practice. + + + Verification and permissions, from the author's side. + + diff --git a/web/lib/product-docs-nav.ts b/web/lib/product-docs-nav.ts index 1b947f82..63a087a8 100644 --- a/web/lib/product-docs-nav.ts +++ b/web/lib/product-docs-nav.ts @@ -211,11 +211,45 @@ export const factorySection: ProductDocSection = { ], }; +export const relayflowsSection: ProductDocSection = { + id: 'relayflows', + label: 'Relayflows', + tagline: 'Deterministic scripts over agentic primitives — every step verified, every crash resumable.', + repo: 'AgentWorkforce/flows', + nav: [ + { + title: 'Start', + items: [ + { title: 'Introduction', slug: 'introduction' }, + { title: 'Quickstart', slug: 'quickstart' }, + ], + }, + { + title: 'Author', + items: [{ title: 'Build a flow', slug: 'build' }], + }, + { + title: 'Run', + items: [{ title: 'CLI', slug: 'cli' }], + }, + { + title: 'Going further', + items: [ + { title: 'Multi-agent flows', slug: 'multi-agent' }, + { title: 'Cloud', slug: 'cloud' }, + { title: 'Memory & integrations', slug: 'memory-and-integrations' }, + { title: 'Reliability', slug: 'reliability' }, + ], + }, + ], +}; + export const productSections: ProductDocSection[] = [ fileSection, agentsSection, factorySection, loopSection, + relayflowsSection, ]; export function getProductSection(id: string): ProductDocSection | null { diff --git a/web/lib/product-docs.ts b/web/lib/product-docs.ts index 27cdd562..aba5007b 100644 --- a/web/lib/product-docs.ts +++ b/web/lib/product-docs.ts @@ -17,6 +17,7 @@ export { factorySection, fileSection, loopSection, + relayflowsSection, productSections, getProductSection, getProductSectionForPath, diff --git a/web/next.config.mjs b/web/next.config.mjs index c846d2f8..a39610ab 100644 --- a/web/next.config.mjs +++ b/web/next.config.mjs @@ -61,6 +61,7 @@ const nextConfig = { '/docs/factory/markdown/:path*', '/docs/file/markdown/:path*', '/docs/loop/markdown/:path*', + '/docs/relayflows/markdown/:path*', '/docs/:slug([^/]+\\.md)', '/.well-known/:path*', ];