JSPM

@nullspend/sdk

0.4.0
  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 16
  • Score
    100M100P100Q73527F
  • License Apache-2.0

TypeScript SDK for NullSpend — propose, approve, and execute risky AI agent actions

Package Exports

  • @nullspend/sdk

Readme

@nullspend/sdk

TypeScript SDK for NullSpend — propose, approve, and execute risky AI agent actions.

Quick start

import { NullSpend } from "@nullspend/sdk";

const seam = new NullSpend({
  baseUrl: "http://localhost:3000",
  apiKey: "ns_live_sk_...", // from your Settings page
});

const result = await seam.proposeAndWait({
  agentId: "my-agent",
  actionType: "send_email",
  payload: { to: "sarah@example.com", subject: "Follow up" },
  execute: async () => {
    return await sendEmail("sarah@example.com", "Follow up", "...");
  },
});

The SDK creates a pending action in NullSpend, polls until a human approves or rejects it, then either runs your execute callback or throws.

API

new NullSpend(config)

Option Type Required Description
baseUrl string Yes URL of your NullSpend instance
apiKey string Yes API key from Settings
fetch fetch No Custom fetch implementation (defaults to global)
requestTimeoutMs number No Per-request timeout in ms (default: 30000). Set to 0 to disable.

seam.proposeAndWait(options)

High-level: proposes an action, waits for approval, executes, and reports the result.

Option Type Default Description
agentId string required Identifier for the agent
actionType string required e.g. send_email, http_post
payload Record<string, unknown> required Action details shown in the inbox
metadata Record<string, unknown> optional Extra context (environment, etc.)
execute (context?: ExecuteContext) => T | Promise<T> required Runs only if approved. Receives { actionId } for cost correlation.
expiresInSeconds number | null optional Server-side TTL. Omit for default (1 hour). Set to 0 or null for never-expire.
pollIntervalMs number 2000 ms between status polls
timeoutMs number 300000 Total timeout in ms
onPoll (action: ActionRecord) => void optional Called each poll cycle

Lower-level methods

// createAction returns { id, status: "pending", expiresAt: string | null }
const action = await seam.createAction({ agentId, actionType, payload, expiresInSeconds: 600 });
const fetched = await seam.getAction(actionId);
const decided = await seam.waitForDecision(actionId, { pollIntervalMs, timeoutMs });
await seam.markResult(actionId, { status: "executed", result: { ... } });

Cost correlation

When using the NullSpend proxy for LLM calls, pass the actionId from the execute context as a header to link cost events to the action:

const result = await seam.proposeAndWait({
  agentId: "my-agent",
  actionType: "http_post",
  payload: { prompt: "Summarize this document" },
  execute: async (context) => {
    const res = await fetch("https://proxy.nullspend.dev/v1/chat/completions", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "x-nullspend-action-id": context?.actionId ?? "",
      },
      body: JSON.stringify({ model: "gpt-4o", messages: [{ role: "user", content: "Hello" }] }),
    });
    return res.json();
  },
});

The linked cost data will appear on the action detail page in the dashboard.

Tracked Fetch (Provider Wrappers)

Wrap your LLM provider's fetch to automatically track costs and enforce policies client-side:

import { NullSpend, SessionLimitExceededError, BudgetExceededError, MandateViolationError } from "@nullspend/sdk";
import OpenAI from "openai";

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

// Basic cost tracking
const openai = new OpenAI({ fetch: ns.createTrackedFetch("openai") });

// With enforcement: budget, mandates, and session limits
const enforced = new OpenAI({
  fetch: ns.createTrackedFetch("openai", {
    enforcement: true,
    sessionId: "task-042",
    sessionLimitMicrodollars: 5_000_000, // $5 per session
    tags: { team: "backend" },
  }),
});

Providers: "openai" and "anthropic". Cost is calculated locally using the built-in pricing engine. With enforcement: true, the SDK checks model mandates, budget, and session limits before each request — throwing MandateViolationError, BudgetExceededError, or SessionLimitExceededError if policy is violated.

When using the proxy, the SDK also intercepts proxy-side 429 denials and throws typed errors:

Error Proxy code When
BudgetExceededError budget_exceeded Key/user/org budget exhausted
BudgetExceededError (entityType: "customer") customer_budget_exceeded Customer-scoped budget exhausted
VelocityExceededError velocity_exceeded Spend rate exceeds velocity limit
SessionLimitExceededError session_limit_exceeded Session spend cap reached
TagBudgetExceededError tag_budget_exceeded Tag-level budget exhausted
LoopDetectedError loop_detected Same model+content called 50+ times in 60s

