@letsping/sdk 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.3.0] - 2026-02-28
9
+
10
+ ### Added
11
+ - First-run experience: single-command demo path and dashboard "Run this" flow for time to first approval.
12
+ - Framework-specific examples: LangGraph + Next.js, Vercel AI SDK + tools, Python + FastAPI (clone, set key, run).
13
+ - Agent path in SDK: helpers for agent workspace creation and signed ingest so agent quickstart does not require raw curl/HMAC.
14
+ - Ergonomic improvements: structured error codes with documentation links, JSDoc "See also" on key methods, optional retries and status helper for defer flows.
15
+ - README "Guides" section: HITL in 2 min, LangGraph, Vercel AI SDK, agent-only, webhooks (links to docs and examples).
16
+
17
+ ### Changed
18
+ - Compatibility: Node.js 18+ (unchanged). All packages aligned to 0.3.0 for coordinated release; public CordiaLabs/LetsPing repo synced with examples and READMEs.
19
+
20
+ ## [0.2.1] - 2025-02-28
21
+
22
+ ### Changed
23
+ - Package metadata: repository, homepage, license, keywords, engines (Node 18+).
24
+
25
+ ## [0.2.0] - 2025-02
26
+
27
+ ### Added
28
+ - LangGraph integration (`@letsping/sdk/integrations/langgraph`) for state persistence and HITL.
29
+ - Agent identity and escrow helpers: `signAgentCall`, `verifyEscrow`, `chainHandoff`.
30
+ - Cryo-Sleep state parking with signed URLs.
31
+ - Behavioral firewall (Markov-based anomaly detection) and smart-accept drift.
32
+
33
+ ### Changed
34
+ - Improved TypeScript types and exports.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 LetsPing / Cordia Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -4,6 +4,8 @@ The official Node.js/TypeScript SDK for [LetsPing](https://letsping.co).
4
4
 
5
5
  LetsPing is a behavioral firewall and Human-in-the-Loop (HITL) infrastructure layer for Agentic AI. It provides mathematically secure state-parking (Cryo-Sleep) and execution governance for autonomous agents built on frameworks like LangGraph, Vercel AI SDK, and custom architectures.
6
6
 
7
+ **What you get with this SDK:** One client that connects your agent to the full LetsPing stack: a hosted dashboard for triage and approvals, a Markov-based behavioral firewall that learns your graph and intercepts anomalies, Cryo-Sleep state parking so long-running flows survive serverless limits, and audit trails for compliance. Use LangGraph (or any runtime) for the graph; use LetsPing for the human layer and guardrails.
8
+
7
9
  ### Features
8
10
  - **The Behavioral Shield:** Silently profiles your agent's execution paths via Markov Chains. Automatically intercepts 0-probability reasoning anomalies (hallucinations/prompt injections).
9
11
  - **Cryo-Sleep State Parking:** Pauses execution and securely uploads massive agent states directly to storage using Signed URLs, entirely bypassing serverless timeouts and webhook payload limits.
@@ -11,8 +13,8 @@ LetsPing is a behavioral firewall and Human-in-the-Loop (HITL) infrastructure la
11
13
  - **Agent Identity & Escrow Helpers:** Optional HMAC-based helpers (`signAgentCall`, `verifyEscrow`, `chainHandoff`) for cryptographically linking agent calls and handoffs to LetsPing requests.
12
14
 
13
15
  ## Requirements
14
- - Node.js 18+
15
- - TypeScript 5+ (recommended)
16
+
17
+ - **Compatibility:** Node.js 18+. TypeScript 5+ recommended.
16
18
  - (Optional) `@langchain/langgraph` and `@langchain/core` for state persistence
17
19
 
18
20
  ## Installation
@@ -263,13 +265,21 @@ interface Decision {
263
265
  }
264
266
  ```
265
267
 
266
- For full documentation, request schema examples, error codes, and dashboard integration see:
268
+ **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.
269
+
270
+ **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).
271
+
272
+ **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.
273
+
274
+ For full documentation, request schema examples, and dashboard integration see:
267
275
  https://letsping.co/docs#sdk
268
276
 
269
277
  ### Agent-to-Agent Escrow (optional)
270
278
 
271
279
  For multi-agent systems that want cryptographic guarantees around handoffs, the SDK exposes:
272
280
 
281
+ - `createAgentWorkspace(options?)` to do 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 without a human. Rate limits apply; see [agent quickstart](https://letsping.co/agent/quickstart).
282
+ - `ingestWithAgentSignature(agentId, agentSecret, payload, options)` to POST a signed ingest (no hand-rolled HMAC or curl). Options: `{ projectId, ingestUrl, apiKey }`.
273
283
  - `signAgentCall(agentId, secret, call)` to attach `agent_id` and `agent_signature` to `/ingest` calls.
274
284
  - `signIngestBody(agentId, secret, body)` to take an existing ingest body (`{ project_id, service, action, payload }`) and return it with `agent_id` and `agent_signature` attached.
275
285
  - `verifyEscrow(event, secret)` to validate LetsPing escrow webhooks.
@@ -341,4 +351,10 @@ const lp = new LetsPing(process.env.LETSPING_API_KEY!, {
341
351
  });
342
352
  ```
343
353
 
344
- All `ask` / `defer` calls made through that client will flow through your local tunnel into the LetsPing dashboard.
354
+ All `ask` / `defer` calls made through that client will flow through your local tunnel into the LetsPing dashboard.
355
+
356
+ ---
357
+
358
+ **Compatibility:** Node 18+, TypeScript 5+. Optional: `@langchain/langgraph`, `@langchain/core` for LangGraph integration.
359
+
360
+ **License:** MIT. Source: [CordiaLabs/LetsPing](https://github.com/CordiaLabs/LetsPing) (packages/sdk).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@letsping/sdk",
3
- "version": "0.2.1",
4
- "description": "Behavioral Firewall and Cryo-Sleep State Parking for Autonomous Agents",
3
+ "version": "0.3.0",
4
+ "description": "Agent trust layer: behavioral firewall, HITL, and Cryo-Sleep state for AI agents. Works with LangGraph, Vercel AI SDK, and custom runners.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
7
7
  "types": "./dist/index.d.ts",
@@ -22,6 +22,22 @@
22
22
  "dev": "tsup --watch",
23
23
  "clean": "rm -rf dist .turbo"
24
24
  },
25
+ "engines": {
26
+ "node": ">=18.0.0"
27
+ },
28
+ "homepage": "https://letsping.co",
29
+ "license": "MIT",
30
+ "keywords": [
31
+ "letsping",
32
+ "agent",
33
+ "hitl",
34
+ "human-in-the-loop",
35
+ "behavioral-firewall",
36
+ "langgraph",
37
+ "vercel-ai",
38
+ "cryo-sleep",
39
+ "state-parking"
40
+ ],
25
41
  "peerDependencies": {
26
42
  "@langchain/core": ">=0.1.52",
27
43
  "@langchain/langgraph": ">=0.0.1",
@@ -48,5 +64,10 @@
48
64
  },
49
65
  "publishConfig": {
50
66
  "access": "public"
67
+ },
68
+ "repository": {
69
+ "type": "git",
70
+ "url": "https://github.com/CordiaLabs/LetsPing.git",
71
+ "directory": "packages/sdk"
51
72
  }
52
- }
73
+ }
package/src/index.d.ts CHANGED
@@ -27,6 +27,8 @@ export interface EscrowEnvelope {
27
27
  handoff_signature: string | null;
28
28
  upstream_agent_id: string | null;
29
29
  downstream_agent_id: string | null;
30
+ x402_mandate?: any;
31
+ ap2_mandate?: any;
30
32
  };
