Context

Share state between workflow steps in Node.js

What is Context?

Context is a key-value store shared across all steps in a workflow run. Every step handler receives a ctx object as its second argument, giving each step access to the same shared state.

Context exposes two explicit namespaces alongside the legacy smart-routing shortcuts (ctx.set / ctx.get / ctx.setBytes / ctx.getBytes):

  • ctx.state — persistable values. Survives pause() / resume() and checkpoint stores.
  • ctx.session — in-process-only values. Excluded from snapshots. Use for request IDs, rate-limit counters, ephemeral caches, and anything that should not survive pause/resume.

The legacy ctx.set / ctx.get shortcuts still work and route into the state namespace under the hood.

StateValue Type

All context values are represented by the StateValue type:

type StateValue = string | number | boolean | null | Buffer | StateValue[] | { [key: string]: StateValue };

ctx.set() accepts any JSON-serializable subset of StateValue (everything except Buffer). For binary data, use the dedicated setBytes/getBytes methods described below.

ctx.get() returns Promise<StateValue | null> and now returns data for all StateValue variants, including arrays and nested objects. It no longer silently drops bytes or native data — if a key was stored via setBytes, get() will return the data as a Buffer.

Setting and Getting Values

wf.addStep("store_data", ["blazen::StartEvent"], async (event, ctx) => {
  await ctx.set("user_id", "user_123");
  await ctx.set("doc_count", 5);
  await ctx.set("tags", ["rust", "workflow"]);
  await ctx.set("config", { retries: 3, verbose: true });
  return { type: "NextEvent" };
});

wf.addStep("use_data", ["NextEvent"], async (event, ctx) => {
  const userId = await ctx.get("user_id");     // "user_123"
  const docCount = await ctx.get("doc_count"); // 5
  const tags = await ctx.get("tags");          // ["rust", "workflow"]
  const config = await ctx.get("config");      // { retries: 3, verbose: true }
  return { type: "blazen::StopEvent", result: { user: userId, docs: docCount } };
});

Important: ctx.set() and ctx.get() are async — always use await.

Run ID

Each workflow execution is assigned a unique run ID. Access it from the context:

const runId = await ctx.runId(); // Returns a UUID string

Binary Storage

While ctx.get() now returns data for all value types (including binary), you can still use ctx.setBytes() and ctx.getBytes() for explicit binary storage. These methods are useful when you want to make it clear that a value is raw binary data, or when you need to store data that should not be JSON-serialized. Binary data persists through pause/resume/checkpoint.

// Store raw binary data
const pixels = Buffer.from([0xff, 0x00, 0x00, 0xff]);
await ctx.setBytes("image-pixels", pixels);

// Retrieve it in another step
const data = await ctx.getBytes("image-pixels"); // Buffer | null

Manual Event Routing

Use ctx.sendEvent() to emit an event manually instead of returning one from the step handler:

await ctx.sendEvent({ type: "Continue" }); // Async
return null; // Don't return an event when using sendEvent

State vs Session

Context exposes two explicit namespaces that make your intent clear at the call site:

NamespaceSurvives pause/resumeUse for
ctx.stateyespersistable values (JSON, bytes)
ctx.sessionno (see pause policy)in-process-only values, request-scoped state
wf.addStep("setup", ["blazen::StartEvent"], async (event, ctx) => {
  // Persistable state -- survives pause/resume and checkpoints.
  await ctx.state.set("inputPath", "data.csv");
  await ctx.state.set("rowCount", 0);
  await ctx.state.setBytes("thumbnail", Buffer.from([0x89, 0x50, 0x4e, 0x47]));

  // In-process-only state -- excluded from snapshots.
  await ctx.session.set("reqId", "abc123");
  await ctx.session.set("rateLimitCount", 0);

  const hasReq = await ctx.session.has("reqId");
  const reqId = await ctx.session.get("reqId");
  await ctx.session.remove("rateLimitCount");

  return { type: "blazen::StopEvent", result: { ok: true } };
});

ctx.state routes through the same dispatch as ctx.set and exposes set / get / setBytes / getBytes. ctx.session exposes set / get / has / remove. The legacy ctx.set / ctx.get still work as smart-routing shortcuts and target the state namespace under the hood.

