Skip to navigation

Manage flows as code

Keep Flow definitions in your repository and review them with your code. In your deploy pipeline, run flows.ensure() to create or update each saved Flow so that it matches the definition.

Choose between ensure and upsert

Use these two code-first Flow methods:

  • flows.upsert() saves and runs a Flow in one dispatch. It creates or updates the Flow by name, then executes it against a record in the same request. Use it in your application when the Flow definition lives beside the code that runs it.
  • flows.ensure() converges the saved Flow definition without executing it. It creates, updates, or leaves the Flow unchanged. Use it in continuous integration and continuous delivery (CI/CD) jobs.

flows.upsert() is not deprecated or an older version of flows.ensure(). Run a Flow with flows.upsert() or by dispatching it by ID. Converge the saved definition with flows.ensure().

What ensure does and does not manage

ensure converges the Flow’s name and steps, and validates them the same way as creating a Flow. When the definition declares inline evals, ensure also converges each eval suite after the Flow converges. The result contains the suite outcomes in result.evals.

ensure does not manage the Flow’s description, schedules, Surfaces, Records, or executions. It does not delete or rename Flows.

Before you begin

Before you converge a Flow, set up the following:

  • Install the TypeScript SDK with npm install @runtypelabs/sdk.
  • Create an API key for the environment that you deploy to. The API key’s organization or personal scope determines which account ensure writes to, and the API endpoint selects the environment.
  • Give the API key the Flows (Write) permission (FLOWS:WRITE) for ensure. Add Flows (Read) (FLOWS:READ) to use pull, or select Flows (Full) (FLOWS:*) for both. For more information, see Managing API keys.
  • Create any saved Tools, Agents, and Flows that the definition references by name in the target environment.

Define a Flow

defineFlow builds a definition locally without network requests. It checks the definition shape and returns the definition object that ensure sends.

Keep the definition portable across environments. defineFlow rejects account-scoped resource references that use tool_, agent_, or flow_ prefixes. Use portable builtin:, platform:, or mcp: tool references, or reference saved resources by name with tool:TOOL_NAME, agent:AGENT_NAME, or flow:FLOW_NAME. Replace each placeholder with the exact saved resource name.

Define a Flow with a name and ordered steps:

TypeScript SDK
import { defineFlow } from '@runtypelabs/sdk'
export const onboardingDigest = defineFlow({
name: 'Onboarding Digest',
steps: [
{
type: 'prompt',
name: 'Summarize signups',
config: {
model: 'claude-sonnet-5',
userPrompt: "Summarize this week's signups: {{signups}}",
outputVariable: 'summary',
},
},
{
type: 'send-email',
name: 'Send digest',
config: {
to: 'dana@example.com',
subject: 'Weekly digest',
text: '{{summary}}',
outputVariable: 'digest_email',
},
},
],
})

The definition includes a name, a steps array, and an optional evals array of inline eval suites. ensure converges inline eval suites with the Flow. For more information, see Colocate evals with a flow.

Each step accepts only the following fields:

  • type: the step type.
  • name: the step’s stable identity across converges.
  • order: the step position. It defaults to the step’s 1-based position in the array.
  • enabled: whether the step runs. It defaults to true.
  • when: a condition that skips the step when it is false. For more information, see Skip a single step with when.
  • config: the step type’s settings.

defineFlow throws an error for any other field, including a step id or a top-level description. Runtype matches steps by name. If you rename a step, the change appears as a removed step and an added step, and the step’s previous results don’t carry over to the renamed step.

Renaming a definition does not rename the Flow. ensure creates a Flow under the renamed name and leaves the old Flow in place. It never deletes a Flow. Treat the name as the stable identity. If several Flows share a name, ensure and pull act on the oldest one. Rename or delete the duplicates before you manage the Flow as code.

Reference saved Tools by name

Built-in, platform, and MCP Tools have portable IDs that you can hardcode. Saved Tools receive an ID for each account and environment. These IDs start with tool_ and cannot appear in a portable definition. Reference a saved Tool by its exact name. Use the tool:TOOL_NAME form in a prompt or transform-data step’s tools.toolIds, and in a tool-call step’s toolId.

Define a Flow that references a saved Tool by name:

TypeScript SDK
export const scrapeAndSummarize = defineFlow({
name: 'Scrape and summarize',
steps: [
{
type: 'tool-call',
name: 'Scrape',
config: {
toolId: 'tool:My Scraper',
parameters: { url: '{{target}}' },
outputVariable: 'page',
},
},
{
type: 'prompt',
name: 'Summarize',
config: {
model: 'claude-sonnet-5',
userPrompt: 'Summarize: {{page}}',
},
},
],
})

