Skip to content
useEventStack 0.3.0 is live.Read the quickstart

Build

React to events with deterministic step pipelines

Define workflows that trigger on event types, execute WASM custom actions in sandboxed runtimes, dispatch webhooks, write projections, and emit derived events.

What is a workflow

A workflow is a deterministic state machine triggered by a specific event type. When an event arrives that matches the workflow's trigger_event_type, the engine sequentially executes the workflow's defined steps.

Workflow fields:

  • id — unique UUID
  • name — human-readable name (non-empty, required)
  • trigger_event — the event type that activates this workflow
  • environmentproduction or sandbox
  • statusenabled or disabled
  • steps — ordered array of step definitions

Step types

Three step types are accepted. Anything else is rejected at write time with unsupported step type.

TypeRequired fieldsDescriptionSuppressed during replay?
custom_actionaction (the action's name)Execute one of your approved WebAssembly modules in the sandboxed runtime. On success the worker writes a projection row, a derived event and a succeeded reaction row in one transaction.No — the logic runs
webhookurl (must be https://)Outbound HTTP POST with retry. Private and link-local destinations are refused.Yes
emit_eventevent_typeEmit a derived event back to the stream, carrying causation_id and an incremented trace_depth.No — internal

Replay suppression is carried on the event's replay_skip_side_effects metadata and read by the worker, so replaying an event re-runs your logic without re-firing webhooks.

There is no projection_write step type. Projections are written by the worker as a result of a custom_action step, from the module's returned output.

Workflow CRUD

Manage workflows via the API or dashboard:

MethodRoute
GET, POST/organizations/:org_id/workflows
GET, PUT, DELETE/organizations/:org_id/workflows/:id
POST/organizations/:org_id/workflows/:id/enable
POST/organizations/:org_id/workflows/:id/disable

Workflow writes require developer access and validate name, trigger event, environment, status, and step configuration.

Custom WASM actions

Custom actions are compiled WebAssembly modules executed in a sandboxed Wasmtime runtime. The guest ABI is raw core WebAssembly over linear memory — not WASI and not the component model — so the module must export memory and execute(ptr: i32, len: i32) -> i32. Action kits that generate the correct exports for you:

  • Rust@useeventstack/wasm-action-rust, cargo build --target wasm32-unknown-unknown --release
  • AssemblyScript@useeventstack/wasm-action-assemblyscript, the asc compiler
  • TinyGo@useeventstack/wasm-action-go

Any language that can emit core WebAssembly works, provided it implements that ABI. WASI imports are not available.

Upload

POST /organizations/:org_id/custom-actions
Content-Type: multipart/form-data
Fields: name, description (optional), permissions (optional), file (WASM binary)

The binary is compiled by Wasmtime at upload time, so a non-WebAssembly or empty upload is rejected with 400.

permissions declares which secrets the module is allowed to read, as a JSON array or a comma/newline separated list. The only accepted form is read_secret:<KEY>, at most 25 entries. A secret you store but do not declare is never injected — the declaration is what an approver consents to, so a module cannot widen its own access after review.

Runtime constraints

  • Fuel: 50,000,000 instruction units. This is an instruction budget, not a clock: there is no wall-clock or epoch deadline, so runaway code is stopped by fuel exhaustion. In practice an infinite loop terminates in well under a second.
  • Stack: 512 KB maximum
  • Memory: 10 MB reservation with a 2 MB guard page
  • Output: 256 KB maximum returned JSON
  • Filesystem: no host access
  • Networking: no socket access

Secrets

Secrets are injected at runtime, never baked into binaries. Values are write-only over the API — the list endpoint returns key names only — and are encrypted at rest with AES-256-GCM:

POST /organizations/:org_id/custom-actions/:id/secrets
{ "key": "STRIPE_KEY", "value": "sk_live_..." }

GET  /organizations/:org_id/custom-actions/:id/secrets  (keys only)
DELETE /organizations/:org_id/custom-actions/:id/secrets/:key

Action output contract

The runtime hands your module one JSON envelope and reads one JSON document back. Both sides are described here, and the reference implementation in examples/02-custom-actions/order-risk-scorer uses every part of it.

Input envelope

{
  "payload":     { /* the triggering event's payload, verbatim */ },
  "secrets":     { "RISK_THRESHOLD": "55" },   // only the keys you declared
  "permissions": ["read_secret:RISK_THRESHOLD"],
  "action":      "order-risk-scorer"
}

secrets contains only the keys covered by an approved read_secret:<KEY> permission that also exist in your stored secrets. Undeclared keys are absent, not empty.

Output document

Return a NUL-terminated UTF-8 JSON document. Returning a null or zero pointer fails the step: it is how a module signals it rejected the payload it was given. Two top-level keys are reserved and read by the worker; everything else is your data.

{
  "projection": {
    "name":        "order_risk",       // lowercase, digits, _ - . (max 120)
    "entity_type": "order",            // same charset (max 64)
    "entity_id":   "order-2001",       // any text or number (max 200)
    "title":       "Order order-2001", // max 200
    "status":      "held_for_review",  // max 40
    "data":        { "score": 72 }     // stored as the projection payload
  },
  "derived_event": { "type": "orders.risk_scored", "payload": { "score": 72 } },
  "score": 72
}
FieldOmitted means
projection.nameworkflow.<workflow name>.<action name>
projection.entity_type + entity_idworkflow_result keyed by the event id — a new row per event, i.e. an append-only log
projection.title<event type> → <action> output
projection.statusthe top-level status string if present, otherwise completed
projection.datayour whole output minus the two reserved keys
derived_event<action_name>.scored carrying the output

Naming an entity is what turns a projection into a read model: the write becomes an upsert on (organization, project, projection name, entity type, entity id), so re-processing the same order updates one row instead of appending another. Supplying only one half of the key is rejected rather than quietly defaulted.

"derived_event": false suppresses the derived event, for an action that only maintains state.

A reserved key of the wrong shape fails the step with a message naming the field — visible on the event's reactions table — rather than being ignored. A misspelled key is treated as ordinary data.

Governance lifecycle

WASM actions follow a governance lifecycle:

  1. Upload — developer submits compiled WASM binary
  2. Pending Review — action awaits admin approval (production only)
  3. Approved / Rejected — admin reviews via POST .../review with status: "approved" or "rejected"
  4. Executable — approved actions run in workflow steps

Approval is required in every environment, including sandbox. A workflow step naming an action that is not approved fails closed and the event lands in failed events for triage.

Sandbox constraints

  • Events are strictly partitioned by environment: an event written to sandbox is not visible in production, and vice versa
  • Sandbox state can be reset with POST /sandbox/reset, so it is safe for experimentation
  • Custom actions still require admin approval — sandbox does not bypass review
  • Sandbox access is a plan entitlement; on the Starter plan it returns 402 sandbox_disabled

SDK usage

// TypeScript
const workflows = await client.workflows.list();
await client.workflows.create({
  name: 'Notify on deploy',
  // Your own event type. There are no built-in event names.
  trigger_event: 'acme.deployment_completed',
  environment: 'production',
  status: 'enabled',
  steps: [{ type: 'webhook', url: 'https://hooks.example.com/deploys' }],
});
await client.workflows.toggle('workflow-id', 'enable');

// Simulate a custom action without approving it
const result = await client.customActions.simulate('action-id', { test: true });