@letsping/sdk 0.1.6 → 0.2.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/README.md CHANGED
@@ -92,36 +92,115 @@ const { id } = await lp.defer({
92
92
  console.log(`Approval request queued → ${id}`);
93
93
  ```
94
94
 
95
- ### Webhook Rehydration (Framework Agnostic)
96
- LetsPing does **not** magically inject state back into your framework natively. You must handle the webhook and rehydrate your specific framework manually.
95
+ ### Webhook Rehydration (Next.js Example)
96
+
97
+ When you pass `state_snapshot` to `ask` / `defer`, the SDK:
98
+
99
+ - Encrypts the snapshot with either `LETSPING_ENCRYPTION_KEY` or a one‑time DEK.
100
+ - Uploads it directly to your storage bucket using a signed URL.
101
+ - Includes a `state_download_url` (and DEK) in subsequent webhooks.
102
+
103
+ You can use the built‑in `webhookHandler` to validate and hydrate webhooks in a Next.js App Router route:
97
104
 
98
105
  ```typescript
99
- // app/api/webhook/letsping/route.ts
100
- if (body.status === "APPROVED") {
101
- let hydratedState = null;
102
- if (body.state_download_url) {
103
- const res = await fetch(body.state_download_url);
104
- hydratedState = lp._decrypt(await res.json());
105
- }
106
- // Manually push `hydratedState` back into your LangGraph/Vercel thread
106
+ // Example Next.js App Router route
107
+ import { NextRequest, NextResponse } from "next/server";
108
+ import { LetsPing } from "@letsping/sdk";
109
+
110
+ const lp = new LetsPing();
111
+ const WEBHOOK_SECRET = process.env.LETSPING_WEBHOOK_SECRET!;
112
+
113
+ export async function POST(req: NextRequest) {
114
+ const rawBody = await req.text();
115
+ const signature = req.headers.get("x-letsping-signature") || "";
116
+
117
+ try {
118
+ const { id, event, data, state_snapshot } = await lp.webhookHandler(
119
+ rawBody,
120
+ signature,
121
+ WEBHOOK_SECRET
122
+ );
123
+
124
+ // At this point:
125
+ // - `data` contains the decision payload (status, payload, patched_payload, metadata, etc.)
126
+ // - `state_snapshot` contains your decrypted agent state, if Cryo-Sleep was used.
127
+
128
+ await handleDecision({ id, event, data, state_snapshot });
129
+
130
+ return NextResponse.json({ ok: true });
131
+ } catch (err: any) {
132
+ console.error("LetsPing webhook error:", err);
133
+ return NextResponse.json({ error: "invalid webhook" }, { status: 400 });
134
+ }
135
+ }
136
+
137
+ async function handleDecision(args: {
138
+ id: string;
139
+ event: string;
140
+ data: any;
141
+ state_snapshot?: Record<string, any>;
142
+ }) {
143
+ // Example: resume a workflow run or LangGraph thread using `state_snapshot`
107
144
  }
108
145
  ```
109
146
 
147
+ This pattern works similarly for Express/Fastify — call `lp.webhookHandler(rawBody, signature, secret)`, then resume your framework using the provided `state_snapshot`.
148
+
110
149
  ### LangGraph Integration (Persisted State)
111
150
 
112
- LetsPing provides a `LetsPingCheckpointer` for LangGraph JS/TS that automatically encrypts and parks your agent's state in Cryo-Sleep storage.
151
+ LetsPing provides a `LetsPingCheckpointer` for LangGraph JS/TS under `@letsping/sdk/integrations/langgraph`.
152
+ 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.
113
153
 
114
154
  ```typescript
115
155
  import { StateGraph } from "@langchain/langgraph";
116
156
  import { LetsPing } from "@letsping/sdk";
157
+ import { LetsPingCheckpointer } from "@letsping/sdk/integrations/langgraph";
158
+
159
+ const lp = new LetsPing(process.env.LETSPING_API_KEY!);
160
+ const checkpointer = new LetsPingCheckpointer(lp);
161
+
162
+ const builder = new StateGraph<any /* your state type */>({});
163
+ const graph = builder.compile({ checkpointer });
164
+ ```
165
+
166
+ #### Auto‑resuming a thread after approval (webhook + checkpointer)
117
167
 
118
- // Import the checkpointer from the specific integration path
168
+ 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:
169
+
170
+ ```ts
171
+ // Example Next.js App Router route for LangGraph auto-resume
172
+ import { NextRequest, NextResponse } from "next/server";
173
+ import { LetsPing } from "@letsping/sdk";
174
+ import { StateGraph } from "@langchain/langgraph";
119
175
  import { LetsPingCheckpointer } from "@letsping/sdk/integrations/langgraph";
176
+ import { graphBuilder } from "@/lib/langgraph"; // your app's graph definition
120
177
 
121
178
  const lp = new LetsPing(process.env.LETSPING_API_KEY!);
122
179
  const checkpointer = new LetsPingCheckpointer(lp);
180
+ const graph = graphBuilder.compile({ checkpointer });
181
+
182
+ export async function POST(req: NextRequest) {
183
+ const raw = await req.text();
184
+ const sig = req.headers.get("x-letsping-signature") || "";
185
+
186
+ const event = await lp.webhookHandler(raw, sig, process.env.LETSPING_WEBHOOK_SECRET!);
187
+ const { data, state_snapshot } = event;
188
+
189
+ // You decide how to encode the thread id into your state snapshot.
190
+ const threadId = state_snapshot?.thread_id as string | undefined;
191
+ if (!threadId) return NextResponse.json({ ok: false, error: "missing_thread_id" }, { status: 400 });
192
+
193
+ // Resume the graph from the latest remote checkpoint.
194
+ await graph.invoke(state_snapshot.input, {
195
+ configurable: { thread_id: threadId },
196
+ });
197
+
198
+ return NextResponse.json({ ok: true });
199
+ }
123
200
  ```
124
201
 
202
+ 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.
203
+
125
204
  ## API Reference
126
205
 
127
206
  ### `new LetsPing(apiKey, options?)`
@@ -163,4 +242,68 @@ interface Decision {
163
242
  For full documentation, request schema examples, error codes, and dashboard integration see:
164
243
  https://letsping.co/docs#sdk
165
244
 
166
- Deploy agents with confidence.
245
+ Deploy agents with confidence.
246
+
247
+ ## 2-Minute Demo (Node/TypeScript)
248
+
249
+ You can feel the full LetsPing loop (intercept → approve → resume) in under 2 minutes.
250
+
251
+ ```ts
252
+ // demo.ts
253
+ import { LetsPing } from "@letsping/sdk";
254
+
255
+ async function main() {
256
+ const apiKey = process.env.LETSPING_API_KEY;
257
+ if (!apiKey) {
258
+ console.error("Missing LETSPING_API_KEY env var.");
259
+ process.exit(1);
260
+ }
261
+
262
+ const lp = new LetsPing(apiKey);
263
+
264
+ console.log("Sending demo approval request to LetsPing…");
265
+ const decision = await lp.ask({
266
+ service: "demo-agent",
267
+ action: "transfer_funds",
268
+ priority: "high",
269
+ payload: {
270
+ amount: 500,
271
+ currency: "USD",
272
+ recipient: "acct_demo_123",
273
+ },
274
+ });
275
+
276
+ if (decision.status === "REJECTED") {
277
+ console.log("Demo request REJECTED by human. No action taken.");
278
+ } else if (decision.status === "APPROVED_WITH_MODIFICATIONS") {
279
+ console.log("APPROVED WITH MODIFICATIONS:");
280
+ console.dir(decision.diff_summary, { depth: null });
281
+ } else {
282
+ console.log("APPROVED with original payload.");
283
+ }
284
+ }
285
+
286
+ main().catch((err) => {
287
+ console.error("Demo failed:", err);
288
+ process.exit(1);
289
+ });
290
+ ```
291
+
292
+ Run:
293
+
294
+ ```bash
295
+ export LETSPING_API_KEY="lp_live_..."
296
+ node demo.ts
297
+ ```
298
+
299
+ Then open the LetsPing dashboard for your project, approve/reject the `demo-agent / transfer_funds` request, and watch the script resume.
300
+
301
+ If you’re using the local tunnel (`npx @letsping/cli dev`), you can also point the SDK at it during local development:
302
+
303
+ ```ts
304
+ const lp = new LetsPing(process.env.LETSPING_API_KEY!, {
305
+ baseUrl: "http://localhost:<port>/api",
306
+ });
307
+ ```
308
+
309
+ All `ask` / `defer` calls made through that client will flow through your local tunnel into the LetsPing dashboard.
@@ -0,0 +1,37 @@
1
+ type Priority = "low" | "medium" | "high" | "critical";
2
+ interface RequestOptions {
3
+ service: string;
4
+ action: string;
5
+ payload: Record<string, any>;
6
+ priority?: Priority;
7
+ schema?: Record<string, any>;
8
+ timeoutMs?: number;
9
+ }
10
+ interface Decision {
11
+ status: "APPROVED" | "REJECTED";
12
+ payload: any;
13
+ patched_payload?: any;
14
+ metadata?: {
15
+ resolved_at: string;
16
+ actor_id: string;
17
+ method?: string;
18
+ };
19
+ }
20
+ declare class LetsPingError extends Error {
21
+ status?: number | undefined;
22
+ constructor(message: string, status?: number | undefined);
23
+ }
24
+ declare class LetsPing {
25
+ private readonly apiKey;
26
+ private readonly baseUrl;
27
+ constructor(apiKey?: string, options?: {
28
+ baseUrl?: string;
29
+ });
30
+ ask(options: RequestOptions): Promise<Decision>;
31
+ defer(options: RequestOptions): Promise<{
32
+ id: string;
33
+ }>;
34
+ private request;
35
+ }
36
+
37
+ export { type Decision, LetsPing, LetsPingError, type Priority, type RequestOptions };
@@ -0,0 +1,59 @@
1
+ export type Priority = "low" | "medium" | "high" | "critical";
2
+ export interface RequestOptions {
3
+ service: string;
4
+ action: string;
5
+ payload: Record<string, any>;
6
+ priority?: Priority;
7
+ schema?: Record<string, any>;
8
+ state_snapshot?: Record<string, any>;
9
+ timeoutMs?: number;
10
+ role?: string;
11
+ /**
12
+ * Optional distributed tracing identifiers. If provided, these will be
13
+ * attached to the request envelope so downstream frameworks can stitch
14
+ * together multi-agent flows.
15
+ */
16
+ trace_id?: string;
17
+ parent_request_id?: string;
18
+ }
19
+ export interface Decision {
20
+ status: "APPROVED" | "REJECTED" | "APPROVED_WITH_MODIFICATIONS";
21
+ payload: any;
22
+ patched_payload?: any;
23
+ diff_summary?: any;
24
+ metadata?: {
25
+ resolved_at: string;
26
+ actor_id: string;
27
+ method?: string;
28
+ };
29
+ }
30
+ export declare class LetsPingError extends Error {
31
+ status?: number | undefined;
32
+ constructor(message: string, status?: number | undefined);
33
+ }
34
+ declare function computeDiff(original: any, patched: any): any;
35
+ export declare class LetsPing {
36
+ private readonly apiKey;
37
+ private readonly baseUrl;
38
+ private readonly encryptionKey;
39
+ constructor(apiKey?: string, options?: {
40
+ baseUrl?: string;
41
+ encryptionKey?: string;
42
+ });
43
+ private _encrypt;
44
+ private _decrypt;
45
+ private _prepareStateUpload;
46
+ ask(options: RequestOptions): Promise<Decision>;
47
+ defer(options: RequestOptions): Promise<{
48
+ id: string;
49
+ }>;
50
+ private request;
51
+ tool(service: string, action: string, priority?: Priority): (context: string | Record<string, any>) => Promise<string>;
52
+ webhookHandler(payloadStr: string, signatureHeader: string, webhookSecret: string): Promise<{
53
+ id: string;
54
+ event: string;
55
+ data: Decision;
56
+ state_snapshot?: Record<string, any>;
57
+ }>;
58
+ }
59
+ export { computeDiff };