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:
proxyUrlis set on theNullSpendconstructor 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- The request carries an
x-nullspend-keyheader (set on eitherinit.headersor the underlyingRequestobject'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.tsThen open http://localhost:3000/app/inbox and approve the action.