:::caution[JS object identity is NOT preserved on Node] Unlike the Python and WASM bindings, Node stores session values as serde_json::Value rather than as a live JS reference. The reason: napi-rs’s Reference<T> is !Send (its Drop must run on the v8 main thread), and tokio worker threads cannot safely hold live JS references — this is a documented architectural limitation.

In practice, await ctx.session.set("k", {name: "alice"}) followed by await ctx.session.get("k") returns a plain object equal to {name: "alice"}, not the same object. ctx.session is still functionally distinct from ctx.state — session values are excluded from snapshots, state values are not — but for true identity preservation of live JS objects across steps you must use the Python or WASM bindings. :::

Pause policy for ctx.session

Session entries are deliberately excluded from snapshots. When you call handler.pause(), the workflow’s session_pause_policy (default pickle_or_error; other policies: warn_drop, hard_error) governs what happens to them. The practical rule: put anything that must survive pause() / resume() in ctx.state, and everything else in ctx.session.

BlazenState

BlazenState is a base class for typed state objects that store each field individually in the workflow context. Instead of manually calling ctx.set() and ctx.get() for every piece of state, you define a class with typed fields and let Blazen handle serialization, storage tiers, and restoration automatically.

Defining a State Class

Extend BlazenState and declare a static meta property to control how fields are stored:

import { BlazenState, BlazenStateMeta, CallbackFieldStore } from "blazen";

class AgentState extends BlazenState {
  static meta: BlazenStateMeta = {
    // Fields that should not survive pause/resume snapshots
    transient: ["scratchpad"],

    // Per-field storage overrides
    storeBy: {
      embeddings: new CallbackFieldStore({
        saveFn: async (key, value, ctx) => {
          await ctx.setBytes(key, Buffer.from(JSON.stringify(value)));
        },
        loadFn: async (key, ctx) => {
          const buf = await ctx.getBytes(key);
          return buf ? JSON.parse(buf.toString()) : null;
        },
      }),
    },
  };

  conversationHistory: string[] = [];
  embeddings: number[][] = [];
  scratchpad: Map<string, string> = new Map();
  retryCount: number = 0;

  // Called after loadFrom() to recreate transient fields
  restore(): void {
    this.scratchpad = new Map();
  }
}

Saving and Loading State

Use saveTo() to persist the state into a workflow context under a given key, and the static loadFrom() to restore it:

wf.addStep("init", ["blazen::StartEvent"], async (event, ctx) => {
  const state = new AgentState();
  state.conversationHistory.push("Hello");
  state.retryCount = 1;

  await state.saveTo(ctx, "state");
  return { type: "ProcessEvent" };
});

wf.addStep("process", ["ProcessEvent"], async (event, ctx) => {
  const state = await AgentState.loadFrom<AgentState>(ctx, "state");

  console.log(state.conversationHistory); // ["Hello"]
  console.log(state.retryCount);          // 1

  state.conversationHistory.push("World");
  await state.saveTo(ctx, "state");

  return { type: "blazen::StopEvent", result: { history: state.conversationHistory } };
});

How Field Storage Works

Each field on the state object is stored individually in the context. This means you can assign different storage strategies to different fields using the storeBy record. Any field listed in storeBy uses its corresponding FieldStore implementation; all other (non-transient) fields use the default context set/get methods.

The FieldStore interface has two methods:

interface FieldStore {
  save(key: string, value: any, ctx: Context): Promise<void>;
  load(key: string, ctx: Context): Promise<any>;
}

CallbackFieldStore is a convenience class that constructs a FieldStore from a pair of callbacks:

new CallbackFieldStore({
  saveFn: async (key, value, ctx) => { /* custom save logic */ },
  loadFn: async (key, ctx) => { /* custom load logic */ },
});

Transient Fields

Fields listed in the transient array are excluded from saveTo() and will not be persisted. After loadFrom() restores the saved fields, it calls restore() on the instance, giving you a place to recreate transient state such as caches, open connections, or in-memory indexes. Transient fields do not survive pause/resume snapshots, but restore() ensures they are always initialized when the state is loaded.