Manage Flows as code

Keep your Flow definitions in your repository — reviewed, versioned, and rendered from the same data your product uses — and have the platform converge to them at deploy time. This is the Flow counterpart of managing Agents as code: the same ensure protocol, applied to Flows.

Ensure and upsert are siblings, not versions of each other

The SDK has two code-first Flow verbs, and they answer different questions:

  • flows.upsert() is the runtime verb: save and run in one dispatch. It creates or updates the Flow by name, then executes it against a record in the same request. Use it inside your application when the Flow definition lives next to the code that runs it.
  • flows.ensure() is the deploy verb: converge only. It makes the platform’s saved definition match your repo’s — creating, updating, or doing nothing — and never executes anything. Use it from CI/CD, the same place you deploy the rest of your application.

upsert is not deprecated and is not an older version of ensure. If you want to run the Flow, use upsert (or dispatch by ID); if you want the deploy to make the platform match the repo, use ensure.

Define the Flow

defineFlow is a pure, local constructor. It validates the definition shape and returns the canonical definition object. Because the same definition converges every environment, it must be environment-portable: account-scoped tool IDs (tool_…) are rejected — use portable builtin:, platform:, or mcp: tool references, or reference a saved tool by name with tool:<name> (see Reference saved tools by name) — and steps carry no server-minted ids.

1import { defineFlow } from '@runtypelabs/sdk'
2
3export const onboardingDigest = defineFlow({
4 name: 'Onboarding Digest',
5 steps: [
6 {
7 type: 'prompt',
8 name: 'Summarize signups',
9 config: {
10 model: 'claude-sonnet-4-6',
11 userPrompt: 'Summarize this week\'s signups: {{signups}}',
12 },
13 },
14 {
15 type: 'send-email',
16 name: 'Send digest',
17 config: { to: 'team@example.com', subject: 'Weekly digest', body: '{{summary}}' },
18 },
19 ],
20})

The definition surface is the Flow’s name and steps. The Flow’s identity is its name plus your account scope (organization, or personal); which environment you converge is decided by the credentials you run with.

Renaming a definition does not rename the Flow. ensure will create a new Flow under the new name and leave the old one in place (it never deletes). Treat the name as the stable identity.

Reference saved tools by name

Built-in, platform, and MCP tools have portable IDs you can hardcode. Your saved tools (external HTTP tools, custom code tools, flow tools) get a per-account, per-environment ID (tool_…) that can’t appear in a portable definition. Reference them by name with the tool:<name> form — in a prompt or transform-data step’s tools.toolIds, and in a tool-call step’s toolId:

1export const scrapeAndSummarize = defineFlow({
2 name: 'Scrape and summarize',
3 steps: [
4 {
5 type: 'tool-call',
6 name: 'Scrape',
7 config: { toolId: 'tool:My Scraper', parameters: { url: '{{target}}' }, outputVariable: 'page' },
8 },
9 {
10 type: 'prompt',
11 name: 'Summarize',
12 config: { model: 'claude-sonnet-4-6', userPrompt: 'Summarize: {{page}}' },
13 },
14 ],
15})

The text after tool: is the tool’s exact name. The name is canonical: it’s what gets hashed (so the same definition converges to the same content hash everywhere) and it resolves to the actual tool at run time, every dispatch. ensure validates each reference when you converge (including dryRun): a name that matches no saved tool fails so the gap surfaces in CI, and a name that matches more than one fails and names both so you can rename one. When you pull a dashboard-built Flow, saved-tool IDs are reverse-mapped to tool:<name>; references that can’t be emitted by name (deleted, or name-shadowed) are left as raw IDs with an entry in a warnings array.

Reference saved agents and flows by name

The same per-account, per-environment ID problem applies wherever a Flow points at another saved resource. Use the matching name form:

  • agent:<name> — an execute-agent step’s agentId, and a saved-subagent runtime tool’s config.agentId.
  • flow:<name> — a flow-as-tool runtime tool’s config.flowId.
