---
title: "Suspendable Nodes"
description: "Build workflow nodes that can pause and resume - guide for developers."
canonical: https://docs.nodetool.ai/developer/suspendable-nodes
markdown: https://docs.nodetool.ai/developer/suspendable-nodes.md
product: NodeTool
source: https://github.com/nodetool-ai/nodetool/blob/main/docs/developer/suspendable-nodes.md
---

# Suspendable Nodes

# Suspendable Nodes

Suspendable nodes allow workflows to pause execution, save state, and resume later. This enables human-in-the-loop workflows, external callbacks, and checkpoint-based processing.

---

## Overview

A suspendable node can:

- **Suspend** the workflow and save state to the event log
- **Resume** when triggered externally (via API or UI)
- **Restore state** from the saved suspension data

This is useful for:

- **Human approvals** - Wait for a manager to approve before continuing
- **External callbacks** - Pause for webhook or API responses
- **Checkpoints** - Save progress in long-running computations
- **Interactive workflows** - Pause for user input

---

## The Built-in WaitNode (a delay, not a suspension)

> **Important:** `WaitNode` (`nodetool.triggers.Wait`, in
> `packages/automation-nodes/src/nodes/triggers.ts`) is **not** a suspendable
> node. It is a simple in-process delay: `process()` sleeps for
> `timeout_seconds` via `setTimeout`, then passes its input through. It does
> **not** use `SuspendableState`, does **not** suspend or resume the workflow,
> and does **not** wait for any external approval or callback. Use it for
> rate-limiting and fixed delays. For true pause/resume, see
> [Creating Custom Suspendable Nodes](#creating-custom-suspendable-nodes) below.

```typescript
import { WaitNode } from "@nodetool-ai/automation-nodes";

// A node that delays for a fixed number of seconds, then forwards `input`.
const waitNode = new WaitNode();
waitNode.timeout_seconds = 5;  // Seconds to wait (0 = no wait, pass through immediately)
waitNode.input = { request_id: "REQ-123" };
```

### WaitNode Properties

| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `timeout_seconds` | `number` | `0` | Seconds to wait before continuing. `0` = **no wait** (pass through immediately). |
| `input` | `any` | `""` | Input data passed through to the output after the delay |

### WaitNode Output

After the delay, the WaitNode outputs:

```typescript
{
  data: { /* input data passed through */ },
  resumed_at: "2026-03-16T12:00:00.000Z",  // ISO timestamp after the delay
  waited_seconds: 5.0                        // Actual seconds slept
}
```

> The output field names (`resumed_at`, `waited_seconds`) read like suspension
> semantics, but they only reflect the `setTimeout` delay — nothing was
> suspended.

---

## Creating Custom Suspendable Nodes

True suspension is provided by the **`SuspendableState`** helper class (in
`packages/kernel/src/suspendable.ts`, exported from `@nodetool-ai/kernel`).
`SuspendableNode` is the *interface* that describes the capability;
`SuspendableState` is the concrete implementation you compose into a node.
Calling `suspendWorkflow(reason, state, metadata?)` throws a
`WorkflowSuspendedError`, which the runner catches to persist state and pause
the run.

```typescript
import { SuspendableState } from "@nodetool-ai/kernel";
import { BaseNode, prop } from "@nodetool-ai/node-sdk";

class ApprovalNode extends BaseNode {
  static readonly nodeType = "custom.ApprovalNode";

  @prop({ type: "str", default: "" })
  declare document_id: string;

  // Compose a SuspendableState helper for this node
  private _suspend = new SuspendableState(ApprovalNode.nodeType);

  async process(): Promise<Record<string, unknown>> {
    // Check if resuming from suspension
    if (this._suspend.isResuming()) {
      const savedState = this._suspend.getSavedState();

      if (savedState.approved) {
        return {
          status: "approved",
          approved_by: savedState.approved_by,
          approved_at: savedState.approved_at,
        };
      } else {
        return {
          status: "rejected",
          reason: savedState.rejection_reason,
        };
      }
    }

    // First execution — suspend and wait for approval
    this._suspend.suspendWorkflow(
      `Waiting for approval of document ${this.document_id}`,
      {
        document_id: this.document_id,
        submitted_at: new Date().toISOString(),
      },
      {
        approver_email: "admin@example.com",
        timeout_hours: 24,
      },
    );
    // Execution never reaches here on first run.
    // suspendWorkflow() throws WorkflowSuspendedError.
  }
}
```

---

## API Methods

### SuspendableNode Methods

#### `isSuspendable(): boolean`

Returns `true` to indicate this node supports suspension.

#### `isResuming(): boolean`

Check if the node is resuming from a previous suspension.

```typescript
if (this._suspend.isResuming()) {
  // Resumption path — get saved state
  const saved = this._suspend.getSavedState();
} else {
  // First execution path — suspend
  this._suspend.suspendWorkflow(reason, state);
}
```

#### `getSavedState(): Record<string, unknown>`

Get the state that was saved when workflow suspended.

```typescript
const savedState = this._suspend.getSavedState();
const approvalStatus = savedState.approved ?? false;
```

Throws `Error` if called when not resuming.

#### `suspendWorkflow(reason: string, state: Record<string, unknown>, metadata?: Record<string, unknown>): never`

Suspend workflow execution and save state.

```typescript
this._suspend.suspendWorkflow(
  "Waiting for user input",
  { partial_result: computedValue },
  { timeout: 3600 },
);
```

This method:

- Logs `NodeSuspended` event with state
- Logs `RunSuspended` event
- Throws `WorkflowSuspendedError` to exit execution
- Never returns (workflow is suspended)

---

## Suspension Flow

### 1. Initial Execution

```
WorkflowRunner.run()
  ├─> NodeActor executes node
  ├─> node.process() calls suspendWorkflow()
  ├─> WorkflowSuspendedError thrown
  ├─> Runner catches exception
  ├─> Logs NodeSuspended event (with state)
  ├─> Logs RunSuspended event
  ├─> Sends JobUpdate(status="suspended") to frontend
  └─> Exits cleanly
```

### 2. External Resume (via UI or API)

```
User clicks Resume button OR API call to resume endpoint
  ├─> WorkflowRecoveryService.resume_workflow()
  ├─> Loads saved state from event log
  ├─> Logs NodeResumed event
  ├─> Sets node.setResumingState()
  └─> WorkflowRunner.run() continues
```

### 3. Node Resumption

```
NodeActor executes node (resuming=True)
  ├─> node.isResuming() returns true
  ├─> node.getSavedState() returns saved state
  ├─> node.process() continues from saved state
  └─> Workflow completes normally
```

---

## Frontend Integration

When a workflow suspends:

1. **Backend sends** `JobUpdate(status="suspended", message="...")`
2. **Frontend state** changes to `"suspended"`
3. **UI shows**:
   - Notification with suspension reason
   - Purple Resume button in toolbar
   - Stop button remains enabled

When user clicks Resume:

1. **Frontend sends** `resume_job` command via WebSocket
2. **Backend resumes** workflow from saved state
3. **Frontend state** changes back to `"running"`

---

## Best Practices

1. **Always check `isResuming()`** - Handle both first execution and resumption paths
2. **Save minimal state** - Only save what's needed to resume
3. **Use descriptive reasons** - Make suspension reason clear for users
4. **Add metadata** - Include context like timeout, approver email, etc.
5. **Handle timeouts** - Consider what happens if workflow isn't resumed
6. **Test both paths** - Test both suspension and resumption code

---

## Example: Webhook Callback

```typescript
import { SuspendableState } from "@nodetool-ai/kernel";
import { BaseNode, prop } from "@nodetool-ai/node-sdk";

class WebhookWaitNode extends BaseNode {
  static readonly nodeType = "custom.WebhookWaitNode";

  @prop({ type: "str", default: "" })
  declare callback_url: string;

  private _suspend = new SuspendableState(WebhookWaitNode.nodeType);

  async process(): Promise<Record<string, unknown>> {
    if (this._suspend.isResuming()) {
      const state = this._suspend.getSavedState();
      return {
        webhook_id: state.webhook_id,
        callback_data: state.callback_data ?? {},
      };
    }

    // Register webhook and get ID
    const webhookId = await registerWebhook(this.callback_url);

    // Suspend until webhook is called
    this._suspend.suspendWorkflow(
      "Waiting for webhook callback",
      { webhook_id: webhookId, callback_url: this.callback_url },
      { external_service: true },
    );
  }
}
```

---

## See Also

- [Workflow Editor](../workflow-editor.md) - User guide for pause/resume controls
- [Trigger Nodes](node-patterns.md) - Nodes that fire on external events
- [Workflow API](../workflow-api.md) - API endpoints for workflow control
