WASM Workflows
Run Blazen workflows entirely in WebAssembly
Overview
Blazen workflows run natively inside the WASM module. Steps, events, and the context store all execute locally — no server round-trips for orchestration. Step handlers are plain JavaScript async functions that the WASM runtime calls back into.
Creating a workflow
import init, { Workflow, ChatMessage, CompletionModel } from '@blazen/sdk';
await init();
const wf = new Workflow('summarizer');
wf.addStep('fetch_text', ['blazen::StartEvent'], async (event, ctx) => {
ctx.set('source', event.url);
const text = await fetch(event.url).then(r => r.text());
return { type: 'SummarizeEvent', text };
});
wf.addStep('summarize', ['SummarizeEvent'], async (event, ctx) => {
// The WASM SDK reads OPENROUTER_API_KEY from the runtime environment.
// Factory methods do not accept runtime keys -- configure the env var
// at deploy time (see the Edge Deployment guide for strategies).
const model = CompletionModel.openrouter();
const response = await model.complete([
ChatMessage.system('Summarize the following text in 2-3 sentences.'),
ChatMessage.user(event.text),
]);
return {
type: 'blazen::StopEvent',
result: { summary: response.content },
};
});
const result = await wf.run({ url: 'https://example.com/article' });
console.log(result.data.summary);
Event-driven architecture
Events are plain objects with a type field. The WASM event router dispatches each event to the step whose eventTypes list includes that type — identical to the Rust and Node.js SDKs.
Built-in event types:
"blazen::StartEvent"— emitted when the workflow begins. The object passed towf.run()is merged onto it."blazen::StopEvent"— returning this from a step ends the workflow. Attach your output to theresultproperty.
Context
The ctx parameter is a WasmContext instance — a real object with methods for sharing state, emitting events, and inspecting the current run. All methods are synchronous (unlike the Node.js SDK, which is async).
Storing and retrieving values
wf.addStep('store', ['blazen::StartEvent'], async (event, ctx) => {
ctx.set('user', event.name);
ctx.set('count', 42);
ctx.set('tags', ['intro', 'demo']);
return { type: 'NextEvent' };
});
wf.addStep('read', ['NextEvent'], async (event, ctx) => {
const user = ctx.get('user'); // 'Alice'
const count = ctx.get('count'); // 42
const missing = ctx.get('nope'); // null
return { type: 'blazen::StopEvent', result: { user, count } };
});
ctx.set(key, value) auto-detects Uint8Array values and stores them as binary. Everything else is stored as-is. ctx.get(key) returns the original value, or null if the key is missing.
Values can be any StateValue:
type StateValue = string | number | boolean | null | Uint8Array | StateValue[] | { [key: string]: StateValue };
State vs Session namespaces
Context exposes two explicit namespaces alongside the legacy smart-routing ctx.set / ctx.get:
ctx.state— persistable values. Routes through the same dispatch asctx.set, so anything that survives theStateValueround-trip belongs here.ctx.session— live in-process JS references. Identity IS preserved within a run.
wf.addStep('share_live', ['blazen::StartEvent'], (event, ctx) => {
ctx.state.set('counter', 5);
const liveObj = { tag: 'live', count: 0 };
ctx.session.set('shared', liveObj);
console.log(ctx.session.get('shared') === liveObj); // true -- identity preserved
liveObj.count += 1;
console.log(ctx.session.get('shared').count); // 1 -- same object reference
return { type: 'blazen::StopEvent', result: {} };
});
Because the WASM runtime is single-threaded, session values are stored as raw JsValue in a dedicated map. This is a key differentiator from the Node bindings, where session values are routed through serde_json::Value and identity is NOT preserved (due to napi-rs threading constraints).
Session values are deliberately excluded from any snapshot — WASM does not currently support cross-process snapshot/resume of session entries. Use ctx.state for anything that needs to survive a snapshot, and ctx.session for live handles (open sockets, in-flight caches, framework instances) that only make sense within the current run.
All namespace methods are synchronous, just like the rest of the WASM Context API.
Binary data
For explicit binary storage, use ctx.setBytes() and ctx.getBytes():
wf.addStep('binary', ['blazen::StartEvent'], async (event, ctx) => {
const data = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]);
// Either approach works for storing binary:
ctx.set('via_set', data); // auto-detected as binary
ctx.setBytes('via_explicit', data); // explicit binary storage
// Both return Uint8Array on read:
const a = ctx.get('via_set'); // Uint8Array
const b = ctx.getBytes('via_explicit'); // Uint8Array | null
return { type: 'blazen::StopEvent', result: { ok: true } };
});
Emitting events
ctx.sendEvent(event) queues an event into the workflow’s event loop, allowing a step to trigger other steps mid-execution:
wf.addStep('kickoff', ['blazen::StartEvent'], async (event, ctx) => {
ctx.sendEvent({ type: 'SideTask', payload: 'extra work' });
return { type: 'MainTask' };
});
Run ID
Each workflow run is assigned a unique UUID v4. Access it with ctx.runId():
wf.addStep('log', ['blazen::StartEvent'], async (event, ctx) => {
console.log('Run:', ctx.runId());
return { type: 'blazen::StopEvent', result: {} };
});
Workflow name
The workflow name is available as a getter property:
console.log(ctx.workflowName); // 'summarizer'
Method summary
| Method | Return type | Description |
|---|---|---|
ctx.set(key, value) | void | Store a value; auto-detects Uint8Array for binary |
ctx.get(key) | StateValue | null | Retrieve a value, or null if missing |
ctx.setBytes(key, data) | void | Explicitly store binary data (Uint8Array) |
ctx.getBytes(key) | Uint8Array | null | Retrieve binary data |
ctx.sendEvent(event) | void | Queue an event into the workflow event loop |
ctx.writeEventToStream(event) | void | No-op in WASM (present for API compatibility) |
ctx.runId() | string | Unique UUID v4 for the current run |
ctx.workflowName | string | Getter property for the workflow name |
BlazenState
For structured state that mixes serializable fields with non-serializable ones (database connections, caches, handles), the WASM SDK supports a BlazenState protocol. Any plain object with the __blazen_state__: true marker is automatically decomposed by ctx.set() and reconstructed by ctx.get() — no explicit saveTo()/loadFrom() calls needed.
The protocol reads metadata from the object’s constructor via a static meta property:
class SessionState {
url = '';
token = '';
conn = null; // non-serializable -- will be recreated
requestCount = 0;
static meta = {
transient: ['conn'], // excluded from storage
storeBy: {}, // custom FieldStore per field (optional)
restore: 'reconnect', // method name called after reconstruction
};
reconnect() {
if (this.url && this.token) {
this.conn = createConnection(this.url, this.token);
}
}
}
Mark instances with __blazen_state__: true before storing:
wf.addStep('init', ['blazen::StartEvent'], async (event, ctx) => {
const state = new SessionState();
state.url = event.url;
state.token = event.token;
state.__blazen_state__ = true;
ctx.set('session', state); // auto-decomposes per field
return { type: 'ProcessEvent' };
});
wf.addStep('process', ['ProcessEvent'], async (event, ctx) => {
const state = ctx.get('session'); // auto-reconstructs, calls reconnect()
console.log(state.url); // preserved
console.log(state.conn); // recreated by reconnect()
return { type: 'blazen::StopEvent', result: { requests: state.requestCount } };
});
How it works:
ctx.set(key, state)— iteratesObject.keys(state), skips__blazen_state__and any field listed inmeta.transient, then stores each remaining field at{key}.{fieldName}. Fields with a customFieldStoreinmeta.storeByare persisted viastore.save(fieldKey, value, ctx)instead of the default path. A metadata entry at{key}.__blazen_meta__records the field list, class name, and configuration.ctx.get(key)— checks for a{key}.__blazen_meta__entry. If found, it loads each field individually (or via the customFieldStore.load()), reassembles the object, sets the__blazen_state__marker, and calls therestoremethod (if specified in the metadata).
All operations are synchronous — the FieldStore.save() and FieldStore.load() callbacks in WASM must return values directly (no Promises).
The storeBy option lets you route specific fields through a custom persistence strategy:
const externalCache = {
save(key, value, ctx) { localStorage.setItem(key, JSON.stringify(value)); },
load(key, ctx) { return JSON.parse(localStorage.getItem(key) ?? 'null'); },
};
class AppState {
preferences = {};
activeTab = 'home';
static meta = {
storeBy: { preferences: externalCache },
};
}
Streaming events
In the WASM SDK, ctx.writeEventToStream() is a no-op — it exists for API compatibility with the Node.js and Rust SDKs but does not emit events to an external stream. You can still use Workflow.runStreaming() to receive events routed via ctx.sendEvent():
wf.addStep('process', ['blazen::StartEvent'], async (event, ctx) => {
for (let i = 0; i < 5; i++) {
ctx.sendEvent({ type: 'Progress', step: i });
}
return { type: 'blazen::StopEvent', result: { done: true } };
});
// Callback-style streaming: invoke the callback for every event the
// workflow emits. The promise resolves with the final StopEvent payload.
const result = await wf.runStreaming({}, (event) => {
console.log(event.eventType, event);
});
Workflow.runStreaming(input, callback) is the callback-driven counterpart to the handler-based API below. The callback fires synchronously from the WASM event loop for each emitted event (start, custom, stop, input requests, errors). Use it when you do not need fine-grained control — only observation.
Handler-based execution
For workflows that need pause/resume, snapshotting, human-in-the-loop input, or external cancellation, use Workflow.runWithHandler(input). It returns a WorkflowHandler immediately while the workflow runs in the background:
const handler = await wf.runWithHandler({ url: 'https://example.com' });
// Stream events through the handler instead of waiting for a final result.
await handler.streamEvents((ev) => {
console.log(ev.eventType);
});
Human-in-the-loop input
Steps can request input from the host by emitting an InputRequested event. The handler’s respondToInput(requestId, response) method delivers the answer back to the paused step:
const handler = await wf.runWithHandler(input);
await handler.streamEvents((ev) => {
if (ev.eventType === 'InputRequested') {
// Reply to the specific request by id. The step's awaiter resumes
// with the supplied response value.
handler.respondToInput(ev.requestId, 'answer');
}
});
Snapshot, pause, and resume
handler.snapshot() captures the current workflow state (queued events, context state, step progress) without halting execution. To pause and later resume in the same process, call handler.resumeInPlace() after the workflow has paused:
const handler = await wf.runWithHandler(input);
// Capture state mid-run without affecting execution.
const snap = await handler.snapshot();
// ...later, after the workflow has paused itself...
await handler.resumeInPlace();
To cancel a run outright, call handler.abort(). Any in-flight step handlers will see the cancellation propagate as the next event-loop tick.
WorkflowHandler method summary
| Method | Description |
|---|---|
handler.streamEvents(callback) | Callback-style event stream covering all emitted events |
handler.respondToInput(requestId, response) | Answer a HITL InputRequested event |
handler.snapshot() | Capture serializable workflow state without pausing |
handler.resumeInPlace() | Resume a paused workflow in the current process |
handler.abort() | Cancel the running workflow |
Session pause policy
By default, a workflow’s ctx.session map is cleared whenever the workflow pauses, since live JS references generally cannot survive a snapshot/resume boundary. If your steps store handles that remain valid across pauses (e.g. a long-lived database client owned by the host), opt into preservation with the session pause policy:
// On the builder before constructing the workflow:
const workflow = builder.setSessionPausePolicy('pause').build();
// Or directly on an already-built workflow:
wf.setSessionPausePolicy('continue');
Accepted values:
"continue"(default) — session-ref state is dropped on pause. Safe; matches snapshot semantics."pause"— session-ref state is preserved across pauses, surviving until the workflow resumes in the same process.
Both Workflow.setSessionPausePolicy(policy) and WorkflowBuilder.setSessionPausePolicy(policy) accept the same policy strings, so you can configure the behavior at whichever construction stage is convenient.
Resuming from a serialized snapshot
handler.snapshot() returns a structure that can be persisted (e.g. to KV, IndexedDB, or a Durable Object). To bring it back to life in a fresh process, rebuild the workflow with Workflow.fromBuilder(...) and call resumeWithSerializableRefs(snapshot, deserializers):
// Persist the snapshot somewhere durable.
const snap = await handler.snapshot();
await kv.put('wf-snap', JSON.stringify(snap));
// Later, in a new process or worker invocation:
const restored = await Workflow.fromBuilder(builder).resumeWithSerializableRefs(
snap,
{
// Map session-ref type names to their deserializers. Each callback
// receives the bytes that were captured at snapshot time and must
// return the live JS value to reinstall into ctx.session.
MyType: (bytes) => myDeserialize(bytes),
},
);
The deserializers map is keyed by the type tag recorded for each session entry at snapshot time. Any session-ref types you have registered as serializable must have a matching entry in the map — unknown types are skipped and logged.
Branching
Return an array of events to fan out to multiple steps:
wf.addStep('classify', ['blazen::StartEvent'], async (event, ctx) => {
return [
{ type: 'PositiveEvent', text: event.text },
{ type: 'NegativeEvent', text: event.text },
];
});
Timeouts
Set a maximum execution time with setTimeout():
wf.setTimeout(30); // 30 seconds
Next steps
- Add tool-calling agents to your workflows with the WASM Agent guide.
- Deploy workflows to the edge with the Edge Deployment guide.