Package Exports
- @letsping/sdk
Readme
@letsping/sdk
The official Node.js and TypeScript SDK for LetsPing.
LetsPing is a behavioral firewall and human in the loop control plane for agents. It pauses high risk actions, lets a human approve, reject, or patch the payload, then resumes execution.
One file quickstart (dangerous action, dashboard link, 3 outcomes)
This is the smallest end to end pattern. It submits a request, prints the dashboard link, then shows what the agent sees on APPROVED, REJECTED, and APPROVED_WITH_MODIFICATIONS.
import { LetsPing } from "@letsping/sdk";
const lp = new LetsPing(process.env.LETSPING_API_KEY!);
const { id } = await lp.defer({ service: "db-agent", action: "sql", payload: { query: "DROP TABLE users" } });
console.log("Approve or reject in dashboard:", `https://letsping.co/requests/${id}`);
const d = await lp.waitForDecision(id, { originalPayload: { query: "DROP TABLE users" } });
if (d.status === "REJECTED") console.log({ status: "REJECTED", message: "Do not proceed." });
else if (d.status === "APPROVED_WITH_MODIFICATIONS") console.log({ status: d.status, diff_summary: d.diff_summary, executed_payload: d.patched_payload });
else console.log({ status: "APPROVED", executed_payload: d.payload });Why not just build this myself
- Anomaly detection: LetsPing learns baselines and can intercept anomalies before they execute, not just request approvals.
- Escrow and x402: agent to agent settlement and funding flows are handled as part of the control plane so your agent does not need to embed payments logic.
- Receipts: decisions and cryptographic receipts are emitted in machine readable shapes for audits, incident review, and billing.
Observability surfaces
- OpenTelemetry spans: the SDK emits
letsping.askandletsping.deferspans when@opentelemetry/apiis present. Attributes includeletsping.service,letsping.action,letsping.priority, andletsping.request_idwhen available. - Cloudflare Tail Workers receipts: use
@letsping/adapters/cloudflareto publish structured receipts intodiagnostics_channelso they appear in Tail Workers.
What you get with this SDK: One client that connects your agent to the LetsPing control plane, including a hosted dashboard for approvals, state parking for long running flows, and audit trails.
When you should not use this
- You need a full audit log of every token or message. This SDK is designed for tool level approvals, not full conversation logging.
- You only need basic API key checks or RBAC. Use your existing auth layer; LetsPing is for high risk tool boundaries and approvals.
- You want to replace your own identity provider. LetsPing does not replace your auth system. It sits in front of tools and services.
One hour evaluation path
If you want to feel the value in under one meeting:
- Clone the
examples/vercel-ai-toolsor your minimal Node demo. - Point it at a staging Stripe or database if you want to see a real side effect.
- Run three steps:
- Trigger a dangerous action like
delete_accountorDROP TABLE users. - Reject it once in the LetsPing dashboard.
- Approve it once with a patched payload.
- Trigger a dangerous action like
- Look in three places:
- The decision in the LetsPing dashboard.
- The log output or Cloudflare Tail Worker receipt.
- The diff between requested and executed payload.
Advanced features
- Behavioral profiling: Optional Markov based profiling of your agent's execution paths. Detects reasoning anomalies and can flag requests before tools run.
- State parking: Pauses execution and securely uploads large agent state to storage using signed URLs, so long running flows are not blocked by timeouts.
- Baseline adaptation: Approval decisions update the baseline over time. Old unused paths decay automatically via exponential moving average.
- Agent identity and escrow helpers: Optional HMAC helpers (
signAgentCall,verifyEscrow,chainHandoff) for linking agent calls and handoffs to LetsPing requests.
Requirements
- Compatibility: Node.js 18+. TypeScript 5+ recommended.
- (Optional)
@langchain/langgraphand@langchain/corefor state persistence
Installation
npm install @letsping/sdkUsage
Minimal drop-in example
The fastest way to see your first approval in the dashboard:
import { LetsPing } from "@letsping/sdk";
const apiKey = process.env.LETSPING_API_KEY;
if (!apiKey) throw new Error("Missing LETSPING_API_KEY env var.");
const lp = new LetsPing(apiKey);
const decision = await lp.ask({
service: "billing-agent",
action: "refund_user",
payload: { user_id: "u_123", amount: 100 },
});Every example in this README follows the same pattern: either pass the key explicitly or rely on LETSPING_API_KEY via env.
Agent path (self-serve + signed ingest)
For headless agents that get their own workspace and send signed ingest without a human:
createAgentWorkspace(options?)— Request token → redeem → register in one call. Returns{ project_id, api_key, ingest_url, agent_id, agent_secret }so the agent gets its own workspace. Rate limits apply; see agent quickstart.ingestWithAgentSignature(agentId, agentSecret, payload, options)— POST a signed ingest with built-in retries and no hand-rolled HMAC. Errors are thrown asLetsPingErrorwithcode(e.g.LETSPING_402_QUOTA,LETSPING_429_RATE_LIMIT) anddocumentationUrlfor branching or user-facing links.
Blocking Request (ask)
Execution suspends until the request is approved, rejected, or times out.
import { LetsPing } from "@letsping/sdk";
const lp = new LetsPing(process.env.LETSPING_API_KEY!);
async function processRefund(userId: string, amount: number) {
try {
const decision = await lp.ask({
service: "billing-agent",
action: "refund_user",
priority: "high",
payload: { userId, amount },
// Optional: JSON Schema to render an editable form in the dashboard
// (If using Zod: convert via zodToJsonSchema(mySchema))
schema: {
type: "object",
properties: {
amount: { type: "number", maximum: 5000 }
},
required: ["amount"]
},
// Optional override (default: 24 hours)
timeoutMs: 30 * 60 * 1000, // 30 minutes
});
if (decision.status === "APPROVED") {
// Prefer patched_payload if human edited values
const data = decision.patched_payload ?? decision.payload;
await stripe.refunds.create({
charge: data.userId,
amount: Math.round(data.amount * 100),
});
console.log("Refund executed");
} else {
console.log(`Refund ${decision.status.toLowerCase()} by operator`);
}
} catch (error) {
console.error("Approval failed or timed out:", error);
}
}Non-Blocking Request (defer)
Queues the request immediately and returns; ideal for serverless or event-driven flows.
const { id } = await lp.defer({
service: "notification-agent",
action: "send_email",
payload: {
to: "user@example.com",
subject: "Your invoice is ready",
amount: 249.99
},
priority: "medium",
// Optional: Pass the full LangGraph/Vercel state dict.
// It will be encrypted client-side and uploaded directly to S3.
state_snapshot: agentState
});
console.log(`Approval request queued → ${id}`);Webhook Rehydration (Next.js Example)
When you pass state_snapshot to ask / defer, the SDK:
- Encrypts the snapshot with either
LETSPING_ENCRYPTION_KEYor a one‑time DEK. - Uploads it directly to your storage bucket using a signed URL.
- Includes a
state_download_url(and DEK) in subsequent webhooks.
You can use the built‑in webhookHandler to validate and hydrate webhooks in a Next.js App Router route:
// Example Next.js App Router route
import { NextRequest, NextResponse } from "next/server";
import { LetsPing } from "@letsping/sdk";
const lp = new LetsPing();
const WEBHOOK_SECRET = process.env.LETSPING_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const signature = req.headers.get("x-letsping-signature") || "";
try {
const { id, event, data, state_snapshot } = await lp.webhookHandler(
rawBody,
signature,
WEBHOOK_SECRET
);
// At this point:
// - `data` contains the decision payload (status, payload, patched_payload, metadata, etc.)
// - `state_snapshot` contains your decrypted agent state, if Cryo-Sleep was used.
await handleDecision({ id, event, data, state_snapshot });
return NextResponse.json({ ok: true });
} catch (err: any) {
console.error("LetsPing webhook error:", err);
return NextResponse.json({ error: "invalid webhook" }, { status: 400 });
}
}
async function handleDecision(args: {
id: string;
event: string;
data: any;
state_snapshot?: Record<string, any>;
}) {
// Example: resume a workflow run or LangGraph thread using `state_snapshot`
}This pattern works similarly for Express/Fastify — call lp.webhookHandler(rawBody, signature, secret), then resume your framework using the provided state_snapshot.
LangGraph Integration (Persisted State)
LetsPing provides a LetsPingCheckpointer for LangGraph JS/TS under @letsping/sdk/integrations/langgraph.
In v0.2 this checkpointer persists checkpoints remotely via the LetsPing control plane — encrypted alongside your existing Cryo‑Sleep state in Supabase Storage. Threads can survive process restarts without you wiring your own database.
import { StateGraph } from "@langchain/langgraph";
import { LetsPing } from "@letsping/sdk";
import { LetsPingCheckpointer } from "@letsping/sdk/integrations/langgraph";
const lp = new LetsPing(process.env.LETSPING_API_KEY!);
const checkpointer = new LetsPingCheckpointer(lp);
const builder = new StateGraph<any /* your state type */>({});
const graph = builder.compile({ checkpointer });Auto‑resuming a thread after approval (webhook + checkpointer)
Because checkpoints are stored remotely, you can resume a LangGraph thread from any worker once a human clicks Approve. A minimal Next.js webhook + auto‑resume flow looks like:
// Example Next.js App Router route for LangGraph auto-resume
import { NextRequest, NextResponse } from "next/server";
import { LetsPing } from "@letsping/sdk";
import { StateGraph } from "@langchain/langgraph";
import { LetsPingCheckpointer } from "@letsping/sdk/integrations/langgraph";
import { graphBuilder } from "@/lib/langgraph"; // your app's graph definition
const lp = new LetsPing(process.env.LETSPING_API_KEY!);
const checkpointer = new LetsPingCheckpointer(lp);
const graph = graphBuilder.compile({ checkpointer });
export async function POST(req: NextRequest) {
const raw = await req.text();
const sig = req.headers.get("x-letsping-signature") || "";
const event = await lp.webhookHandler(raw, sig, process.env.LETSPING_WEBHOOK_SECRET!);
const { data, state_snapshot } = event;
// You decide how to encode the thread id into your state snapshot.
const threadId = state_snapshot?.thread_id as string | undefined;
if (!threadId) return NextResponse.json({ ok: false, error: "missing_thread_id" }, { status: 400 });
// Resume the graph from the latest remote checkpoint.
await graph.invoke(state_snapshot.input, {
configurable: { thread_id: threadId },
});
return NextResponse.json({ ok: true });
}In your agent runner, you simply include thread_id and state_snapshot when you first call LetsPing from inside a LangGraph node. The checkpointer and webhook then keep the thread resumable across restarts. If the human edited the payload in the dashboard, data.patched_payload (or data.payload) is available in the webhook payload — use your framework’s normal state-update or channel overwrite semantics to inject the approved payload into the resumed graph so the run sees the correct values.
API Reference
new LetsPing(apiKey, options?)
apiKey(string) — required — Service Role or Project API key from LetsPing dashboardoptions.baseUrl(string) — optional — Override endpoint (self-hosted / staging)
lp.ask(options): Promise<Decision>
Blocks until resolved (approve / reject / timeout).
| Property | Type | Description |
|---|---|---|
service |
string |
Service / module identifier (e.g. "billing", "compliance") |
action |
string |
Action name (e.g. "refund", "transfer_funds") |
payload |
Record<string, any> |
Context passed to human operator (and returned in Decision) |
priority |
"low" | "medium" | "high" | "critical" |
Routing priority in dashboard |
schema |
object |
JSON Schema (draft 07) — generates editable form in dashboard |
timeoutMs |
number |
Max wait time in milliseconds (default: 86_400_000 ms = 24 hours) |
lp.defer(options): Promise<{ id: string }>
Fire-and-forget: queues request and returns request ID immediately. Same options shape as ask.
Decision Type
interface Decision {
status: "APPROVED" | "REJECTED" | "APPROVED_WITH_MODIFICATIONS";
payload: Record<string, any>; // Original payload sent by agent
patched_payload?: Record<string, any>; // Human-edited values (if modified)
diff_summary?: any; // Field-level diff between payload and patched_payload
metadata?: {
actor_id: string; // ID/email of the approving/rejecting human
resolved_at: string; // ISO 8601 timestamp
method?: string; // Optional resolution method (e.g. "dashboard")
};
}Structured errors: All API and network errors are thrown as LetsPingError with optional status, code (e.g. LETSPING_402_QUOTA, LETSPING_429_RATE_LIMIT, LETSPING_TIMEOUT), and documentationUrl so you can branch or log and link users to the right doc. See https://letsping.co/docs#errors.
Optional retries: Pass retry: { maxAttempts: 3, initialDelayMs: 1000, maxDelayMs: 10000 } in the constructor to enable exponential backoff for ingest and status calls (429 and 5xx are retried).
Status helper: Use lp.getRequestStatus(id) after defer() to poll for request status without calling the raw HTTP API. See https://letsping.co/docs#requests.
For full documentation, request schema examples, and dashboard integration see:
https://letsping.co/docs#sdk
Agent-to-Agent Escrow (optional)
For multi-agent systems that want cryptographic guarantees around handoffs, the SDK exposes:
signAgentCall(agentId, secret, call)to attachagent_idandagent_signatureto/ingestcalls.signIngestBody(agentId, secret, body)to take an existing ingest body ({ project_id, service, action, payload }) and return it withagent_idandagent_signatureattached.verifyEscrow(event, secret)to validate LetsPing escrow webhooks.chainHandoff(previous, nextData, secret)to safely construct downstream handoffs tied to the original request id.
See the one-page spec at /docs/agent-escrow-spec in the LetsPing web app for the exact wire format and interoperability rules.
Deploy agents with confidence.
2-Minute Demo (Node/TypeScript)
You can feel the full LetsPing loop (intercept → approve → resume) in under 2 minutes.
// demo.ts
import { LetsPing } from "@letsping/sdk";
async function main() {
const apiKey = process.env.LETSPING_API_KEY;
if (!apiKey) {
console.error("Missing LETSPING_API_KEY env var.");
process.exit(1);
}
const lp = new LetsPing(apiKey);
console.log("Sending demo approval request to LetsPing…");
const decision = await lp.ask({
service: "demo-agent",
action: "transfer_funds",
priority: "high",
payload: {
amount: 500,
currency: "USD",
recipient: "acct_demo_123",
},
});
if (decision.status === "REJECTED") {
console.log("Demo request REJECTED by human. No action taken.");
} else if (decision.status === "APPROVED_WITH_MODIFICATIONS") {
console.log("APPROVED WITH MODIFICATIONS:");
console.dir(decision.diff_summary, { depth: null });
} else {
console.log("APPROVED with original payload.");
}
}
main().catch((err) => {
console.error("Demo failed:", err);
process.exit(1);
});Run:
export LETSPING_API_KEY="lp_live_..."
node demo.tsThen open the LetsPing dashboard for your project, approve/reject the demo-agent / transfer_funds request, and watch the script resume.
If you’re using the local tunnel (npx @letsping/cli dev), you can also point the SDK at it during local development:
const lp = new LetsPing(process.env.LETSPING_API_KEY!, {
baseUrl: "http://localhost:<port>/api",
});All ask / defer calls made through that client will flow through your local tunnel into the LetsPing dashboard.
Compatibility: Node 18+, TypeScript 5+. Optional: @langchain/langgraph, @langchain/core for LangGraph integration.
License: MIT. Source: CordiaLabs/LetsPing (packages/sdk).