Overview
step.invoke spawns a child workflow run and suspends the parent step until the child completes. When the child finishes, the parent resumes with the child's return value. The parent's step result is durably memoized — on retry, the cached child result is returned without re-spawning.
Unlike enqueueing a job and polling, step.invoke gives you a type-safe return value and propagates errors — the parent step throws if the child fails, times out, or is cancelled.
API shape
const { result, runId } = await step.invoke<{ score: number }>("enrich-contact", {
workflow: { id: "contact-enrichment" }, // target workflow id (must be event-triggered)
data: { contactId: "c_123" }, // trigger data sent as the event payload
timeout: "10m", // REQUIRED — no infinite default. Max 24h.
});// Full type signature:
invoke<R = unknown>(
id: string,
opts: {
workflow: { id: string };
data: unknown;
timeout: string; // REQUIRED. e.g. "5m", "1h", "24h". TypeScript build fails without it.
}
): Promise<{ result: R; runId: string }>Return value
step.invoke returns { result, runId }:
| Field | Type | Description |
|---|---|---|
result | R | The child workflow's return value, typed via the <R> generic |
runId | string | The child run's UUID — useful for debugging, linking logs, and dashboard navigation |
The runId is included because observability matters: when a parent invokes dozens of children, you need to link parent and child spans without scraping logs.
Error model
step.invoke throws one of three errors when the child doesn't complete successfully:
| Error class | When it fires | Import |
|---|---|---|
WorkflowFailedError | Child workflow threw or reached terminal failure | @rotor/sdk/errors |
WorkflowTimeoutError | Parent-side timeout elapsed (child continues running) | @rotor/sdk/errors |
WorkflowCancelledError | Child was cancelled mid-flight (user cancel, future cancelOn match) | @rotor/sdk/errors |
import {
WorkflowFailedError,
WorkflowTimeoutError,
WorkflowCancelledError,
} from "@rotor/sdk/errors";
const { result } = await step.invoke("run-analysis", {
workflow: { id: "analysis-workflow" },
data: { reportId },
timeout: "30m",
}).catch((err) => {
if (err.name === "WorkflowTimeoutError") {
// Child is still running independently — record the runId for follow-up
return { result: null, runId: "unknown" };
}
if (err.name === "WorkflowCancelledError") {
// Child was explicitly cancelled — compensate
await step.run("record-cancellation", () => recordCancellation(reportId));
throw err;
}
// WorkflowFailedError or unexpected — rethrow
throw err;
});Cancellation matrix
| Direction | What happens | Error surfaced |
|---|---|---|
| Parent cancelled → child | cancelRun(parentId) recursively cancels all in-flight children via parent_run_id scan | N/A — parent itself was cancelled |
| Child cancelled → parent step | Child reaches cancelled terminal status; parent's pending step.invoke throws | WorkflowCancelledError |
| Parent-side timeout fires | workflow:invoke-timeout marks parent step failed; child keeps running independently | WorkflowTimeoutError |
WorkflowTimeoutError does NOT cancel the child. The child run continues
executing in the background. The timeout means "I'm done waiting for you" — it
is a parent concern, not a child concern. This is intentional and matches the
"wait for at most N minutes, but don't waste the work already done" semantics.
Record the runId so you can track or cancel the child separately if needed.
Why timeout is required
Inngest's blocks-forever default is a billing bug pretending to be a UX
feature. Forgetting to set a timeout on a blocking sub-workflow call means
your run — and your billing meter — runs until the child completes, which could
be days. Rotor surfaces this as a compile error: timeout is required at the
TypeScript type level. The maximum is 24 hours.
Recommended default: "5m" — matches the step.run cap and is long enough for
most enrichment/analysis sub-agents. Increase only when you have a concrete
SLA reason.
Memoization
On parent retry, the cached child result is returned without re-spawning:
- The serve adapter hashes
"stepId:counter"to produce a stable step key. - If
completedSteps[hash]is already populated,step.invokereturns the cached{ result, runId }immediately. - No duplicate child runs are created — the child workflow is spawned exactly once per unique step ID.
If the parent timed out on the previous attempt, the cached WorkflowTimeoutError is re-thrown without spawning a new child. Catch WorkflowTimeoutError inside your workflow function if you want to break out of this retry loop.
Common patterns
Sub-agent delegation
Fan out to a specialist workflow and collect structured results:
export const analysisOrchestrator = createFunction(
{ id: "analysis-orchestrator", trigger: { event: "analysis.requested" }, retries: 2 },
async ({ event, step }) => {
// Delegate to a contact enrichment specialist
const { result: enriched } = await step.invoke("enrich", {
workflow: { id: "contact-enricher" },
data: { contactId: event.data.contactId },
timeout: "5m",
});
// Delegate to a scoring model
const { result: score } = await step.invoke("score", {
workflow: { id: "lead-scorer" },
data: { enriched },
timeout: "2m",
});
return { contactId: event.data.contactId, score: score.value };
}
);Pipeline composition
Chain workflows where each stage's output feeds the next:
const { result: stage1 } = await step.invoke("stage-1", {
workflow: { id: "data-cleaner" },
data: rawPayload,
timeout: "10m",
});
const { result: stage2 } = await step.invoke("stage-2", {
workflow: { id: "data-transformer" },
data: stage1,
timeout: "10m",
});
const { result: final } = await step.invoke("stage-3", {
workflow: { id: "data-loader" },
data: stage2,
timeout: "5m",
});Fan-out with Promise.all
Invoke multiple children in parallel and merge results:
const [{ result: a }, { result: b }, { result: c }] = await Promise.all([
step.invoke("scorer-a", { workflow: { id: "scorer-a" }, data: contact, timeout: "3m" }),
step.invoke("scorer-b", { workflow: { id: "scorer-b" }, data: contact, timeout: "3m" }),
step.invoke("scorer-c", { workflow: { id: "scorer-c" }, data: contact, timeout: "3m" }),
]);
return { consensus: average(a.score, b.score, c.score) };Limits
| Constraint | Value |
|---|---|
Maximum timeout | 24 hours |
| Target workflow trigger type | Must be event-triggered (not cron) |
| Recursion depth | No platform limit — user's responsibility to avoid infinite loops |
| Parallel invocations | No platform limit — use Promise.all freely |
Avoid invoking cron workflows. Cron workflows are not designed to receive
event data — they read from schedules, not event payloads. step.invoke will
fail immediately if the target workflow has trigger_type = 'cron'.