Stevora

State Management

How workflow state flows between steps

State Management

Every workflow run carries two data objects: input (immutable, set at creation) and state (mutable, built up as steps execute). Together, these drive the data flow between steps.

Workflow Input

When you create a workflow run, you pass an input object. This is stored on the run and never modified — every step can read it, but no step can change it.

POST /api/v1/workflow-runs
{
  "definitionId": "def_abc123",
  "input": {
    "leadEmail": "jane@example.com",
    "leadName": "Jane Smith",
    "campaignId": "camp_456"
  }
}

The input is accessible throughout the workflow via ctx.workflowInput in step handlers and {{input.fieldName}} in templates.

Workflow State

State is a mutable key-value object that accumulates results as steps complete. It starts as an empty object {} and grows as each step merges its stateUpdates into it.

The merge happens in handleStepResult inside the execution engine:

const newState = {
  ...((run.state as Record<string, unknown>) ?? {}),
  ...(result.stateUpdates ?? {}),
};

This is a shallow merge — each step can add new keys or overwrite existing ones.

How Steps Produce State Updates

Every step handler returns a StepResult. The stateUpdates field is merged into the workflow state when the step completes:

interface StepResult {
  status: 'completed' | 'failed' | 'waiting';
  output?: Record<string, unknown>;
  stateUpdates?: Record<string, unknown>;
  error?: { message: string; code?: string; details?: unknown };
  waitUntil?: Date;
  signalType?: string;
}

For example, an LLM step that drafts an email would return:

{
  status: 'completed',
  output: { subject: '...', body: '...' },
  stateUpdates: { subject: '...', body: '...' },
}

After this step, state.subject and state.body are available to all subsequent steps.

State Flow Example

Consider a three-step workflow: enrich a lead, draft an email, then send it.

Step 1: enrich-lead
  input:  { leadEmail: "jane@example.com" }
  state before: {}
  stateUpdates: { leadName: "Jane Smith", company: "Acme Inc" }
  state after:  { leadName: "Jane Smith", company: "Acme Inc" }

Step 2: draft-email (LLM step)
  input:  { leadEmail: "jane@example.com" }
  state before: { leadName: "Jane Smith", company: "Acme Inc" }
  stateUpdates: { emailDraft: "Hi Jane, ..." }
  state after:  { leadName: "Jane Smith", company: "Acme Inc", emailDraft: "Hi Jane, ..." }

Step 3: send-email (Task step)
  input:  { leadEmail: "jane@example.com" }
  state before: { leadName: "Jane Smith", company: "Acme Inc", emailDraft: "Hi Jane, ..." }
  stateUpdates: { sentAt: "2025-06-01T12:00:00Z", messageId: "msg_789" }
  state after:  { leadName: "Jane Smith", company: "Acme Inc", emailDraft: "Hi Jane, ...",
                  sentAt: "2025-06-01T12:00:00Z", messageId: "msg_789" }

Template Variables

LLM step prompts support template variables with {{...}} syntax. The engine resolves these against the workflow input and state before sending messages to the model.

function resolveTemplate(template: string, ctx: StepContext): string {
  return template.replace(/\{\{([\w.]+)\}\}/g, (_match, path: string) => {
    const parts = path.split('.');
    const root = parts[0];
    const rest = parts.slice(1);

    let value: unknown;
    if (root === 'state') {
      value = ctx.workflowState;
    } else if (root === 'input') {
      value = ctx.workflowInput;
    } else {
      return `{{${path}}}`; // leave unresolved
    }

    for (const part of rest) {
      if (value && typeof value === 'object') {
        value = (value as Record<string, unknown>)[part];
      } else {
        return `{{${path}}}`;
      }
    }

    return value !== undefined && value !== null ? String(value) : '';
  });
}

Two root namespaces are available:

PrefixSourceExample
{{input.x}}The immutable input passed at run creation{{input.leadEmail}}
{{state.x}}The accumulated workflow state{{state.leadName}}

Nested access is supported with dot notation. If a path cannot be resolved, the template variable is left in place unchanged. If the value is null or undefined, it resolves to an empty string.

Template Example

{
  "type": "llm",
  "name": "draft-outreach",
  "model": "gpt-4o",
  "systemPrompt": "You are a sales assistant for {{input.companyName}}.",
  "messages": [
    {
      "role": "user",
      "content": "Write a personalized email to {{state.leadName}} at {{state.company}}. Their email is {{input.leadEmail}}."
    }
  ]
}

Condition Branching and Internal State

Condition steps write internal keys to the state to control execution flow:

stateUpdates: {
  [`__condition_${ctx.stepName}`]: branchResult,
  __nextStep: branchResult ? step.trueStep : step.falseStep,
}

The __nextStep key tells the engine which step to execute next. It is consumed and removed from the persisted state after being read, so it does not leak into subsequent steps.

Workflow Output

When a workflow completes, its final state becomes the workflow output:

await tx.workflowRun.updateMany({
  where: { id: run.id },
  data: {
    status: 'COMPLETED',
    completedAt: now,
    output: (run.state ?? {}) as Prisma.JsonObject,
    version: { increment: 1 },
  },
});

This means every stateUpdates value accumulated across all steps is available in the completed run's output field.