The text after tool: must match the Tool’s exact name. Runtype resolves the name to the saved Tool during each dispatch. If you rename the Tool, update the reference in the definition. ensure, including dryRun, validates each reference during convergence. A name that matches no saved Tool fails validation. A name that matches multiple Tools fails validation and identifies the matches.

When you use pull on a dashboard-built Flow, Runtype converts saved Tool IDs to tool:TOOL_NAME when possible. If the Tool was deleted or its name conflicts, pull leaves the raw ID and adds an entry to warnings.

Reference saved Agents and Flows by name

Use name references when a Flow points to another saved resource. Use these forms:

  • agent:AGENT_NAME: Use this form for an execute-agent step’s agentId or a saved-subagent runtime Tool’s config.agentId.
  • flow:FLOW_NAME: Use this form for a flow-as-tool runtime Tool’s config.flowId.

Define a Flow that references a saved Agent and Flow by name:

TypeScript SDK
export const researchPipeline = defineFlow({
name: 'Research pipeline',
steps: [
{
type: 'execute-agent',
name: 'Research',
config: {
agentId: 'agent:Researcher',
message: 'Research: {{topic}}',
},
},
{
type: 'prompt',
name: 'Enrich and write',
config: {
model: 'claude-sonnet-5',
userPrompt: 'Write it up: {{agent_response}}',
tools: {
runtimeTools: [
{
toolType: 'flow',
name: 'enrich',
description: 'Enrich a record',
parametersSchema: {},
config: { flowId: 'flow:Enrich Lead' },
},
],
},
},
},
],
})

Runtype resolves these names during each dispatch. ensure, including dryRun, rejects a name that matches no saved Agent or Flow or matches more than one. When you use pull, Runtype converts raw IDs to name forms when possible and adds a warnings entry for each reference that it cannot convert.

Converge at deploy time

Configure the SDK and converge the Flow definition:

TypeScript SDK
import { Runtype } from '@runtypelabs/sdk'
Runtype.configure({ apiKey: process.env.RUNTYPE_API_KEY })
const result = await Runtype.flows.ensure(onboardingDigest)

The result contains result, flowId, versionId, and contentHash. result is unchanged, created, or updated. versionId can be null. When the definition declares inline eval suites, the result also contains evals with one outcome for each suite.

ensure first checks whether the saved Flow already matches, and sends the full definition only when needed. When the definition changes, ensure writes the saved Flow and adds a version to version history. Runtype never changes existing versions, so an overwritten dashboard edit remains available in version history.

Publish a version

By default, ensure writes the saved Flow and a draft version. To make the version that ensure creates the published version, pass release: 'publish':

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

If the content is unchanged but the current draft isn’t published, release: 'publish' publishes it and the result is still unchanged. You can run ensure once to write a draft, then run it again with release: 'publish' to publish the same content.

Label the version

Pass an optional version object to record which commit produced the version that ensure adds to version history:

TypeScript SDK
await Runtype.flows.ensure(onboardingDigest, {
version: { label: process.env.GIT_SHA, notes: 'Promoted from CI run 4821.' },
})

Both fields are optional. label holds up to 100 characters and notes up to 2,000. A typical label is a git SHA or release tag. Omit version to keep the default sdk-ensure label and the generated notes.

Detect drift in CI

Use a dry run to return a plan without writing:

TypeScript SDK
const plan = await Runtype.flows.ensure(onboardingDigest, { dryRun: true })

The plan contains result: 'plan', a changes value of none, create, or update, and the contentHash. When a remote Flow exists, it also contains remoteHash. changedKeys lists step changes such as steps.added.STEP_NAME, steps.removed.STEP_NAME, and steps.modified.STEP_NAME. Replace STEP_NAME with the affected step name.

A dry run plans the Flow only. It doesn’t plan or converge inline eval suites. To check eval suites for drift, see Manage evals as code.

Use expectNoChanges to fail the check when the plan reports a change:

TypeScript SDK
import { FlowDriftError } from '@runtypelabs/sdk'
try {
await Runtype.flows.ensure(onboardingDigest, { expectNoChanges: true })
} catch (error) {
if (error instanceof FlowDriftError) {
console.error(error.plan.changedKeys)
process.exit(1)
}
throw error
}

expectNoChanges runs a dry run and throws a FlowDriftError when the plan is not none. It never writes. The error’s plan property contains the plan.

To apply the definition only when the remote state matches the dry run, pass the plan’s remoteHash as expectedRemoteHash:

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

Handle dashboard edits

Runtype records whether each Flow write came from code or from somewhere else. After a dashboard edit or API write changes a Flow that code manages, the next ensure fails with a 409 conflict by default. The SDK raises a FlowEnsureConflictError with code: 'external_modification'.

