NullSpend Docs

Human-in-the-Loop Approvals

Agents pause before sensitive operations and wait for human approval. NullSpend provides the coordination layer — your agent proposes an action, a human appro

Agents pause before sensitive operations and wait for human approval. NullSpend provides the coordination layer — your agent proposes an action, a human approves or rejects it, and the agent proceeds or stops.

How It Works

Agent                    NullSpend                  Human
  │                         │                         │
  ├─ POST /api/actions ────►│                         │
  │                         ├─ Slack notification ───►│
  │                         │                         │
  │   (polls GET /api/      │    Reviews in dashboard │
  │    actions/:id)         │                         │
  │◄────────────────────────┤◄── Approves ────────────┤
  │                         │                         │
  ├─ Executes action        │                         │
  │                         │                         │
  ├─ POST result ──────────►│                         │
  │                         │                         │

NullSpend does not execute the action itself. It manages the approval workflow — state transitions, polling, expiration, and notifications. Your agent is responsible for performing the actual operation after receiving approval.

State Machine

pending ──► approved ──► executing ──► executed (terminal)
  │              │             │
  │              │             └──────► failed   (terminal)
  ├──► rejected (terminal)
  └──► expired  (terminal)
FromToTriggered By
pendingapprovedHuman approves in dashboard
pendingrejectedHuman rejects in dashboard
pendingexpiredTTL elapses
approvedexecutingAgent calls markResult({ status: "executing" })
executingexecutedAgent calls markResult({ status: "executed", result })
executingfailedAgent calls markResult({ status: "failed", errorMessage })

Terminal states (rejected, expired, executed, failed) cannot transition further. Attempting an invalid transition returns 409 with error.code: "invalid_action_transition".

Action Types

TypeDescription
send_emailSend an email
http_postMake an HTTP POST request
http_deleteMake an HTTP DELETE request
shell_commandExecute a shell command
db_writeWrite to a database
file_writeWrite to a file
file_deleteDelete a file

Action types are informational labels — NullSpend does not enforce or validate what the agent actually does after approval. Only the values listed above are accepted; arbitrary strings are rejected by validation.

Quick Start with the SDK

The proposeAndWait method handles the full lifecycle: create action, poll for decision, execute on approval, report result. See the JavaScript SDK reference for full method signatures and configuration options.

import { NullSpend, RejectedError, TimeoutError } from "@nullspend/sdk";

const ns = new NullSpend({
  baseUrl: "https://nullspend.dev",
  apiKey: "ns_live_sk_...",
});

try {
  const result = await ns.proposeAndWait({
    agentId: "support-agent",
    actionType: "send_email",
    payload: {
      to: "customer@example.com",
      subject: "Refund Confirmation",
      body: "Your refund of $49.99 has been processed.",
    },
    metadata: {
      ticketId: "TICKET-1234",
      refundAmount: 4999,
    },
    expiresInSeconds: 1800, // 30 minutes

    execute: async ({ actionId }) => {
      // This runs only after human approval.
      // Use actionId as X-NullSpend-Action-Id header to correlate costs.
      const response = await sendEmail({
        to: "customer@example.com",
        subject: "Refund Confirmation",
        body: "Your refund of $49.99 has been processed.",
      });
      return { messageId: response.id };
    },
  });

  console.log("Email sent:", result.messageId);
} catch (err) {
  if (err instanceof RejectedError) {
    console.log(`Action ${err.actionId} was ${err.actionStatus}`);
  } else if (err instanceof TimeoutError) {
    console.log("No decision within timeout");
  } else {
    throw err;
  }
}

Python equivalent (raw HTTP):

import time
import requests

BASE = "https://nullspend.dev"
HEADERS = {"X-NullSpend-Key": "ns_live_sk_..."}

# 1. Create the action
action = requests.post(f"{BASE}/api/actions", headers=HEADERS, json={
    "agentId": "support-agent",
    "actionType": "send_email",
    "payload": {"to": "customer@example.com", "subject": "Refund Confirmation"},
    "expiresInSeconds": 1800,
}).json()

action_id = action["id"]

# 2. Poll for decision
while True:
    status = requests.get(f"{BASE}/api/actions/{action_id}", headers=HEADERS).json()
    if status["status"] != "pending":
        break
    time.sleep(2)

