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 ────►│                         │
  │                         ├─ Webhook fan-out ──────►│
  │                         │                         │
  │   (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
budget_increaseRequest approval to raise a NullSpend budget ceiling (used by request_budget_increase in the SDK + MCP server)

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:

from nullspend import NullSpend, ProposeAndWaitOptions, RejectedError, PollTimeoutError

ns = NullSpend(api_key="ns_live_sk_...")

try:
    result = ns.propose_and_wait(ProposeAndWaitOptions(
        agent_id="support-agent",
        action_type="send_email",
        payload={
            "to": "customer@example.com",
            "subject": "Refund Confirmation",
            "body": "Your refund of $49.99 has been processed.",
        },
        metadata={"ticket_id": "TICKET-1234"},
        expires_in_seconds=1800,
        execute=lambda ctx: send_email(
            to="customer@example.com",
            subject="Refund Confirmation",
        ),
    ))
    print(f"Email sent: {result}")
except RejectedError as err:
    print(f"Action {err.action_id} was {err.action_status}")
except PollTimeoutError:
    print("No decision within timeout")

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 effective expiration is 7 days (604,800 seconds). The validator currently accepts up to 30 days, but computeExpiresAt clamps any value above 7 days to 7 days — a value of 2_592_000 is silently treated as 604_800.

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

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