All denial types fire the onDenied callback before throwing. Proxy 429s with non-NullSpend codes (e.g. an upstream OpenAI rate limit forwarded through the proxy) pass through as raw Response objects.

Every denial error includes an optional recovery property with machine-readable hints:

try {
  await trackedFetch(url, init);
} catch (err) {
  if (err instanceof BudgetExceededError) {
    console.log(err.recovery?.retryable);          // false
    console.log(err.recovery?.ownerActionRequired); // true — needs human/config action
    console.log(err.recovery?.retryAfterSeconds);   // null (not retryable)
    console.log(err.recovery?.docs);                // docs URL or null
  }
}

The Recovery type is exported from @nullspend/sdk for consumers who want to type it. The recovery property is undefined when connecting to an older proxy that doesn't include it.

Proxy detection — the SDK takes the proxied path (skipping client-side cost tracking to avoid double-counting against the proxy's own write) when EITHER:

  1. proxyUrl is set on the NullSpend constructor AND the request URL's origin matches it (strict on scheme + host + port — include the port if your proxy uses a non-default one), OR
  2. The request carries an x-nullspend-key header (set on either init.headers or the underlying Request object's headers).

See the full SDK docs for TrackedFetchOptions reference.

Error handling

import {
  RejectedError,
  TimeoutError,
  NullSpendError,
  BudgetExceededError,
  LoopDetectedError,
  MandateViolationError,
  SessionLimitExceededError,
  VelocityExceededError,
  TagBudgetExceededError,
} from "@nullspend/sdk";

try {
  await seam.proposeAndWait({ ... });
} catch (err) {
  if (err instanceof RejectedError) {
    // Human rejected (or action expired)
  } else if (err instanceof TimeoutError) {
    // No decision within timeoutMs
  } else if (err instanceof LoopDetectedError) {
    // Agent stuck in loop — err.model, err.callCount, err.detectionType
    // err.recovery?.retryable is true (retry after backoff)
  } else if (err instanceof VelocityExceededError) {
    // Spending too fast — err.retryAfterSeconds, err.limitMicrodollars
    // err.recovery?.retryable is true, err.recovery?.retryAfterSeconds has the wait
  } else if (err instanceof TagBudgetExceededError) {
    // Tag budget exhausted — err.tagKey, err.tagValue, err.remainingMicrodollars
    // err.recovery?.ownerActionRequired is true
  } else if (err instanceof NullSpendError) {
    // API error (err.statusCode has the HTTP status)
  }
}

Loop Detection

Detects agents stuck in infinite loops — repeated identical calls that burn budget without progress.

Proxy users: Loop detection is on by default. If your agent calls the same model with identical content 50+ times in 60 seconds, the proxy returns a 429 with code: "loop_detected". No configuration needed.

SDK users: Opt in with one line:

const openai = new OpenAI({
  fetch: ns.createTrackedFetch("openai", { loopDetection: true }),
});

Customize thresholds:

const openai = new OpenAI({
  fetch: ns.createTrackedFetch("openai", {
    loopDetection: {
      maxCalls: 100,        // higher for batch workloads
      windowSeconds: 120,   // wider window
    },
  }),
});

Disabling: Set loopMaxCalls: 0 on the budget entity via the API or dashboard.

Multi-model detection: If 5+ distinct model patterns each show 3+ repeated calls in the window, aggregate loop detection triggers — catching agents that alternate between models while stuck.

Demos

Three runnable demos in examples/ show the approval loop for different action types:

Demo Action type What it does
demo-send-email.ts send_email Simulates sending an email (no real mail sent)
demo-http-post.ts http_post POSTs a CRM lead payload to a real public API
demo-shell-command.ts shell_command Executes a safe shell command on the host
# Terminal 1: start the app
pnpm dev

# Terminal 2: run any demo
NULLSPEND_API_KEY=ns_live_sk_... pnpm tsx packages/sdk/examples/demo-send-email.ts
NULLSPEND_API_KEY=ns_live_sk_... pnpm tsx packages/sdk/examples/demo-http-post.ts
NULLSPEND_API_KEY=ns_live_sk_... pnpm tsx packages/sdk/examples/demo-shell-command.ts

Then open http://localhost:3000/app/inbox and approve the action.