Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions web/app/docs/relayflows/[slug]/og.png/route.tsx
Original file line number Diff line number Diff line change
@@ -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(
<DefaultVariant
headingFamily={headingFamily}
bodyFamily={bodyFamily}
eyebrow={relayflowsSection.label}
title={doc.frontmatter.title}
subtitle={doc.frontmatter.description}
/>,
{
...OG_SIZE,
...(fonts.length > 0 ? { fonts } : {}),
}
);
}
59 changes: 59 additions & 0 deletions web/app/docs/relayflows/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 <ProductDocPage sectionId={SECTION_ID} slug={slug} />;
}
30 changes: 30 additions & 0 deletions web/app/docs/relayflows/markdown/[slug]/route.ts
Original file line number Diff line number Diff line change
@@ -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',
},
});
}
Comment thread
cursor[bot] marked this conversation as resolved.
5 changes: 5 additions & 0 deletions web/app/docs/relayflows/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';

export default function RelayflowsDocsIndexPage() {
redirect('/docs/relayflows/introduction');
}
8 changes: 6 additions & 2 deletions web/components/docs/DocsLanguageContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
type ReactNode,
} from 'react';

export type DocsLanguage = 'typescript' | 'python';
export type DocsLanguage = 'typescript' | 'python' | 'yaml';

type DocsLanguageContextValue = {
language: DocsLanguage;
Expand All @@ -32,6 +32,10 @@ export function normalizeDocsLanguageLabel(label: string): DocsLanguage | null {
return 'python';
}

if (normalized === 'yaml' || normalized === 'yml') {
return 'yaml';
}

return null;
}

Expand All @@ -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);
}
}, []);
Expand Down
2 changes: 2 additions & 0 deletions web/components/docs/DocsNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
Smile,
Terminal,
Users,
Workflow,
Zap,
} from 'lucide-react';
import { BsChatRightText } from 'react-icons/bs';
Expand Down Expand Up @@ -53,6 +54,7 @@ const productSectionIcons: Record<string, NavIcon> = {
agents: Bot,
file: FolderOpenIcon,
loop: Repeat,
relayflows: Workflow,
};

const currentRelayDocsVersion = docsVersions.find((version) => version.id === currentDocsVersion)?.shortLabel;
Expand Down
3 changes: 2 additions & 1 deletion web/components/docs/LegacySpawnOptionsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
21 changes: 18 additions & 3 deletions web/components/docs/TableOfContents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Language preference wiped across sections

Medium Severity

The new effect writes typescript into the shared docs language state whenever the stored preference is not offered on the current section. Opening a Relayflows page with a python preference, or a core docs page with yaml, overwrites localStorage via setLanguage, so the original choice is gone after navigating back.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 33cda75. Configure here.


useEffect(() => {
const headings = items.map((item) => document.getElementById(item.id)).filter(Boolean) as HTMLElement[];
Expand Down Expand Up @@ -43,11 +58,11 @@ export function TableOfContents({ items }: { items: TocItem[] }) {
<select
className={styles.tocSelect}
aria-label="Select docs language"
value={language}
onChange={(event) => setLanguage(event.target.value as 'typescript' | 'python')}
value={language === secondaryLanguage ? secondaryLanguage : 'typescript'}
onChange={(event) => setLanguage(event.target.value as 'typescript' | 'python' | 'yaml')}
Comment thread
cursor[bot] marked this conversation as resolved.
>
<option value="typescript">TypeScript</option>
<option value="python">Python</option>
<option value={secondaryLanguage}>{secondaryLanguageLabel}</option>
</select>
</label>
</div>
Expand Down
144 changes: 144 additions & 0 deletions web/content/docs/relayflows/build.mdx
Original file line number Diff line number Diff line change
@@ -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.

<CodeGroup>
```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', {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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'
```
</CodeGroup>

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.

<Note>
`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`.
</Note>

## The context a flow body gets

```ts
interface Ctx {
run(command: string): Step<string>;
llm(strings: TemplateStringsArray, ...values: unknown[]): Step<string>;
agent(name: string, options: { task: string; workspace?: string; cli?: string; model?: string }): Step<{ summary: string; artifacts: string[] }>;
human(question: string, options: { to: string }): Promise<boolean>;
dispatch<T>(flow: string, input: unknown): Promise<T>;
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

<CardGroup cols={2}>
<Card title="Quickstart" href="/docs/relayflows/quickstart">
Scaffold one and run it end to end.
</Card>
<Card title="CLI" href="/docs/relayflows/cli">
check, run, resume — every flag.
</Card>
</CardGroup>
Loading
Loading