@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 +34 -0
- package/LICENSE +21 -0
- package/README.md +20 -4
- package/package.json +24 -3
- package/src/index.d.ts +56 -12
- package/src/index.ts +353 -28
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
|
-
|
|
15
|
-
- TypeScript 5+
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
|
74
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
451
|
-
|
|
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
|
-
|
|
537
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
}
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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(
|
|
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);
|