1export const researchPipeline = defineFlow({
2 name: 'Research pipeline',
3 steps: [
4 {
5 type: 'execute-agent',
6 name: 'Research',
7 config: { agentId: 'agent:Researcher', message: 'Research: {{topic}}' },
8 },
9 {
10 type: 'prompt',
11 name: 'Enrich and write',
12 config: {
13 model: 'claude-sonnet-4-6',
14 userPrompt: 'Write it up: {{agent_response}}',
15 tools: {
16 runtimeTools: [
17 {
18 toolType: 'flow',
19 name: 'enrich',
20 description: 'Enrich a record',
21 parametersSchema: {},
22 config: { flowId: 'flow:Enrich Lead' },
23 },
24 ],
25 },
26 },
27 },
28 ],
29})

These behave exactly like tool:<name>: the name is canonical and hashed, resolution happens at run time every dispatch, and ensure (including dryRun) fails on a name that matches no agent/flow or more than one in the target account. Raw agent_… / flow_… IDs stay rejected on the ensure surface, and pull reverse-maps them to the name forms (leaving anything that can’t round-trip as a raw ID with a warnings entry).

Converge at deploy time

1import { Runtype } from '@runtypelabs/sdk'
2
3Runtype.configure({ apiKey: process.env.RUNTYPE_API_KEY })
4
5const result = await Runtype.flows.ensure(onboardingDigest)
6// → { result: 'unchanged' | 'created' | 'updated', flowId, versionId, contentHash }

ensure is hash-first: in the steady state (nothing changed) it sends one tiny probe request carrying only the name and a content hash, and writes nothing. When the content differs, it writes the saved Flow and appends an immutable version snapshot — history is never mutated, and a dashboard edit you overwrite is still recoverable from version history.

The content hash covers the Flow’s steps and is the same hash the upsert protocol uses, so ensure and upsert interoperate on the same Flow. The contentHash in every response is computed by the server over its canonical, normalized form; the SDK echoes the server’s hash on later probes.

Publishing

By default ensure writes the saved Flow and a draft version. To also re-aim the published-version pointer at the version it just created:

1await Runtype.flows.ensure(onboardingDigest, { release: 'publish' })

Detect drift in CI

1const plan = await Runtype.flows.ensure(onboardingDigest, { dryRun: true })
2// → { result: 'plan', changes: 'none' | 'create' | 'update', changedKeys, contentHash, remoteHash }

changedKeys names the steps that would change (steps.added.<name>, steps.removed.<name>, steps.modified.<name>). For a one-line PR gate, expectNoChanges throws when the plan is anything but none:

1await Runtype.flows.ensure(onboardingDigest, { expectNoChanges: true })

To make the apply step act only on the exact state the dry run saw, bind them with expectedRemoteHash:

1await Runtype.flows.ensure(onboardingDigest, { expectedRemoteHash: plan.remoteHash })

Conflicts with dashboard edits

The platform records where each Flow write came from. If someone edits an ensure-managed Flow in the dashboard (or via the API), the next ensure fails with a 409 conflict by default, surfaced in the SDK as a FlowEnsureConflictError with code: 'external_modification'.

You have two remediation directions:

  • Repo wins. Re-run with onConflict: 'overwrite'. The dashboard edit is overwritten in the saved Flow but preserved in version history.
  • Dashboard wins. Absorb the edit into your repo with pull, review it as a git diff, and commit or revert:
1const { definition, contentHash, lastModifiedSource, updatedAt } =
2 await Runtype.flows.pull('Onboarding Digest')

What ensure does and does not manage

ensure converges the Flow definition: its name and step list (validated through the same canonical validator as Flow creation). It does not manage the Flow’s description, schedules, surfaces, records, or executions, and it never deletes or renames Flows.

Wire protocol (direct API use)

If you are not using the TypeScript SDK, the protocol is one endpoint: POST /v1/flows/ensure.

  1. Probe with { "name": "...", "contentHash": "<sha-256>" }. A match returns { "result": "unchanged" }; a miss returns a normal 200 { "result": "definitionRequired" } — retry with the full definition included.
  2. The server recomputes the canonical hash over any submitted definition and returns it on every response. Echo the server’s hash in later probes.
  3. Conflicts are 409s (external_modification, remote_changed); a submitted contentHash that disagrees with the server’s recompute over the definition is a 422 (content_hash_mismatch). Omit contentHash on full requests to avoid it entirely.

GET /v1/flows/pull?name=... returns the canonical definition plus provenance (contentHash, lastModifiedSource, updatedAt, versionId).

Next steps