@stateway/frontend-sdk — Reference
Installation
npm install @stateway/frontend-sdk
# or
yarn add @stateway/frontend-sdk
Supports browsers and SSR environments (Next.js, Nuxt, etc.). No runtime dependencies beyond the package itself.
StatewayClient
import { StatewayClient } from '@stateway/frontend-sdk';
const sw = new StatewayClient({
endpoint: 'wss://ws.stateway.io', // required
sessionToken: 'sws_live_...', // required
reconnect: true, // default: true
reconnectMaxDelay: 30000, // default: 30000ms
queueWhileDisconnected: false, // default: false
debug: false, // default: false
});
Constructor Options
| Option | Type | Default | Description |
|---|---|---|---|
endpoint | string | — | WebSocket server URL |
sessionToken | string | — | Session token from POST /v1/sessions |
reconnect | boolean | true | Enable automatic reconnection |
reconnectMaxDelay | number | 30000 | Maximum reconnection backoff in milliseconds |
queueWhileDisconnected | boolean | false | Queue actions called while disconnected (up to 10) and send them on reconnect instead of rejecting them with CONNECTION_LOST. A queued action not sent within its 30s timeout rejects with TIMEOUT |
debug | boolean | false | Enable debug logging to console |
Events
Connection Events
sw.on('connected', () => void)
sw.on('disconnected', ({ code: number, reason: string }) => void)
On a connection drop the SDK reconnects automatically with exponential backoff (1s → 2s → 4s → … capped at reconnectMaxDelay, default 30s) and emits disconnected before each attempt.
Missed events are not replayed. There is no event buffer: events emitted while disconnected are lost. Recovery relies solely on the fresh work snapshot the server sends on every (re)connect (work.loading → work.snapshot | work.empty) — treat each snapshot as the full replacement of your local work state. This covers work items and open instances only; notification and instance.completed events missed while offline are lost — use search() to reconcile instance status if needed. See the Quickstart for the recovery recipe, and Event ordering and delivery for why handlers must be registered before the first event arrives.
Close codes in the 4000-4999 range (authentication failures) are terminal: the SDK emits error (with the close reason as error.code), does not emit disconnected, and does not attempt to reconnect. The app must renew the session and reconnect. Close codes outside that range follow the normal disconnected + backoff path.
Work Events
// Fired immediately on connect — connection is live, snapshot is loading
sw.on('work.loading', () => void)
// Fired once snapshot is ready (after work.loading) — user has pending tasks
sw.on('work.snapshot', (tasks: WorkItem[], instances: InstanceSummary[]) => void)
// Fired when there are no pending tasks
sw.on('work.empty', (state: WorkEmptyState) => void)
The work.* events follow a fixed lifecycle on every (re)connect:
work.loading → work.snapshot (user has pending tasks)
→ work.empty (no pending tasks)
work.loading — emitted synchronously on connection before the server runs the work-resolution query. Show a loading indicator.
work.snapshot — the user has at least one pending task.
tasks: WorkItem[]— the pending tasksinstances: InstanceSummary[]— all process instances associated with the user, including those without a pending task
work.empty — no pending tasks, but process instances may still be running.
state.definitions— which processes the user can startstate.instances: InstanceSummary[]— running process instances with no pending task for this user
instances.length is the canonical check for whether a user has open process activity. For example, a support ticket waiting for an agent is visible in instances even though the user has no task to complete.
Task Events
// A new task was assigned to the user
sw.on('task.assigned', (task: WorkItem) => void)
// A task was completed — sent to every eligible user, including the completer
sw.on('task.completed', (result: TaskCompletedEvent) => void)
// Claim confirmed for the user who made the claim (update isClaimed locally)
sw.on('task.claimed', (event: TaskClaimedEvent) => void)
// A pool task was claimed by another user (remove or disable task in UI)
sw.on('task.claimed_by_other', ({ taskId: string, instanceId: string, claimedBy: string }) => void)
// A task stopped existing before anyone completed it (remove it from the UI)
sw.on('task.cancelled', (event: TaskCancelledEvent) => void)
task.claimed is sent only to the user who performed the claim. Use it to update isClaimed and show the task form without optimistic updates. task.claimed_by_other is sent to all other eligible users.
task.cancelled is sent to every user who could see the task, the same users who would receive its task.completed, including whoever claimed it. Remove the task when it arrives: completing a cancelled task fails. reason tells what cancelled it:
reason | The task was cancelled because |
|---|---|
boundary | a boundary event attached to the task interrupted it, for example a message, signal or timer, or an error boundary after its submitted outputs were rejected |
scope | the sub-process that contains the task was interrupted or cancelled |
terminateEndEvent | a terminate end event ended the task's path |
callActivity | the task belongs to a process started by a call activity, and that call activity was interrupted |
terminated | the process instance was terminated |
api | the task or its token was cancelled directly through the API |
Event recipients
Every event goes to a fixed set of recipients, so a frontend can drive its whole UI from events alone — no optimistic local writes after claimTask() or completeTask():
| Event | Recipients |
|---|---|
task.assigned | Every connection eligible for the task: the assignee, candidateUsers members (session scope for the definition sets candidate_as_user: true), and any session whose scope for the definition lists a roles entry that matches a candidateGroups entry |
task.completed | Every connection eligible for the task — including the user who completed it and the rest of the pool. Each frontend removes the card locally on this event |
task.claimed | Only the connection that performed the claim |
task.claimed_by_other | Every connection eligible for the task, except the claiming user |
task.cancelled | Same recipients as task.completed, including whoever claimed the task. Each frontend removes the card locally on this event |
instance.completed | The instance's participants: the user who started it with startProcess and every user who is the assignee of one of its tasks (open, completed or cancelled). Candidates who never claimed a task do not receive it — see Who Receives a Notification |
notification | Same participants as instance.completed — see Who Receives a Notification |
Instance Events
// A process instance reached a terminal state
sw.on('instance.completed', (event: InstanceCompletedEvent) => void)
Fired when a process instance transitions to completed, terminated, or error.
| Field | Type | Description |
|---|---|---|
instanceId | string | ID of the instance that ended |
status | 'completed' | 'terminated' | 'error' | Final state of the instance |
definitionKey | string | Process definition key |
variables | object | Final instance variables (only populated with variable_visibility: 'all'; {} with 'declared') |
endedAt | string (ISO 8601) | Timestamp of when the instance ended |
It is sent to the instance's participants: the user who started it with startProcess and every user who is the assignee of one of its tasks, open, completed or cancelled. Candidates who never claimed a task do not receive it. These are the same recipients as notifications.
Use instance.completed to track process history in the UI, show completion toasts, or navigate away from an active process view. This event is the reliable alternative to polling.
sw.on('instance.completed', ({ instanceId, status, variables }) => {
if (status === 'completed') {
showToast(`Process finished — rating: ${variables.rating}`);
} else if (status === 'terminated') {
showToast(`Process was cancelled.`);
}
removeFromActiveList(instanceId);
});
Session Events
// Session is approaching expiry
sw.on('session.expiring', ({ expiresIn: number }) => void)
// This session was superseded by a new session for the same user_id
sw.on('session.superseded', () => void)
Error Event
sw.on('error', (error: StatewayError) => void)
Event ordering and delivery
Two kinds of frames share the same socket, each delivered in arrival order:
- Responses to the actions you call (
startProcess,completeTask, …), correlated by the unique request ID generated for each call - Pushes (
work.*,task.*,notification,instance.completed, …), emitted to every eligible connection as the engine advances
There is no ordering guarantee between a response and the push events that action causes — and none between the pushes of different instances. The response frame is written only after the engine call behind it finishes, while the pushes it triggers are dispatched as the engine advances, on a separate path that can win the race. Starting a process whose first task is assigned to the starter — or claimable by another agent — can deliver task.assigned before the startProcess() promise resolves, on the starter's own socket and on the other agent's socket. The same applies to every action and its consequences (task.completed, instance.completed, …).
Two rules follow:
- Register handlers right after constructing
StatewayClient. The socket opens in the constructor and pushes are not buffered — an event that arrives while no handler is registered is lost, not replayed. Register in the same synchronous block, before anyawait. - Handlers must be self-sufficient. Derive state from the event payload itself (
task.instanceId,task.taskId,message, …) — never from data that only exists after anawait.
// ❌ loses the race — `task.assigned` for the first task can arrive before
// startProcess resolves, so the handler registered afterwards never fires
const { instanceId } = await sw.startProcess('purchase-request', vars);
sw.on('task.assigned', (task) => {
if (task.instanceId === instanceId) openForm(task);
});
// ✅ registered before any action and self-sufficient — state comes from the payload
sw.on('task.assigned', (task) => openForm(task));
await sw.startProcess('purchase-request', vars);
Actions
All action methods return Promises and are correlated by a unique request ID internally.
startProcess
const instance = await sw.startProcess(
definitionKey: string,
variables?: Record<string, unknown>
): Promise<{ instanceId: string; status: string }>
Starts a new process instance. Requires can_start: true in the session scope for that definition.
variables are client dataThe end user's browser decides what goes into variables. Treat them like form input and never use them to identify who started the instance: a value such as { openedBy: user.id } can be set to any other user's id. To refer to the user who started the instance, use {{session.userId}} in the process definition. Stateway fills it from the session. See Assigning to Whoever Started the Instance.
The promise resolving is not a synchronization point: task.assigned for the first task of the new instance can be delivered before startProcess() resolves — including on another user's socket, for a pool task. Register the task.assigned handler before calling, and correlate by the event payload. See Event ordering and delivery.
claimTask
await sw.claimTask(taskId: string): Promise<void>
Claims a pool task (candidateUsers or candidateGroups). The user must be eligible. Returns CLAIM_CONFLICT if another user claimed first.
unclaimTask
await sw.unclaimTask(taskId: string): Promise<void>
Releases a claim previously made by this user.
completeTask
await sw.completeTask(
taskId: string,
variables?: Record<string, unknown>
): Promise<void>
Completes a task with output variables. For pool tasks, the user must have claimed the task first; otherwise returns CLAIM_REQUIRED.
sendEvent
await sw.sendEvent(
instanceId: string,
eventName: string,
variables?: Record<string, unknown>
): Promise<void>
Sends a named event to a process instance. The event resumes only the waiting elements that
declare eventName as their name: an intermediate catch event, a message boundary event, or an
async service task's callbackEvent (see
Which token does the event resume?).
It never completes a user task: tasks are completed with completeTask, which
checks the user's eligibility. If no waiting element declares the name, the event is recorded and a
catch event that reaches that name later consumes it; nothing else advances.
The instance must belong to a definition in the session scope and, when the scope sets an
instance_filter, match it. Otherwise it
throws SCOPE_VIOLATION with the message Instance not found or not in session scope — the
same error as for an instance that does not exist.
evaluateDecision
const result = await sw.evaluateDecision(
decisionKey: string,
variables: Record<string, unknown>
): Promise<Record<string, unknown>>
Evaluates a DMN decision model and returns the result.
rotateToken
await sw.rotateToken(newToken: string): Promise<void>
Replaces the session token without disconnecting. Use when your backend has issued a refreshed token.
search
const result = await sw.search({
definitionKey: string,
definitionVersion?: string | number,
statusScope?: string | string[],
filter?: Record<string, unknown>,
sort?: Record<string, number>,
page?: { limit?: number; cursor?: string | null },
projection?: Record<string, 1>,
text?: { query: string; mode?: 'fulltext' | 'trigram'; fields?: string[]; fuzzy?: boolean },
}): Promise<SearchResult>
Searches process instances by variable values. definitionKey is required and must be in the session scope. Only variables listed in searchable_variables — declared per definition in the
session scope, see Sessions API → Searchable Variables —
can be filtered on, and results are reduced to those variables. Omitted or [] means
none are searchable.
searchable_variables limits variables, not instances. Without an
instance_filter in the session scope,
search() returns the instances of every user of your tenant. A filter such as
{ 'variables.openedBy': user.id } sent from the browser is not a restriction: the browser can
change it. To show users only their own instances, have your backend set instance_filter
when it creates the session.
text without fields searches only the searchable variables. With searchable_variables: []
text search is rejected.
const result = await sw.search({
definitionKey: 'loan-application',
filter: {
'variables.loan_status': 'in_review',
'variables.amount': { $gte: 50000 },
},
page: { limit: 20 },
});
// next page
if (result.nextCursor) {
const page2 = await sw.search({
definitionKey: 'loan-application',
filter: { 'variables.loan_status': 'in_review' },
page: { limit: 20, cursor: result.nextCursor },
});
}
Throws SCOPE_VIOLATION if a filter key or text.fields entry names a variable not
in searchable_variables, if filter is not an object, if text is sent without fields
while no variable is searchable, or if definitionKey is not in the session scope.
projection is not gated — non-searchable variables are simply omitted from results.
See Query Language for filter syntax.
disconnect
sw.disconnect(): void
Closes the WebSocket connection and stops reconnection.
onTaskFlow Helper
A high-level helper for single-process wizard flows:
const cleanup = sw.onTaskFlow({
onTask?: (task: WorkItem) => void,
onProcessing?: (elementName: string) => void,
onNotification?: (message: string, variables: Record<string, unknown>) => void,
onComplete?: () => void,
onInstanceCompleted?: (event: InstanceCompletedEvent) => void,
onTaskCancelled?: (event: TaskCancelledEvent) => void,
}): () => void // returns cleanup function
Returns a cleanup function. Call it when the component unmounts to remove all listeners.
Types
WorkItem
interface WorkItem {
kind: 'task';
taskId: string;
instanceId: string;
definitionKey: string;
name: string;
assignee: string | null; // null for pool tasks
candidateUsers: string[];
candidateGroups: string[];
isClaimed: boolean;
claimedBy: string | null; // user_id of who claimed; null if unclaimed
formSchema: JSONSchema | null;
variables: Record<string, unknown>;
createdAt: string;
dueAt: string | null;
}
InstanceSummary
interface InstanceSummary {
instanceId: string;
definitionKey: string;
status: 'running' | 'suspended';
startedAt: string; // ISO 8601
}
Included in both work.snapshot (second argument) and work.empty (state.instances). Represents a process instance associated with the user — whether or not the user currently has a task in that instance.
WorkEmptyState
interface WorkEmptyState {
definitions: Array<{
key: string;
canStart: boolean;
}>;
instances: InstanceSummary[];
}
TaskCompletedEvent
interface TaskCompletedEvent {
taskId: string;
instanceId: string;
next: {
kind: 'automatic' | 'waiting_event' | 'timer';
elementName: string;
estimatedFireAt: string | null; // set when kind === 'timer'
};
}
TaskClaimedEvent
interface TaskClaimedEvent {
taskId: string;
instanceId: string;
claimedBy: string; // user_id of the user who made the claim (always the current user)
}
TaskCancelledEvent
interface TaskCancelledEvent {
taskId: string;
instanceId: string;
reason: 'boundary' | 'scope' | 'terminateEndEvent' | 'callActivity' | 'terminated' | 'api';
}
NotificationEvent
interface NotificationEvent {
instanceId: string;
elementId: string;
message: string;
variables: Record<string, unknown>;
eventName?: string; // set when the throw event also throws a named event to the instance
}
InstanceCompletedEvent
interface InstanceCompletedEvent {
instanceId: string;
status: 'completed' | 'terminated' | 'error';
definitionKey: string;
variables: Record<string, unknown>;
endedAt: string;
}
StatewayError
interface StatewayError {
code: string;
message: string;
requestId?: string;
}
SearchQuery
interface SearchQuery {
definitionKey: string; // required; must be in session scope
definitionVersion?: string | number;
statusScope?: string | string[]; // 'active' (default), 'completed', 'all'
filter?: Record<string, unknown>;
sort?: Record<string, number>;
page?: { limit?: number; cursor?: string | null };
projection?: Record<string, 1>;
text?: SearchTextQuery;
}
SearchTextQuery
interface SearchTextQuery {
query: string;
mode?: 'fulltext' | 'trigram';
fields?: string[];
fuzzy?: boolean;
}
InstanceResult
interface InstanceResult {
instanceId: string;
definitionKey: string;
definitionVersion: number;
correlationId: string | null;
status: string;
startedAt: string; // ISO 8601
endedAt: string | null;
variables: Record<string, unknown>; // only keys listed in searchable_variables (all keys if "all")
metadata?: Record<string, unknown>;
}
SearchStats
interface SearchStats {
returned: number;
limit: number;
scanMode: string; // e.g. 'gin_index', 'seq_scan'
textMode: string | null;
estimatedComplexity: string;
}
SearchResult
interface SearchResult {
results: InstanceResult[];
nextCursor: string | null;
stats: SearchStats;
}
Error Codes
| Code | Situation |
|---|---|
CLAIM_CONFLICT | Another user won the claim simultaneously |
CONFLICT | Task completion rejected. Two distinct causes: (1) the underlying execution token was already dead (e.g. the process instance already errored for an unrelated reason) — recovery (e.g. token move) is needed before the task can be completed; (2) another concurrent request already completed or cancelled this same task and won the race — the task is already in its final state and the instance is healthy, so re-fetch the task instead of retrying. Differs from TASK_NOT_FOUND below: TASK_NOT_FOUND fires for a sequential second attempt against a task that is already completed/cancelled by the time this request starts reading it; CONFLICT fires when this request raced a concurrent completion attempt in flight and lost. |
VALIDATION_ERROR | startProcess rejected: variables is not an object, or the variables that reach the instance (after the start event's ioMapping outputs) fail the definition's typed data objects or the size cap. No instance is created. Task completion rejected. Three causes: (1) the variables patch is not a plain object; (2) the merged payload would exceed the variables size cap — for both, nothing is left inconsistent, reverting the task back to its pre-request state if a concurrent write only trips the size check after human_tasks was already mutated; (3) the submitted outputs fail the task's declared outputSets validation — the same check as the Human Tasks API, with the same effect: the task is cancelled (if a matching interrupting error boundary event exists on it) or the process instance is marked error. |
CLAIM_REQUIRED | Pool task — must claim before completing |
FORBIDDEN | User is not eligible for that task |
TASK_NOT_FOUND | Task was completed or cancelled by another path |
SCOPE_VIOLATION | A search() filter key or text.fields entry is not in searchable_variables (or filter is not an object), the definition is not in session scope, or the sendEvent() instance does not exist, is outside the scope, or does not match the scope's instance_filter |
CONNECTION_LOST | Two causes: (1) the request was in flight when the socket dropped — it rejects once the SDK reconnects (or on disconnect()), and the outcome is unknown: the request may or may not have been applied, so do not blindly retry, wait for the next snapshot instead; (2) the action was called while disconnected with queueWhileDisconnected off, or with the queue full — the request never left the client, so nothing was applied and it is safe to retry after reconnecting. |
SESSION_EXPIRED | Token expired — frontend must renew the session |
MAX_CONCURRENT_REACHED | Session scope's max_concurrent limit reached for this definition |