# 3. Execute if approved
if status["status"] == "approved":
    requests.post(f"{BASE}/api/actions/{action_id}/result", headers=HEADERS,
                  json={"status": "executing"})
    try:
        result = send_email(...)
        requests.post(f"{BASE}/api/actions/{action_id}/result", headers=HEADERS,
                      json={"status": "executed", "result": {"messageId": result.id}})
    except Exception as e:
        requests.post(f"{BASE}/api/actions/{action_id}/result", headers=HEADERS,
                      json={"status": "failed", "errorMessage": str(e)})

Step-by-Step (Low-Level)

If you need more control than proposeAndWait, use the low-level methods directly.

1. Create an action

const { id, expiresAt } = await ns.createAction({
  agentId: "data-pipeline",
  actionType: "db_write",
  payload: { query: "DELETE FROM users WHERE inactive_days > 365" },
  metadata: { estimatedRows: 1200 },
  expiresInSeconds: 3600,
});

2. Wait for a decision

const decision = await ns.waitForDecision(id, {
  pollIntervalMs: 2000,  // default: 2000 (2 seconds)
  timeoutMs: 300000,     // default: 300000 (5 minutes)
  onPoll: (action) => console.log(`Status: ${action.status}`),
});

The SDK polls GET /api/actions/:id every pollIntervalMs until the status leaves pending or the timeout elapses.

3. Execute and report

if (decision.status === "approved") {
  await ns.markResult(id, { status: "executing" });

  try {
    const result = await performDatabaseWrite();
    await ns.markResult(id, { status: "executed", result: { rowsDeleted: 1200 } });
  } catch (err) {
    await ns.markResult(id, { status: "failed", errorMessage: err.message });
  }
}

See the Actions API for full request/response schemas.

Expiration

Actions expire automatically if no decision is made within the TTL.

expiresInSeconds ValueBehavior
Omitted / undefinedDefault: 3600 seconds (1 hour)
0 or nullNo expiration — action stays pending indefinitely
Positive numberExpires in that many seconds from creation

Maximum expiration is 30 days (2,592,000 seconds).

When an action expires, its status transitions to expired and an action.expired webhook is fired. The SDK's waitForDecision resolves with the expired action (it doesn't throw — check action.status). proposeAndWait throws a RejectedError with actionStatus: "expired".

Notifications

Slack

If Slack is configured (Settings → Slack), NullSpend sends a notification to your channel when an action is created. This is fire-and-forget — Slack delivery failures don't affect the action.

Webhooks

Four webhook event types cover the action lifecycle:

EventFires When
action.createdAction is created (status: pending)
action.approvedHuman approves the action
action.rejectedHuman rejects the action
action.expiredTTL elapses without a decision

See Event Types for full payload examples.

Dashboard

The Actions page in the dashboard shows all actions with their current status. You can:

  • Filter by status (pending, approved, rejected, expired, executing, executed, failed)
  • Approve or reject pending actions with one click
  • View the action payload, metadata, and result
  • See associated cost events (when the agent sends X-NullSpend-Action-Id with subsequent requests)

Error Handling

SDK Errors

ErrorWhen
RejectedErrorproposeAndWait: action was rejected or expired. Check err.actionStatus.
TimeoutErrorwaitForDecision or proposeAndWait: no decision within timeoutMs.
NullSpendErrorNetwork errors, invalid responses, or API errors. Check err.statusCode and err.code.

API Error Codes

CodeStatusMeaning
invalid_action_transition409Invalid state transition (e.g., approving an already-rejected action)
action_expired409Action has expired
stale_action409Concurrent modification detected
not_found404Action doesn't exist or belongs to another user

Best Practices

  • Set an expiration. Pending actions that never resolve waste attention. Default is 1 hour; use shorter TTLs for time-sensitive operations.
  • Use metadata for context. Include enough information for the reviewer to make a decision without leaving the dashboard — ticket IDs, affected records, estimated impact.
  • Handle rejection gracefully. Your agent should have a fallback path when an action is rejected — not just crash.
  • Correlate costs with X-NullSpend-Action-Id. After approval, send the action ID as a header on subsequent proxy requests to link cost events to the approved action.
  • Use onPoll for logging. Track how long your agent waits and whether decisions are taking longer than expected.
  • Actions API — full endpoint reference for creating, polling, approving, rejecting, and reporting results
  • Webhook Event Typesaction.created, action.approved, action.rejected, action.expired payloads
  • Budgets — budget enforcement that might trigger HITL workflows for high-cost operations
  • JavaScript SDK — full NullSpend client reference with proposeAndWait, waitForDecision, and error classes
  • MCP Server — MCP server exposing propose_action and check_action tools
  • MCP Proxy — MCP proxy that gates upstream tool calls through approval

On this page