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 }:

FieldTypeDescription
resultRThe child workflow's return value, typed via the <R> generic
runIdstringThe 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 classWhen it firesImport
WorkflowFailedErrorChild workflow threw or reached terminal failure@rotor/sdk/errors
WorkflowTimeoutErrorParent-side timeout elapsed (child continues running)@rotor/sdk/errors
WorkflowCancelledErrorChild 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

DirectionWhat happensError surfaced
Parent cancelled → childcancelRun(parentId) recursively cancels all in-flight children via parent_run_id scanN/A — parent itself was cancelled
Child cancelled → parent stepChild reaches cancelled terminal status; parent's pending step.invoke throwsWorkflowCancelledError
Parent-side timeout firesworkflow:invoke-timeout marks parent step failed; child keeps running independentlyWorkflowTimeoutError
Warning

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

Note

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:

  1. The serve adapter hashes "stepId:counter" to produce a stable step key.
  2. If completedSteps[hash] is already populated, step.invoke returns the cached { result, runId } immediately.
  3. 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

ConstraintValue
Maximum timeout24 hours
Target workflow trigger typeMust be event-triggered (not cron)
Recursion depthNo platform limit — user's responsibility to avoid infinite loops
Parallel invocationsNo platform limit — use Promise.all freely
Tip

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'.