31
33
  }
32
34
  export declare function verifyEscrow(event: EscrowEnvelope, secret: string): boolean;
@@ -54,6 +56,35 @@ export declare function signIngestBody(agentId: string, secret: string, body: {
54
56
  agent_signature: string;
55
57
  };
56
58
  export declare function verifyAgentSignature(agentId: string, secret: string, call: AgentCallPayload, signature: string): boolean;
59
+
60
+ export interface AgentWorkspaceCredentials {
61
+ project_id: string;
62
+ api_key: string;
63
+ ingest_url: string;
64
+ agents_register_url: string;
65
+ agent_id: string;
66
+ agent_secret: string;
67
+ org_id?: string;
68
+ docs_url?: string;
69
+ }
70
+ export declare function createAgentWorkspace(options?: { baseUrl?: string }): Promise<AgentWorkspaceCredentials>;
71
+
72
+ export interface IngestWithAgentSignatureOptions {
73
+ projectId: string;
74
+ ingestUrl: string;
75
+ apiKey: string;
76
+ }
77
+ export interface IngestPayload {
78
+ service: string;
79
+ action: string;
80
+ payload: Record<string, any>;
81
+ }
82
+ export declare function ingestWithAgentSignature(
83
+ agentId: string,
84
+ agentSecret: string,
85
+ payload: IngestPayload,
86
+ options: IngestWithAgentSignatureOptions
87
+ ): Promise<Record<string, any>>;
57
88
  export declare function chainHandoff(previous: EscrowEnvelope, nextData: {
58
89
  service: string;
59
90
  action: string;
@@ -69,24 +100,37 @@ export declare function chainHandoff(previous: EscrowEnvelope, nextData: {
69
100
  handoff_signature: string;
70
101
  };
71
102
  };
103
+ export declare const LETSPING_DOCS_BASE: string;
104
+ export type LetsPingErrorCode = "LETSPING_401_AUTH" | "LETSPING_402_QUOTA" | "LETSPING_403_FORBIDDEN" | "LETSPING_404_NOT_FOUND" | "LETSPING_429_RATE_LIMIT" | "LETSPING_TIMEOUT" | "LETSPING_NETWORK" | "LETSPING_WEBHOOK_INVALID" | string;
72
105
  export declare class LetsPingError extends Error {
73
- status?: number | undefined;
74
- constructor(message: string, status?: number | undefined);
106
+ status?: number;
107
+ code?: LetsPingErrorCode;
108
+ documentationUrl?: string;
109
+ constructor(message: string, status?: number, code?: LetsPingErrorCode, documentationUrl?: string);
110
+ }
111
+ export interface RetryOptions {
112
+ maxAttempts?: number;
113
+ initialDelayMs?: number;
114
+ maxDelayMs?: number;
115
+ }
116
+ export interface RequestStatus {
117
+ id: string;
118
+ status: "PENDING" | "APPROVED" | "REJECTED";
119
+ payload?: any;
120
+ patched_payload?: any;
121
+ resolved_at?: string | null;
122
+ actor_id?: string | null;
75
123
  }
76
124
  export declare class LetsPing {
77
- private readonly apiKey;
78
- private readonly baseUrl;
79
125
  constructor(apiKey?: string, options?: {
80
126
  baseUrl?: string;
81
127
  encryptionKey?: string;
128
+ retry?: RetryOptions;
82
129
  });
83
130
  ask(options: RequestOptions): Promise<Decision>;
84
- defer(options: RequestOptions): Promise<{
85
- id: string;
86
- }>;
87
- waitForDecision(id: string, options?: {
88
- originalPayload?: Record<string, any>;
89
- timeoutMs?: number;
90
- }): Promise<Decision>;
91
- private request;
131
+ defer(options: RequestOptions): Promise<{ id: string }>;
132
+ waitForDecision(id: string, options?: { originalPayload?: Record<string, any>; timeoutMs?: number }): Promise<Decision>;
133
+ getRequestStatus(id: string): Promise<RequestStatus>;
134
+ tool(service: string, action: string, priority?: Priority): (context: string | Record<string, any>) => Promise<string>;
135
+ webhookHandler(payloadStr: string, signatureHeader: string, webhookSecret: string): Promise<{ id: string; event: string; data: Decision; state_snapshot?: Record<string, any> }>;
92
136
  }
package/src/index.ts CHANGED
@@ -62,13 +62,85 @@ export interface Decision {
62
62
  };
63
63
  }
64
64
 
65
+ /** Status of a request returned by GET /status/:id. Use with defer() + getRequestStatus() for polling without reading the raw HTTP API. */
66
+ export interface RequestStatus {
67
+ id: string;
68
+ status: "PENDING" | "APPROVED" | "REJECTED";
69
+ payload?: any;
70
+ patched_payload?: any;
71
+ resolved_at?: string | null;
72
+ actor_id?: string | null;
73
+ }
74
+
75
+ /** Base URL for error documentation. Errors may include a link to a specific anchor. */
76
+ export const LETSPING_DOCS_BASE = "https://letsping.co/docs";
77
+
78
+ /** Known error codes for programmatic handling and doc links. */
79
+ export type LetsPingErrorCode =
80
+ | "LETSPING_401_AUTH"
81
+ | "LETSPING_402_QUOTA"
82
+ | "LETSPING_403_FORBIDDEN"
83
+ | "LETSPING_404_NOT_FOUND"
84
+ | "LETSPING_429_RATE_LIMIT"
85
+ | "LETSPING_TIMEOUT"
86
+ | "LETSPING_NETWORK"
87
+ | "LETSPING_WEBHOOK_INVALID"
88
+ | string;
89
+
65
90
  export class LetsPingError extends Error {
66
- constructor(message: string, public status?: number) {
91
+ /** HTTP status when the error came from the API (e.g. 402, 429). */
92
+ public readonly status?: number;
93
+ /** Stable code for handling (e.g. LETSPING_402_QUOTA). Use for branching or logging. */
94
+ public readonly code?: LetsPingErrorCode;
95
+ /** Link to the relevant doc section. Present when code is set. */
96
+ public readonly documentationUrl?: string;
97
+
98
+ constructor(
99
+ message: string,
100
+ status?: number,
101
+ code?: LetsPingErrorCode,
102
+ documentationUrl?: string
103
+ ) {
67
104
  super(message);
68
105
  this.name = "LetsPingError";
106
+ this.status = status;
107
+ this.code = code ?? (status ? statusToCode(status) : undefined);
108
+ this.documentationUrl = documentationUrl ?? (this.code ? codeToDocUrl(this.code) : undefined);
69
109
  }
70
110
  }
71
111
 
112
+ function statusToCode(status: number): LetsPingErrorCode {
113
+ switch (status) {
114
+ case 401: return "LETSPING_401_AUTH";
115
+ case 402: return "LETSPING_402_QUOTA";
116
+ case 403: return "LETSPING_403_FORBIDDEN";
117
+ case 404: return "LETSPING_404_NOT_FOUND";
118
+ case 429: return "LETSPING_429_RATE_LIMIT";
119
+ case 408: return "LETSPING_TIMEOUT";
120
+ default: return status >= 500 ? "LETSPING_NETWORK" : (`LETSPING_${status}` as LetsPingErrorCode);
121
+ }
122
+ }
123
+
124
+ function codeToDocUrl(code: LetsPingErrorCode): string {
125
+ const anchor: Record<string, string> = {
126
+ LETSPING_401_AUTH: "#auth",
127
+ LETSPING_402_QUOTA: "#billing",
128
+ LETSPING_403_FORBIDDEN: "#auth",
129
+ LETSPING_404_NOT_FOUND: "#requests",
130
+ LETSPING_429_RATE_LIMIT: "#rate-limits",
131
+ LETSPING_TIMEOUT: "#timeouts",
132
+ LETSPING_NETWORK: "#errors",
133
+ LETSPING_WEBHOOK_INVALID: "#webhooks",
134
+ };
135
+ return `${LETSPING_DOCS_BASE}${anchor[code] ?? ""}`;
136
+ }
137
+
138
+ function parseApiError(responseStatus: number, body: { message?: string; error?: string; code?: string }): { message: string; code: LetsPingErrorCode; documentationUrl: string } {
139
+ const message = body?.message ?? body?.error ?? `API Error [${responseStatus}]`;
140
+ const code = (body?.code as LetsPingErrorCode) ?? statusToCode(responseStatus);
141
+ return { message, code, documentationUrl: codeToDocUrl(code) };
142
+ }
143
+
72
144
  interface EncEnvelope {
73
145
  _lp_enc: true;
74
146
  iv: string;
@@ -162,6 +234,8 @@ export interface EscrowEnvelope {
162
234
  handoff_signature: string | null;
163
235
  upstream_agent_id: string | null;
164
236
  downstream_agent_id: string | null;
237
+ x402_mandate?: any;
238
+ ap2_mandate?: any;
165
239
  };
166
240
  }
167
241
 
@@ -173,6 +247,8 @@ export function verifyEscrow(event: EscrowEnvelope, secret: string): boolean {
173
247
  data: event.data,
174
248
  upstream_agent_id: event.escrow.upstream_agent_id,
175
249
  downstream_agent_id: event.escrow.downstream_agent_id,
250
+ x402_mandate: event.escrow.x402_mandate ?? null,
251
+ ap2_mandate: event.escrow.ap2_mandate ?? null,
176
252
  };
177
253
  const expected = createHmac("sha256", secret).update(JSON.stringify(base)).digest("hex");
178
254
  return expected === event.escrow.handoff_signature;
@@ -232,6 +308,139 @@ export function signIngestBody(
232
308
  };
233
309
  }
234
310
 
311
+ /** Credentials returned by createAgentWorkspace. Use api_key for Bearer auth and ingestWithAgentSignature for signed ingest. */
312
+ export interface AgentWorkspaceCredentials {
313
+ project_id: string;
314
+ api_key: string;
315
+ ingest_url: string;
316
+ agents_register_url: string;
317
+ agent_id: string;
318
+ agent_secret: string;
319
+ org_id?: string;
320
+ docs_url?: string;
321
+ }
322
+
323
+ /**
324
+ * Request a signup token, redeem it to create a workspace, and register one agent. Returns credentials so the agent can call ingestWithAgentSignature.
325
+ * Rate limits apply (see letsping.co/docs). Throws on 4xx/5xx or if self-serve signup is disabled.
326
+ * @param options.baseUrl - App root URL (e.g. https://letsping.co). Defaults to LETSPING_BASE_URL or https://letsping.co.
327
+ */
328
+ export async function createAgentWorkspace(options?: { baseUrl?: string }): Promise<AgentWorkspaceCredentials> {
329
+ const baseUrl = (options?.baseUrl ?? process.env.LETSPING_BASE_URL ?? "https://letsping.co").replace(/\/+$/, "");
330
+
331
+ const tokenRes = await fetch(`${baseUrl}/api/agent-signup/request-token`, {
332
+ method: "POST",
333
+ headers: { "Content-Type": "application/json" },
334
+ body: "{}",
335
+ });
336
+ if (!tokenRes.ok) {
337
+ const err = await tokenRes.json().catch(() => ({})) as { error?: string; code?: string };
338
+ const { message, code, documentationUrl } = parseApiError(tokenRes.status, err);
339
+ throw new LetsPingError(message, tokenRes.status, code, documentationUrl);
340
+ }
341
+ const { token } = (await tokenRes.json()) as { token: string };
342
+ if (!token) {
343
+ throw new LetsPingError("LetsPing Error: No token in request-token response");
344
+ }
345
+
346
+ const redeemRes = await fetch(`${baseUrl}/api/agent-signup`, {
347
+ method: "POST",
348
+ headers: { "Content-Type": "application/json" },
349
+ body: JSON.stringify({ token }),
350
+ });
351
+ if (!redeemRes.ok) {
352
+ const err = await redeemRes.json().catch(() => ({})) as { error?: string; message?: string };
353
+ const { message, code, documentationUrl } = parseApiError(redeemRes.status, err);
354
+ throw new LetsPingError(message, redeemRes.status, code, documentationUrl);
355
+ }
356
+ const redeem = (await redeemRes.json()) as {
357
+ project_id: string;
358
+ api_key: string;
359
+ ingest_url: string;
360
+ agents_register_url: string;
361
+ org_id?: string;
362
+ docs_url?: string;
363
+ };
364
+ if (!redeem.api_key || !redeem.agents_register_url) {
365
+ throw new LetsPingError("LetsPing Error: Invalid redeem response (missing api_key or agents_register_url)");
366
+ }
367
+
368
+ const registerRes = await fetch(redeem.agents_register_url, {
369
+ method: "POST",
370
+ headers: {
371
+ Authorization: `Bearer ${redeem.api_key}`,
372
+ "Content-Type": "application/json",
373
+ },
374
+ body: "{}",
375
+ });
376
+ if (!registerRes.ok) {
377
+ const err = await registerRes.json().catch(() => ({})) as { error?: string };
378
+ const { message, code, documentationUrl } = parseApiError(registerRes.status, err);
379
+ throw new LetsPingError(message, registerRes.status, code, documentationUrl);
380
+ }
381
+ const reg = (await registerRes.json()) as { agent_id: string; agent_secret: string };
382
+ if (!reg.agent_id || !reg.agent_secret) {
383
+ throw new LetsPingError("LetsPing Error: Invalid register response (missing agent_id or agent_secret)");
384
+ }
385
+
386
+ return {
387
+ project_id: redeem.project_id,
388
+ api_key: redeem.api_key,
389
+ ingest_url: redeem.ingest_url,
390
+ agents_register_url: redeem.agents_register_url,
391
+ agent_id: reg.agent_id,
392
+ agent_secret: reg.agent_secret,
393
+ org_id: redeem.org_id,
394
+ docs_url: redeem.docs_url,
395
+ };
396
+ }
397
+
398
+ /** Options for ingestWithAgentSignature. */
399
+ export interface IngestWithAgentSignatureOptions {
400
+ projectId: string;
401
+ ingestUrl: string;
402
+ apiKey: string;
403
+ }
404
+
405
+ /** Ingest payload: service, action, and payload. */
406
+ export interface IngestPayload {
407
+ service: string;
408
+ action: string;
409
+ payload: Record<string, any>;
410
+ }
411
+
412
+ /**
413
+ * Build a signed ingest body and POST it to the ingest URL with Bearer apiKey. Returns the JSON response; throws on non-2xx.
414
+ * Use this so the agent quickstart does not require hand-rolled HMAC or curl. See also: signIngestBody.
415
+ */
416
+ export async function ingestWithAgentSignature(
417
+ agentId: string,
418
+ agentSecret: string,
419
+ payload: IngestPayload,
420
+ options: IngestWithAgentSignatureOptions
421
+ ): Promise<Record<string, any>> {
422
+ const body = signIngestBody(agentId, agentSecret, {
423
+ project_id: options.projectId,
424
+ service: payload.service,
425
+ action: payload.action,
426
+ payload: payload.payload ?? {},
427
+ });
428
+ const res = await fetch(options.ingestUrl, {
429
+ method: "POST",
430
+ headers: {
431
+ Authorization: `Bearer ${options.apiKey}`,
432
+ "Content-Type": "application/json",
433
+ },
434
+ body: JSON.stringify(body),
435
+ });
436
+ const data = (await res.json().catch(() => ({}))) as Record<string, any>;
437
+ if (!res.ok) {
438
+ const { message, code, documentationUrl } = parseApiError(res.status, data as { error?: string });
439
+ throw new LetsPingError(message, res.status, code, documentationUrl);
440
+ }
441
+ return data;
442
+ }
443
+
235
444
  export function verifyAgentSignature(
236
445
  agentId: string,
237
446
  secret: string,
@@ -276,12 +485,23 @@ export function chainHandoff(previous: EscrowEnvelope, nextData: {
276
485
  };
277
486
  }
278
487
 
488
+ /** Optional retry config for ingest and status calls. Disabled when maxAttempts is 1 or omitted. */
489
+ export interface RetryOptions {
490
+ /** Max attempts per request (default 1 = no retry). Try 3 for transient resilience. */
491
+ maxAttempts?: number;
492
+ /** Initial delay in ms before first retry (default 1000). */
493
+ initialDelayMs?: number;
494
+ /** Cap on delay between retries in ms (default 10000). */
495
+ maxDelayMs?: number;
496
+ }
497
+
279
498
  export class LetsPing {
280
499
  private readonly apiKey: string;
281
500
  private readonly baseUrl: string;
282
501
  private readonly encryptionKey: string | null;
502
+ private readonly retry: Required<RetryOptions>;
283
503
 
284
- constructor(apiKey?: string, options?: { baseUrl?: string; encryptionKey?: string }) {
504
+ constructor(apiKey?: string, options?: { baseUrl?: string; encryptionKey?: string; retry?: RetryOptions }) {
285
505
  const key = apiKey || process.env.LETSPING_API_KEY;
286
506
  if (!key) throw new Error("LetsPing: API Key is required. Pass it to the constructor or set LETSPING_API_KEY env var.");
287
507
 
@@ -290,6 +510,12 @@ export class LetsPing {
290
510
  this.encryptionKey = options?.encryptionKey
291
511
  ?? process.env.LETSPING_ENCRYPTION_KEY
292
512
  ?? null;
513
+ const r = options?.retry ?? {};
514
+ this.retry = {
515
+ maxAttempts: r.maxAttempts ?? 1,
516
+ initialDelayMs: r.initialDelayMs ?? 1000,
517
+ maxDelayMs: r.maxDelayMs ?? 10000,
518
+ };
293
519
  }
294
520
 
295
521
  private _encrypt(payload: Record<string, any>): Record<string, any> {
@@ -335,6 +561,13 @@ export class LetsPing {
335
561
  };
336
562
  }
337
563
 
564
+ /**
565
+ * Send a request and block until a human approves or rejects it (or timeout). Use for HITL steps in your agent.
566
+ * @param options - service, action, payload; optional priority, schema, state_snapshot, timeoutMs, role
567
+ * @returns Decision with status APPROVED | REJECTED | APPROVED_WITH_MODIFICATIONS and payload (or patched_payload)
568
+ * @throws LetsPingError with code/documentationUrl on API or network errors, or LETSPING_TIMEOUT if no decision in time
569
+ * @see https://letsping.co/docs#ask
570
+ */
338
571
  async ask(options: RequestOptions): Promise<Decision> {
339
572
  if (options.schema && (options.schema as any)._def) {
340
573
  throw new LetsPingError("LetsPing Error: Raw Zod schema detected. You must convert it to JSON Schema (e.g. using 'zod-to-json-schema') before passing it to the SDK.");
@@ -447,8 +680,13 @@ export class LetsPing {
447
680
  delay = Math.min(delay * 1.5, maxDelay);
448
681
  }
449
682
 
450
- throw new LetsPingError(`Request ${id} timed out waiting for approval.`);
451
- } catch (error: any) {
683
+ throw new LetsPingError(
684
+ `Request ${id} timed out waiting for approval.`,
685
+ undefined,
686
+ "LETSPING_TIMEOUT",
687
+ `${LETSPING_DOCS_BASE}#timeouts`
688
+ );
689
+ } catch (error: any) {
452
690
  if (span) {
453
691
  span.recordException(error);
454
692
  span.setStatus({ code: otel.SpanStatusCode.ERROR });
@@ -458,6 +696,24 @@ export class LetsPing {
458
696
  }
459
697
  }
460
698
 
699
+ /**
700
+ * Fetch the current status of a request by id. Use after defer() to poll until status is APPROVED or REJECTED without calling the raw HTTP API.
701
+ * @param id - Request id returned from defer()
702
+ * @returns RequestStatus with status PENDING | APPROVED | REJECTED, payload, resolved_at, actor_id
703
+ * @see https://letsping.co/docs#requests
704
+ */
705
+ async getRequestStatus(id: string): Promise<RequestStatus> {
706
+ const raw = await this.request<RequestStatus>("GET", `/status/${id}`);
707
+ return raw;
708
+ }
709
+
710
+ /**
711
+ * Send a request and return immediately with the request id. Poll with getRequestStatus(id) or waitForDecision(id) until resolved.
712
+ * Use for async flows (e.g. webhook rehydration) where you do not want to block in-process.
713
+ * @param options - service, action, payload; optional priority, schema, state_snapshot, role
714
+ * @returns { id } - use id with getRequestStatus(id) or waitForDecision(id)
715
+ * @see https://letsping.co/docs#defer
716
+ */
461
717
  async defer(options: RequestOptions): Promise<{ id: string }> {
462
718
  const otel = await getOtel();
463
719
  let span: any = null;
@@ -533,30 +789,76 @@ export class LetsPing {
533
789
  "User-Agent": `letsping-node/${SDK_VERSION}`,
534
790
  };
535
791
 
536
- try {
537
- const response = await fetch(`${this.baseUrl}${path}`, {
538
- method,
539
- headers,
540
- body: body ? JSON.stringify(body) : undefined,
541
- });
792
+ const maxAttempts = Math.max(1, this.retry.maxAttempts);
793
+ let lastError: LetsPingError | null = null;
542
794
 
543
- if (!response.ok) {
544
- const errorText = await response.text();
545
- let message = errorText;
546
- try {
547
- const json = JSON.parse(errorText);
548
- if (json.message) message = json.message;
549
- } catch { }
550
- throw new LetsPingError(`API Error [${response.status}]: ${message}`, response.status);
551
- }
795
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
796
+ try {
797
+ const response = await fetch(`${this.baseUrl}${path}`, {
798
+ method,
799
+ headers,
800
+ body: body ? JSON.stringify(body) : undefined,
801
+ });
552
802
 
553
- return response.json() as Promise<T>;
554
- } catch (e: any) {
555
- if (e instanceof LetsPingError) throw e;
556
- throw new LetsPingError(`Network Error: ${e.message}`);
803
+ if (!response.ok) {
804
+ const errorText = await response.text();
805
+ let errorBody: { message?: string; error?: string; code?: string } = {};
806
+ try {
807
+ errorBody = JSON.parse(errorText);
808
+ } catch { }
809
+ const { message, code, documentationUrl } = parseApiError(response.status, errorBody);
810
+ lastError = new LetsPingError(message, response.status, code, documentationUrl);
811
+ const retryable = response.status === 429 || response.status >= 500;
812
+ if (retryable && attempt < maxAttempts) {
813
+ await this._delay(attempt);
814
+ continue;
815
+ }
816
+ throw lastError;
817
+ }
818
+
819
+ return await response.json() as T;
820
+ } catch (e: any) {
821
+ if (e instanceof LetsPingError) {
822
+ lastError = e;
823
+ const retryable = e.status === 429 || (e.status != null && e.status >= 500);
824
+ if (retryable && attempt < maxAttempts) {
825
+ await this._delay(attempt);
826
+ continue;
827
+ }
828
+ throw e;
829
+ }
830
+ lastError = new LetsPingError(
831
+ `Network Error: ${e?.message ?? "Unknown"}`,
832
+ undefined,
833
+ "LETSPING_NETWORK",
834
+ `${LETSPING_DOCS_BASE}#errors`
835
+ );
836
+ if (attempt < maxAttempts) {
837
+ await this._delay(attempt);
838
+ continue;
839
+ }
840
+ throw lastError;
841
+ }
557
842
  }
843
+
844
+ throw lastError ?? new LetsPingError("Request failed", undefined, "LETSPING_NETWORK", `${LETSPING_DOCS_BASE}#errors`);
558
845
  }
559
846
 
847
+ private _delay(attempt: number): Promise<void> {
848
+ const delay = Math.min(
849
+ this.retry.initialDelayMs * Math.pow(1.5, attempt - 1) + Math.random() * 200,
850
+ this.retry.maxDelayMs
851
+ );
852
+ return new Promise(r => setTimeout(r, delay));
853
+ }
854
+
855
+ /**
856
+ * Poll for a decision on a request created with defer(). Blocks until status is APPROVED/REJECTED or timeout.
857
+ * @param id - request id from defer()
858
+ * @param options - originalPayload (fallback if payload not in response), timeoutMs (default 24h)
859
+ * @returns Decision same shape as ask()
860
+ * @see https://letsping.co/docs#requests
861
+ */
560
862
  async waitForDecision(
561
863
  id: string,
562
864
  options?: { originalPayload?: Record<string, any>; timeoutMs?: number }
@@ -604,9 +906,22 @@ export class LetsPing {
604
906
  delay = Math.min(delay * 1.5, maxDelay);
605
907
  }
606
908
 
607
- throw new LetsPingError(`Request ${id} timed out waiting for approval.`);
909
+ throw new LetsPingError(
910
+ `Request ${id} timed out waiting for approval.`,
911
+ undefined,
912
+ "LETSPING_TIMEOUT",
913
+ `${LETSPING_DOCS_BASE}#timeouts`
914
+ );
608
915
  }
609
916
 
917
+ /**
918
+ * Build a callable tool (e.g. for LangChain) that runs ask(service, action, payload) and returns a result string.
919
+ * @param service - LetsPing service name
920
+ * @param action - action name
921
+ * @param priority - optional priority (default medium)
922
+ * @returns Async function(context) => string; context can be JSON string or object
923
+ * @see https://letsping.co/docs#tool
924
+ */
610
925
  tool(service: string, action: string, priority: Priority = "medium"): (context: string | Record<string, any>) => Promise<string> {
611
926
  return async (context: string | Record<string, any>): Promise<string> => {
612
927
  let payload: Record<string, any>;
@@ -646,6 +961,15 @@ export class LetsPing {
646
961
  };
647
962
  }
648
963
 
964
+ /**
965
+ * Validate and parse an incoming LetsPing webhook body. Verifies signature and optionally fetches/decrypts state_snapshot.
966
+ * @param payloadStr - raw request body (e.g. await req.text())
967
+ * @param signatureHeader - x-letsping-signature header
968
+ * @param webhookSecret - secret from dashboard → Settings → Webhooks
969
+ * @returns { id, event, data, state_snapshot } for resuming your workflow
970
+ * @throws LetsPingError with code LETSPING_WEBHOOK_INVALID and documentationUrl on invalid signature or replay
971
+ * @see https://letsping.co/docs#webhooks
972
+ */
649
973
  async webhookHandler(
650
974
  payloadStr: string,
651
975
  signatureHeader: string,
@@ -656,25 +980,26 @@ export class LetsPing {
656
980
 
657
981
  const rawTs = sigMap["t"];
658
982
  const rawSig = sigMap["v1"];
983
+ const docUrl = `${LETSPING_DOCS_BASE}#webhooks`;
659
984
  if (!rawTs || !rawSig) {
660
- throw new LetsPingError("LetsPing Error: Missing webhook signature fields", 401);
985
+ throw new LetsPingError("LetsPing Error: Missing webhook signature fields", 401, "LETSPING_WEBHOOK_INVALID", docUrl);
661
986
  }
662
987
 
663
988
  const ts = Number(rawTs);
664
989
  if (!Number.isFinite(ts)) {
665
- throw new LetsPingError("LetsPing Error: Invalid webhook timestamp", 401);
990
+ throw new LetsPingError("LetsPing Error: Invalid webhook timestamp", 401, "LETSPING_WEBHOOK_INVALID", docUrl);
666
991
  }
667
992
 
668
993
  const now = Date.now();
669
994
  const skewMs = Math.abs(now - ts);
670
995
  const maxSkewMs = 5 * 60 * 1000; // 5 minutes
671
996
  if (skewMs > maxSkewMs) {
672
- throw new LetsPingError("LetsPing Error: Webhook replay window exceeded", 401);
997
+ throw new LetsPingError("LetsPing Error: Webhook replay window exceeded", 401, "LETSPING_WEBHOOK_INVALID", docUrl);
673
998
  }
674
999
 
675
1000
  const expected = createHmac("sha256", webhookSecret).update(payloadStr).digest("hex");
676
1001
  if (rawSig !== expected) {
677
- throw new LetsPingError("LetsPing Error: Invalid webhook signature", 401);
1002
+ throw new LetsPingError("LetsPing Error: Invalid webhook signature", 401, "LETSPING_WEBHOOK_INVALID", docUrl);
678
1003
  }
679
1004
 
680
1005
  const payload = JSON.parse(payloadStr);