Workflow IR
The Intermediate Representation (IR) is a provider-agnostic, JSON-serializable description of a workflow. It is the single source of truth — the database stores the IR, and all code generation reads from it.
Why a Separate IR?
The canvas (XYFlow) stores spatial data: positions, selection state, UI metadata. The IR strips all of that away and keeps only what matters for execution. This separation means:
- The same IR can be compiled to Cloudflare, Inngest, or Temporal without changing the canvas.
- Validation runs against the IR, not against canvas state.
- The IR can be hand-authored or generated programmatically, independent of the UI.
WorkflowIR Type
interface WorkflowIR {
kind?: 'workflow' // Optional — defaults to 'workflow' if absent
metadata: WorkflowMetadata
nodes: WorkflowNode[]
edges: Edge[]
entryNodeId: NodeId // ID of the first node to execute
trigger?: TriggerConfig // Optional — how the workflow is invoked
}
interface WorkflowMetadata {
name: string
description?: string
version: number // Integer, auto-incremented on each save
createdAt: string // ISO timestamp
updatedAt: string // ISO timestamp
}
interface WorkflowNode {
id: NodeId // Unique within this workflow, e.g. "charge_card"
type: NodeType // Built-in: "step", "branch", etc. Custom: "stripe_charge"
name: string // Human label shown on the canvas
position: { x: number; y: number }
version: string // Node definition semver, e.g. "1.0.0"
provider: string // Provider this node targets, e.g. "cloudflare"
data: Record<string, unknown> // Config values from the config drawer
config?: StepConfig // Per-node retry / timeout overrides
}
interface StepConfig {
retries?: {
limit: number
delay: number | string // e.g. "10 seconds"
backoff?: 'constant' | 'linear' | 'exponential'
}
timeout?: number | string // Per-attempt timeout
}
interface Edge {
id: EdgeId
source: NodeId
target: NodeId
label?: string // Used on branch edges to identify the condition path
}Triggers
Triggers describe how a workflow run is initiated. If omitted, the workflow can only be started manually.
type TriggerConfig =
| { type: 'http'; path?: string; method?: 'GET' | 'POST' | 'PUT' | 'DELETE' }
| { type: 'cron'; expression: string } // UNIX cron expression
| { type: 'event'; eventType: string } // e.g. "user-signup"
| { type: 'manual' }HTTP Trigger Example
{
"trigger": {
"type": "http",
"path": "/webhooks/stripe",
"method": "POST"
}
}Cron Trigger Example
{
"trigger": {
"type": "cron",
"expression": "0 9 * * 1"
}
}Scripts vs Workflows
The kind discriminator on the IR distinguishes two artifact kinds. Both share the node graph and per-node generators; the difference is the wrapper and which nodes are valid.
type ArtifactIR = WorkflowIR | ScriptIR
interface ScriptIR extends Omit<WorkflowIR, 'kind' | 'trigger'> {
kind: 'script' // Required
trigger: HttpTriggerConfig // Required — scripts are HTTP-only
}The kind field on WorkflowIR is optional for back-compat: legacy IRs persisted before the discriminator existed continue to validate as workflows. ScriptIR requires kind: 'script' literally.
Script constraints
ScriptIR rejects nodes that need a durable runtime — scripts run inside async fetch(request, env) and have no instance lifecycle:
| Node | Workflow | Script |
|---|---|---|
step | ✅ | ✅ |
branch | ✅ | ✅ |
parallel | ✅ | ✅ |
race | ✅ | ✅ |
http_request | ✅ | ✅ |
try_catch | ✅ | ✅ |
loop | ✅ | ✅ |
break | ✅ | ✅ |
sub_workflow | ✅ | ✅ (fire-and-forget) |
sleep | ✅ | ❌ |
sleep_until | ✅ | ❌ |
wait_for_event | ✅ | ❌ |
validateScript rejects sleep/sleep_until/wait_for_event with a clear error. The web canvas surfaces the same errors at publish time when kind === 'script'.
Validation dispatch
import { validateArtifact } from '@awaitstep/ir'
const result = validateArtifact(ir) // dispatches on ir.kindvalidateArtifact reads the kind field and dispatches to either validateIR (workflow) or validateScript. Absence of kind is treated as 'workflow'.
Sub-workflow nodes in scripts
In script mode, sub_workflow is always fire-and-forget — there is no durable runtime to poll the child instance to completion. The generator emits a single const X = await env.X.create({ id, params }) returning the WorkflowInstance handle; callers can read .id or .status() from the response directly.
Expression System
Nodes can reference the output of upstream nodes using the {{nodeId.property}} expression syntax. Expressions are valid in any expression-typed config field and are resolved by the codegen pipeline at build time.
Syntax
{{nodeId.property.nestedProperty}}nodeId— theidof an upstreamWorkflowNodeproperty— a key in that node's output schema- Additional
.-separated segments for nested object access
Examples
{{fetch_user.email}}
{{charge_result.amount}}
{{get_orders.results.0.id}}How Expressions Are Resolved
The codegen pipeline performs a topological sort of the DAG and assigns each node a JavaScript variable name. At code generation time, {{nodeId.property}} becomes a direct JavaScript property access:
// Before resolution (in IR data field):
"to": "{{fetch_user.email}}"
// After resolution (in generated worker code):
const sendEmail_result = await step.do('sendEmail', async () => {
return sendEmail({ to: fetch_user_result.email })
})Validation
The IR validator checks that:
- Every expression references a node that exists in the workflow.
- The referenced node is topologically upstream of the current node (no forward references).
- Expressions do not reference the node itself.
Minimal IR Example
{
"metadata": {
"name": "Welcome Email",
"version": 1,
"createdAt": "2025-01-01T00:00:00Z",
"updatedAt": "2025-01-01T00:00:00Z"
},
"entryNodeId": "fetch_user",
"trigger": { "type": "manual" },
"nodes": [
{
"id": "fetch_user",
"type": "http_request",
"name": "Fetch User",
"position": { "x": 0, "y": 0 },
"version": "1.0.0",
"provider": "cloudflare",
"data": {
"method": "GET",
"url": "https://api.example.com/users/{{trigger.userId}}"
}
},
{
"id": "send_email",
"type": "resend_send_email",
"name": "Send Welcome Email",
"position": { "x": 0, "y": 200 },
"version": "1.0.0",
"provider": "cloudflare",
"data": {
"to": "{{fetch_user.body}}",
"subject": "Welcome!"
}
}
],
"edges": [{ "id": "e1", "source": "fetch_user", "target": "send_email" }]
}