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. Survivespause()/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:
| Namespace | Survives pause/resume | Use for |
|---|---|---|
ctx.state | yes | persistable values (JSON, bytes) |
ctx.session | no (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.