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 to wf.run() is merged onto it.
  • "blazen::StopEvent" — returning this from a step ends the workflow. Attach your output to the result property.

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 as ctx.set, so anything that survives the StateValue round-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

MethodReturn typeDescription
ctx.set(key, value)voidStore a value; auto-detects Uint8Array for binary
ctx.get(key)StateValue | nullRetrieve a value, or null if missing
ctx.setBytes(key, data)voidExplicitly store binary data (Uint8Array)
ctx.getBytes(key)Uint8Array | nullRetrieve binary data
ctx.sendEvent(event)voidQueue an event into the workflow event loop
ctx.writeEventToStream(event)voidNo-op in WASM (present for API compatibility)
ctx.runId()stringUnique UUID v4 for the current run
ctx.workflowNamestringGetter 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) — iterates Object.keys(state), skips __blazen_state__ and any field listed in meta.transient, then stores each remaining field at {key}.{fieldName}. Fields with a custom FieldStore in meta.storeBy are persisted via store.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 custom FieldStore.load()), reassembles the object, sets the __blazen_state__ marker, and calls the restore method (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

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