# Workify guide

Workify is a headless TypeScript workflow engine for directed acyclic graphs (DAGs). You define nodes and links, pass JSON data between named ports, and observe execution through immutable state snapshots and events. The engine has no runtime dependencies and is independent of any UI framework.

This guide covers graph construction, execution semantics, state and history, cancellation, data contracts, and integration patterns. Workify does not include a visual editor, persistent workflow service, or automatic retry engine.

## Contents

- [Install and import](#install-and-import) · [Public API index](#public-api-index)
- [Build and run a graph](#build-and-run-a-graph) · [Route through named ports](#route-through-named-ports)
- [Execution rules](#execution-rules) · [Handle results and errors](#handle-results-and-errors)
- [Observe state, events, and history](#observe-state-events-and-history)
- [Cancel cooperatively](#cancel-cooperatively) · [JSON payload contract](#keep-payloads-within-the-json-contract)
- [Find nodes and links](#find-nodes-and-links) · [Application integration](#integrate-with-a-ui-or-application)
- [Develop and validate](#develop-and-validate) · [License](#license)
- [Troubleshooting FAQ](#troubleshooting)

## Install and import

```sh
npm install @newvexa/workify
```

Use the scoped package name: the unscoped `workify` package belongs to another publisher and is a different project. If you are installing a local release archive instead, pass its actual path to `npm install`.

The package provides ESM, CommonJS, and TypeScript declarations in `dist`. The core targets ES2022 and environments with the standard `Promise`, `AbortController`, and `AbortSignal` APIs, including Node.js, Bun, and modern browsers. Build tools and framework validation dependencies are development concerns, not runtime dependencies of the core.

<!-- workify-snippet: guide.imports -->
```ts
import { Engine, Workflow } from "@newvexa/workify";
import type { JsonValue, NodeInput, RunResult } from "@newvexa/workify";
```

CommonJS consumers can use `require("@newvexa/workify")`.

## Public API index

These runtime exports come from the main `@newvexa/workify` entry. The linked sections explain their contracts and limitations.

| Export | Purpose and key methods |
| --- | --- |
| `Workflow` | [Build a graph](#build-and-run-a-graph) with `new Workflow({ id, name })`, `addNode(node)`, and `addLink(link)`. Read `id`, `name`, `nodes`, and `links`; use `findNodes(query)`, `findLinks(query)`, `search(text)`, and `toDefinition()` for [descriptor access](#find-nodes-and-links). Both add methods return the builder. |
| `Engine` | Construct a validated execution plan with `new Engine(workflow, options?)`. Use `run(initial, options?)` for [execution](#handle-results-and-errors), and `getSnapshot()`, `subscribe(listener)`, `onEvent(listener)`, `getHistory()`, and `clearHistory()` for [observation](#observe-state-events-and-history). |
| `ifNode` | Create a logic-node descriptor from `IfNodeOptions`, routing the selected value to `"true"` or `"false"`. Add it with `workflow.addNode(...)`; see [routing](#route-through-named-ports). |
| `switchNode` | Create a logic-node descriptor from `SwitchNodeOptions`, routing to an exact case port or `"default"`; see [routing](#route-through-named-ports). |
| `WorkflowValidationError` | Identify invalid workflow metadata, graph structure, or configuration. Its `code` is `"WORKFLOW_VALIDATION_ERROR"`. |
| `EngineBusyError` | Identify an overlapping run on the same engine, including cancellation draining. Its `code` is `"ENGINE_BUSY"`. |
| `PayloadValidationError` | Identify a payload or conditional callback result that violates its runtime contract. Its `code` is `"PAYLOAD_VALIDATION_ERROR"`; invalid outputs during execution become recorded failures rather than a rejected run promise. |

The entry also exports the public types from `types.ts`, including `INode`, `ILink`, `IWorkflow`, `IEngine`, `JsonValue`, `NodeInput`, `NodeContext`, `LinkContext`, `NodeResult`, `RunResult`, `EngineSnapshot`, `EngineEvent`, and the options and query types. These are TypeScript types, not runtime constructors. See [error handling](#handle-results-and-errors) for where to catch errors versus inspect a resolved result.

## Build and run a graph

A node implements the asynchronous `execute(input, context)` method and returns an `outputs` dictionary. Each key names an output port. A link connects a source port to a target port and can optionally transform the value before delivery.

<!-- workify-snippet: guide.build -->
```ts
import { Engine, Workflow } from "@newvexa/workify";

const workflow = new Workflow({ id: "orders", name: "Order processing" });

workflow.addNode({
  id: "start",
  name: "Order received",
  type: "trigger",
  tags: ["orders"],
  async execute(input, context) {
    context.signal.throwIfAborted();
    return { outputs: { main: input.initial } };
  },
});

workflow.addNode({
  id: "prepare",
  name: "Prepare order",
  type: "action",
  tags: ["orders"],
  async execute(input, context) {
    context.signal.throwIfAborted();
    const incoming = input.incoming[0];
    if (!incoming) throw new Error("An incoming order is required.");
    return { outputs: { main: incoming.value } };
  },
});

workflow.addLink({
  id: "start-prepare",
  name: "Order for processing",
  source: { nodeId: "start", port: "main" },
  target: { nodeId: "prepare", port: "order" },
  transform: (value, context) => {
    context.signal.throwIfAborted();
    return { order: value, prepared: true };
  },
});

const engine = new Engine(workflow, { concurrency: 4, historyLimit: 500 });
const result = await engine.run({ orderId: "A-001", total: 1500 });

console.log(result.status); // succeeded
console.log(result.results.prepare?.outputs.main);
// { order: { orderId: "A-001", total: 1500 }, prepared: true }
```

`NodeInput` contains the run's `initial` value and an ordered `incoming` array. Each incoming entry identifies its link, source node, source port, target port, and delivered value. Inputs are not automatically merged into a single object, even when several links target the same port. Validate the business shape expected by your node before using its values.

`NodeContext` supplies `runId`, `nodeId`, and `signal`. A transformation receives `LinkContext`, which supplies `runId`, `linkId`, the source and target endpoints, and `signal`.

The builder captures node and link descriptors. Constructing an `Engine` validates the complete graph and retains a frozen copy, so later builder changes do not alter that engine's graph. Unbound methods receive the captured descriptor as `this`. For a class method that uses private fields, supply an arrow function or a method bound with `bind`; business resources and captured closures are not cloned.

## Route through named ports

Node types are `trigger`, `action`, `logic`, and `custom`. These describe a node's role; they do not activate an external listener or daemon. Calling `run()` starts processing.

The package exports two routing helpers:

- `ifNode({ id, name, value, predicate, ... })` creates a logic node that emits the selected value on exactly one port, `"true"` or `"false"`. The predicate must return a boolean.
- `switchNode({ id, name, cases, value, select, ... })` creates a logic node that emits the selected value on an exact, case-sensitive case port. An unmatched string uses the reserved `"default"` port. Case names must be nonempty and unique, and `"default"` cannot be declared as a case. The selector must return a string.

Both helpers accept optional `categories` and `tags`. Their `value`, `predicate`, and `select` callbacks receive the node context and may be asynchronous. An invalid callback result fails the node during execution.

### The order-routing example

The repository's [vanilla example](../examples/vanilla.ts) defines four nodes and two branches:

```text
Order received (start) → total > 1,000 (amount)
                          ├─ true  → priority
                          └─ false → standard
```

The `amount-priority` link transforms the order into `{ order: value, priority: "high" }`; the standard link passes the order unchanged. A total of `1500` runs the priority action and skips the standard action. A total of `250`, or exactly `1000`, runs the standard action. These actions return demonstration data and do not call an external service.

The example exports `createOrderEngine()` and `runDemo()`. These are example functions, not package exports. Importing that file does not start a run. From the repository, `npm run example` bundles the vanilla example and explicitly calls `runDemo()`, exercising both branches in two successive runs.

## Execution rules

### Roots, joins, and inactive branches

Every root—a node with no incoming links—executes once per run with `initial` and `incoming: []`. A downstream node waits until **all** its incoming links are terminal. Delivered entries appear in link declaration order, regardless of concurrent completion order; multiple links to one target port remain distinct entries.

Output presence is determined by an own property of the `outputs` dictionary, not by truthiness. A missing output port makes its outgoing links `inactive`. Present values such as `false`, `0`, `""`, and `null` are delivered normally.

If all incoming links are inactive, the destination becomes `skipped`. At least one delivered entry is enough to execute it, provided no dependency failed. A failed dependency blocks the destination even if another incoming link delivered data. `inactive`, `skipped`, and `blocked` describe different situations and should not be treated as equivalent.

### Failures and concurrency

A rejected node execution makes that node `failed`. A rejected link transformation makes the **link** fail without changing its source node's successful state. Failed dependencies block downstream nodes; independent branches continue.

The concurrency budget is shared by node executions and link transformations. The default is four tasks in flight. This is asynchronous concurrency, not multithreading: synchronous CPU work still blocks the event loop.

Outside cancellation, any node or link failure makes the run `failed`. An inactive branch or skipped node does not fail the run. An empty graph succeeds. There are no automatic retries or implicit replays of effects.

## Handle results and errors

`run()` resolves to a `RunResult` containing `runId`, the final `status`, the final `snapshot`, and a `results` dictionary of successful node outputs. Read business payloads from `results`; they are not part of snapshots or history.

Execution failures are represented in the result and the node or link state. Error metadata is an `ErrorInfo` with `code` and `message`, rather than the original thrown object. Inspect both the run status and the relevant state when presenting a failure.

Invalid graph metadata or structure, an invalid initial payload, and an overlapping run are errors to catch around construction or `run()`. The package exports `WorkflowValidationError`, `PayloadValidationError`, and `EngineBusyError`. A bad output or transformation payload during execution, by contrast, is recorded as a node or link failure.

<!-- workify-snippet: guide.errors -->
```ts
// Continue with the engine created above.
try {
  const result = await engine.run({ orderId: "A-002", total: 250 });
  if (result.status === "failed") {
    console.error(result.snapshot.nodes.prepare?.error);
    console.error(result.snapshot.links["start-prepare"]?.error);
  }
} catch (cause: unknown) {
  console.error(cause instanceof Error ? cause.message : String(cause));
}
```

## Observe state, events, and history

### Immutable snapshots

`engine.getSnapshot()` returns an immutable `EngineSnapshot`. Its reference is stable until the next state transition, which produces a new snapshot. It contains `workflowId`, `runId`, `version`, `status`, and the `nodes` and `links` state dictionaries—**not payloads**.

| State | Status values |
| --- | --- |
| Run | `idle`, `running`, `succeeded`, `failed`, `cancelled` |
| Node | `pending`, `running`, `succeeded`, `failed`, `skipped`, `blocked`, `cancelled` |
| Link | `pending`, `delivered`, `inactive`, `failed`, `cancelled` |

Node states can include start and finish timestamps and error metadata. Link states can include error metadata.

### Subscriptions and events

`subscribe(listener)` notifies a state observer; call `getSnapshot()` inside the listener to read current state. `onEvent(listener)` receives an `EngineEvent`. Both methods return an unsubscribe function.

<!-- workify-snippet: guide.observe -->
```ts
// Continue with the engine created above.
const stopState = engine.subscribe(() => {
  console.log(engine.getSnapshot().status);
});
const stopEvents = engine.onEvent((event) => {
  console.log(event.sequence, event.type);
});

try {
  await engine.run({ orderId: "A-003", total: 1500 });
  const previous = engine.getHistory().at(-2);
  if (previous) console.log(previous.snapshot);
} finally {
  stopState();
  stopEvents();
}
```

The event types are `run.started`, `node.changed`, `link.changed`, and `run.finished`. Every event has a sequence number, run ID, and timestamp. Node and link change events include the affected ID and state; the finish event includes the final run status.

The snapshot and history are updated before synchronous notifications. Synchronous observer exceptions are isolated from execution. Use sequence numbers to establish event order and timestamps for display. Unsubscribing removes an observer; it does **not** cancel a run.

### Bounded history, not replay

`getHistory()` returns entries containing an event and its associated status snapshot. By default, the engine retains 500 entries. Set `historyLimit: 0` to disable retention, or call `clearHistory()` to empty it. The limit bounds the number of entries, not their total size in bytes.

History contains execution metadata and status snapshots, never initial values, node outputs, or link data. Error codes and messages are metadata too: do not include secrets in them.

Inspecting an old entry does not change the current state and does not replay anything. It cannot resend an email, repeat a payment, or restart a network request. History is an observation facility, not persistence, recovery, or time-travel execution.

## Cancel cooperatively

Pass an external signal through the run options:

<!-- workify-snippet: guide.cancel -->
```ts
// Continue with the engine created above.
const controller = new AbortController();
const pending = engine.run(
  { orderId: "A-004", total: 1500 },
  { signal: controller.signal },
);

controller.abort();
const result = await pending;
console.log(result.status); // cancelled if cancellation was accepted before completion
```

Cancellation stops new task starts and signals already-running nodes and transformations through `context.signal`. Pass that signal to cancellable operations such as `fetch`, and check it in your own handlers. Late results are not injected into the graph.

The engine still waits for tasks that have already started to settle. A promise that ignores the signal cannot be forcibly interrupted and may delay finalization indefinitely. Cancellation is not a transaction rollback and does not undo external effects already performed by your handlers.

**Only one run may be active on an engine, including the draining period after cancellation.** Calling `controller.abort()` does not immediately free the engine for another run; await the original run promise first. Use separate engine instances for simultaneous runs. Cancellation accepted before finalization produces `cancelled`; aborting after finalization has no effect.

## Keep payloads within the JSON contract

Payloads use `JsonValue`: `null`, booleans, finite numbers, strings, arrays, and plain data objects. The engine validates, recursively copies, and freezes data at the initial input boundary, after node execution, and after link transformation.

Cycles, `undefined`, functions, class instances, accessors, and non-finite numbers are not accepted payloads. Sparse arrays and symbol-bearing data are also rejected. JSON validation protects the data contract, but it does not validate your application's schema: check required fields and port-specific business rules in each node.

Keys such as `__proto__`, `constructor`, and `prototype` are handled as data rather than through unsafe merging or inherited-property lookup. Output presence is checked on own keys. These protections do not replace authorization, input sanitization, or other security controls in your integrations.

Validation and copying are recursive. Avoid treating the engine as a transport for arbitrarily large or unboundedly nested payloads.

## Find nodes and links

Search operates on workflow descriptors, independently of execution state:

<!-- workify-snippet: guide.search -->
```ts
// Continue with the workflow created above.
workflow.findNodes({ type: "action", tags: ["orders", "billing"] });
workflow.findLinks({ sourceNodeId: "start", name: "Order" });
workflow.search("order"); // { nodes, links }
```

Query fields combine with **AND**. Within a categories or tags list, any match is sufficient (**OR**); an empty list adds no restriction. IDs, node types, and endpoint node references match exactly. Names and free text use case-insensitive substring matching. Categories and tags use case-insensitive equality.

`search()` covers node IDs, names, types, categories, and tags, plus link IDs and names. Results preserve declaration order. An empty query returns everything.

`workflow.toDefinition()` returns an **in-memory** definition that retains `execute` and `transform` functions. It is not a JSON serialization format or a persistence mechanism.

## Integrate with a UI or application

The engine exposes an external-store-style state API but ships no UI components or visual editor. The repository includes small framework adapters:

| Example | Integration pattern |
| --- | --- |
| [React](../examples/react/useWorkflow.tsx) | `useSyncExternalStore`, a stable engine instance, event cleanup, and cancellation on unmount. |
| [Vue](../examples/vue/useWorkflow.ts) | `shallowRef` and subscription cleanup through `onScopeDispose`. |
| [Angular service](../examples/angular/workflow.service.ts) and [component](../examples/angular/workflow.component.ts) | Signals, a provider per view, and cleanup through `DestroyRef`. |

Start runs from an explicit application or user action, not automatically on module import or in a render effect. Keep the engine instance stable for its consumer and manage subscriptions separately from cancellation. The Vue composable must run within an active scope, such as `setup()` or an `effectScope`.

For React SSR, create an engine per request and provide a server snapshot consistent with the initial hydration state. Do not share mutable execution state across requests. UI cleanup should unsubscribe observers and abort any owned run, while recognizing that its in-flight work may still be draining.

Framework dependencies belong in the consuming application or a separate validation project, never in the core's runtime dependencies.

### Extension boundaries

Implement domain-specific work inside node handlers, link transformations, or separately imported adapters. Workify does not implicitly provide network, database, or AI plugins. Specialized plugins that use `fs`, `child_process`, or other Node-only APIs must remain separately imported; the main `@newvexa/workify` entry does not re-export them.

Captured resources, handler lifecycles, external side effects, and integration security remain your application's responsibility. Cycles, persistence and recovery after restart, a persistent scheduler, distributed execution, automatic retries, and effect replay are outside the core's scope.

## Develop and validate

Run these commands from the source repository:

```sh
npm run build       # ESM, CommonJS, and TypeScript declarations
npm test            # test suite
npm run typecheck   # TypeScript checks
npm run check       # build + tests + typecheck
npm run example     # bundled vanilla demonstration
```

These commands describe the validation workflow; listing them is not proof that they have passed in your environment. Core validation and framework example validation are separate.

The [v0 validation report](validation-v0.md) records development checks performed on November 3, 2026: 63 Node test cases passed, the same 19 portable assertions passed in Node ESM, Node CommonJS, Bun, and Chromium, and the UI examples compiled independently with their framework dependencies. Those are recorded checks, not an exhaustive runtime compatibility guarantee or a benchmark.

In particular, UI validation was TypeScript compilation—not mounting or hydration testing, and not Angular template AOT compilation. Browser checks exercised the headless engine. No compatibility claim is made for every browser or framework version. See the report for exact environments, reproduction steps, and remaining limitations; see [architecture](architecture.md) for implementation responsibilities and execution semantics.

## License

Workify is licensed under [MIT](../LICENSE), copyright © 2026 Workify contributors.

## Troubleshooting

### Why does installing or importing Workify give me a different API?

Check that both the installed dependency and every import use `@newvexa/workify`, not `workify`. The unscoped name belongs to a different project. The same scoped entry works for ESM imports and CommonJS `require`; framework adapters and example helpers are not core exports. See [installation](#install-and-import) and the [public API index](#public-api-index).

### Why do I still get EngineBusyError after aborting a run?

Aborting signals cancellation but does not immediately release the engine. Already-started node handlers and link transformations must settle before the original `run()` promise completes, even if their states already show `cancelled`. Await that promise before starting another run on the same engine, and pass `context.signal` to cancellable work. A handler that ignores cancellation can keep the engine busy indefinitely. Use separate engines when runs must overlap; see [cooperative cancellation](#cancel-cooperatively).

### How do skipped, blocked, and failed differ?

A `skipped` node did not execute because all its incoming links were `inactive`; this is normal for an unselected branch and does not fail the run. A `blocked` node did not execute because an incoming dependency failed, even if another link delivered data. A `failed` node did execute, but its handler rejected or its result violated the output contract. A transformation failure belongs to the link and does not change the successful source node to `failed`. Inspect the relevant node and link errors; outside cancellation, failures make the run `failed`. See [execution rules](#execution-rules).

### Why is my payload rejected even though JSON.stringify accepts it?

Workify validates the supplied value directly rather than serializing it and accepting JSON's conversions or omissions. Use `null`, booleans, finite numbers, strings, dense arrays, and plain data objects with enumerable string-keyed data properties. Convert values such as dates into explicit strings and keep functions, class instances, `bigint`, symbols, accessors, cycles, `undefined`, and non-finite numbers outside the payload. Non-enumerable object properties and extra array properties are also rejected. To leave an output port inactive, omit the key instead of assigning `undefined`; `null` is a present value and is delivered. Invalid initial data rejects `run()` with `PayloadValidationError`, whereas invalid node outputs or transformed data become execution failures. See the [JSON contract](#keep-payloads-within-the-json-contract).

### Why are node outputs missing from my state or history?

Snapshots, events, and history describe execution status, timestamps, and error metadata, not business payloads. Await `run()` and read successful node outputs from `result.results[nodeId]?.outputs`. There is no snapshot or history payload replay API. If your application needs to persist results, implement that storage explicitly and apply its own security and retention policies. See [results](#handle-results-and-errors) and [bounded history](#bounded-history-not-replay).

### Can I use Node-only adapters without a UI?

Yes. Import your adapter separately in the Node application, call it from a node's `execute(input, context)` handler or a link transformation, and return JSON-compatible data. Workify remains headless: start work explicitly with `engine.run()` and observe it only if your application needs status updates. Keep adapters that depend on `fs`, `child_process`, or other Node-only APIs out of browser bundles; they are not re-exported by the core entry. Adapter dependencies, resource cleanup, authorization, secret handling, and external side effects belong to the consuming application. Forward `context.signal` where the adapter supports cancellation, and do not assume aborting rolls back effects. See [extension boundaries](#extension-boundaries).