The dashboard shows a Managed in code badge for a Flow that a code definition last converged. The badge appears in the Flow editor header and on the Flows list. When you click Save, click Publish with unsaved changes, or publish a version from Version History, the dashboard opens a confirmation dialog. Click Save anyway to continue.

Saving from the dashboard marks the Flow as edited outside code, so the next ensure detects the change and fails with a conflict. Publishing from the editor header with no unsaved edits only promotes the existing draft and does not open the dialog. Publishing a version from Version History always writes that version’s definition to the Flow, so it always asks first. To make a lasting change, update the Flow definition in your code.

Choose one of these approaches:

  • Repository wins: Run ensure again with onConflict: 'overwrite'. The saved Flow changes, and the dashboard edit remains in version history.
  • Dashboard wins: Run pull, review the definition as a git diff, then commit or revert the change.

Use this call to pull the saved definition and where its last change came from:

TypeScript SDK
const { definition, contentHash, lastModifiedSource, updatedAt } =
await Runtype.flows.pull('Onboarding Digest')

Bring an existing Flow under code management

To manage a Flow that you built in the dashboard, follow these steps:

  1. Run Runtype.flows.pull('FLOW_NAME'). Replace FLOW_NAME with the Flow’s exact name.
  2. Commit the returned definition in a defineFlow call.
  3. Run ensure. When the definition matches the saved Flow, the result is unchanged and the dashboard shows the Managed in code badge.

If the Flow references saved Tools, Agents, or Flows, pull converts their IDs to names. The converted definition differs from the saved Flow, so the first ensure of a dashboard-edited Flow returns a 409 conflict. Run that first ensure with onConflict: 'overwrite'.

To start from a generated snippet instead, open the Flow in the dashboard, click Launch flow, and select the Ensure Flow example. The example contains a defineFlow definition of the open Flow and a flows.ensure call. The snippet keeps the IDs of saved Tools, Agents, and Flows, which defineFlow rejects. If the Flow references saved resources, use pull instead.

Troubleshoot ensure errors

The following errors are the most common:

  • defineFlow throws before any request: the definition contains an unknown field, such as description or a step id, or an account-scoped ID. Remove the field or use a name reference.
  • 400: a step config is invalid, such as an unknown model or a missing required field. The response lists the validation errors. For more information, see Flow validation warnings.
  • 403: the API key is missing a permission. ensure needs FLOWS:WRITE to create or change a Flow. pull needs FLOWS:READ.
  • 404 from pull: no Flow has that name in the API key’s scope.
  • 409: the Flow was edited outside code (external_modification), or the Flow changed after your dry run (remote_changed). For more information, see Handle dashboard edits.

Call the ensure endpoint without the SDK

Use POST /v1/flows/ensure when you call the Runtype API without the TypeScript SDK. The request body accepts the following fields:

  • name: the Flow name. Required.
  • definition: the full definition with name and steps. definition.name must equal name.
  • contentHash: a lowercase, 64-character hexadecimal SHA-256 hash of the Flow’s steps.
  • dryRun: true to return a plan without writing.
  • onConflict: error or overwrite.
  • release: none or publish.
  • expectedRemoteHash: the remoteHash from a dry run.
  • version: an object with optional label and notes.

Each request needs definition, contentHash, or both. Send the full definition to converge a Flow:

cURL
curl -X POST https://api.runtype.com/v1/flows/ensure \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Onboarding Digest",
"definition": {
"name": "Onboarding Digest",
"steps": [
{
"type": "prompt",
"name": "Summarize signups",
"config": {
"model": "claude-sonnet-5",
"userPrompt": "Summarize this week'\''s signups: {{signups}}",
"outputVariable": "summary"
}
}
]
},
"version": { "label": "a1b2c3d" }
}'

Replace YOUR_API_KEY with your Runtype API key.

To skip sending an unchanged definition, follow this sequence:

  1. Send a probe with { "name": "Onboarding Digest", "contentHash": "YOUR_CONTENT_HASH" }. Replace YOUR_CONTENT_HASH with the contentHash from the last response.
  2. If the response is { "result": "definitionRequired" }, resend the request with the full definition and omit contentHash. A matching hash returns { "result": "unchanged" } with 200.
  3. Store the contentHash from each response for later probes. The server computes the hash over each submitted definition.
  4. Handle 409 conflicts for external_modification and remote_changed. A full request with a contentHash that differs from the server’s hash returns 422 with content_hash_mismatch. Omit contentHash from full requests to avoid this error.

The server trims version.label and version.notes and treats a blank value as omitted. For the length limits, see Label the version.

GET /v1/flows/pull?name=Onboarding%20Digest returns the saved definition, contentHash, lastModifiedSource, updatedAt, and versionId.

Next steps

Continue with these guides: