@pushary/server 2.2.0 → 2.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/README.md CHANGED
@@ -1,6 +1,14 @@
1
1
  # @pushary/server
2
2
 
3
- Server-side SDK for [Pushary](https://pushary.com): send push notifications, and add human-in-the-loop approvals to your AI agent (pause on a decision until a specific human answers).
3
+ The decision layer for AI agents. Your agent asks, your user decides on their phone.
4
+
5
+ This is the server SDK for [Pushary](https://pushary.com). It also sends push notifications.
6
+
7
+ ## What you need
8
+
9
+ - A Pushary Partner plan, from $99 a month, to ask your own users. [Start the trial](https://pushary.com/sign-up?from=agent&plan=partner).
10
+ - An API key from [Partner onboarding](https://pushary.com/onboarding/partner), set as `PUSHARY_API_KEY`.
11
+ - Your users install the free Pushary app ([iPhone](https://apps.apple.com/us/app/pushary/id6785677563), [Android](https://play.google.com/store/apps/details?id=com.pushary.app)). They never sign up or pay.
4
12
 
5
13
  ## Installation
6
14
 
@@ -19,17 +27,25 @@ bun add @pushary/server
19
27
  ```typescript
20
28
  import { createPusharyServer } from '@pushary/server'
21
29
 
22
- const pushary = createPusharyServer({
23
- apiKey: process.env.PUSHARY_API_KEY,
24
- })
30
+ const pushary = createPusharyServer({ apiKey: process.env.PUSHARY_API_KEY })
25
31
 
26
- await pushary.notifications.send({
27
- title: 'Hello!',
28
- body: 'Your order has shipped',
29
- subscriberIds: ['sub_123'],
32
+ const { universalLink } = await pushary.enroll('user-123')
33
+ // Once per user: show universalLink as a button or QR code.
34
+
35
+ const { approved } = await pushary.decisions.ask({
36
+ externalId: 'user-123',
37
+ question: 'Issue a $50 refund?',
38
+ type: 'confirm', // confirm | select | input
30
39
  })
40
+ if (approved) await issueRefund()
31
41
  ```
32
42
 
43
+ That is the whole integration: connect a user's phone once, then ask them whenever
44
+ your agent needs a yes. `ask()` creates a fresh decision per call and waits until the
45
+ person answers or the deadline passes (55 seconds by default). `approved` is true only
46
+ when the person said yes, so a declined, expired or unanswered decision blocks the
47
+ action. For longer waits, see [Decisions (lower-level)](#decisions-lower-level).
48
+
33
49
  ## API Key
34
50
 
35
51
  The server SDK requires your full API key (`pk_xxx.sk_xxx`) which includes the secret portion.
@@ -38,6 +54,22 @@ The server SDK requires your full API key (`pk_xxx.sk_xxx`) which includes the s
38
54
 
39
55
  Get your API key by following [Get your API key](https://pushary.com/docs/agents/api-key).
40
56
 
57
+ ## Push notifications quick start
58
+
59
+ ```typescript
60
+ import { createPusharyServer } from '@pushary/server'
61
+
62
+ const pushary = createPusharyServer({
63
+ apiKey: process.env.PUSHARY_API_KEY,
64
+ })
65
+
66
+ await pushary.notifications.send({
67
+ title: 'Hello!',
68
+ body: 'Your order has shipped',
69
+ subscriberIds: ['sub_123'],
70
+ })
71
+ ```
72
+
41
73
  ## Resources
42
74
 
43
75
  ### Subscribers
@@ -121,30 +153,6 @@ await pushary.notifications.send({
121
153
  })
122
154
  ```
123
155
 
124
- ### Human-in-the-loop for agents (the two-call contract)
125
-
126
- The whole integration is two calls. Connect an end-user's phone once, then ask them
127
- whenever your agent needs a human. Requires the [Partner plan](https://pushary.com/agent-notifications-integration).
128
-
129
- ```typescript
130
- // 1. Connect an end-user's phone (keyless, no account for them). Show the link.
131
- const { universalLink } = await pushary.enroll('user-123')
132
- // Render universalLink as a button or QR. One tap turns on approvals.
133
-
134
- // 2. Ask that person and block until they answer. Fail-closed `approved` flag.
135
- const { approved, value, status } = await pushary.decisions.ask({
136
- externalId: 'user-123',
137
- question: 'Issue a $50 refund?',
138
- type: 'confirm', // confirm | select | input
139
- })
140
- if (approved) await issueRefund()
141
- ```
142
-
143
- `ask()` creates a fresh decision per call and polls
144
- durably until the human answers or the deadline passes (default 55s for a positive local wait budget).
145
- `approved` is true only when the person actually said yes, so a declined, expired, or
146
- unanswered decision safely blocks the action.
147
-
148
156
  ### Decisions (lower-level)
149
157
 
150
158
  For long waits or your own resume logic, use `create` + a webhook or `get`, and
@@ -248,7 +256,8 @@ For an explicit human gate, set `policy: false`. The gate keeps the full review
248
256
  **Upgrade handling:** version 2.1 changes human gate keys. Finish already-started operations on the SDK version that created them, or migrate their persisted bindings explicitly. Do not replay previously executed operations through the new key scheme. A fingerprint identifies the proposed action; the application's execution record and framework persistence still own recovery after an uncertain tool execution.
249
257
 
250
258
  Every official Pushary adapter (`@pushary/eve`, `@pushary/ai-sdk`,
251
- `@pushary/langgraph`, `@pushary/mastra`, `@pushary/openai-agents`) is a thin binding
259
+ `@pushary/langgraph`, `@pushary/mastra`, `@pushary/openai-agents`,
260
+ `@pushary/claude-agent-sdk`) is a thin binding
252
261
  over one shared kernel, and that kernel is public. If you run an in-house harness, or
253
262
  a framework we have not shipped for, this is the same surface they are built on.
254
263
 
@@ -316,15 +325,15 @@ names asks a person. `run` is called only after the action is authorized, and an
316
325
  error it throws is not caught: reporting it as `ok: false` would be
317
326
  indistinguishable from a refusal.
318
327
 
319
- `protect()` is **at most once**. The decision is idempotent — a replay of the same
320
- `runId` + `callId` + action lands on the same approval instead of asking twice — and
328
+ `protect()` is **at most once**. The decision is idempotent: a replay of the same
329
+ `runId` + `callId` + action lands on the same approval instead of asking twice: and
321
330
  the approval is then spent against a durable permit before `run` is called. A retry, a
322
331
  concurrent worker and a resumed run all reach the same permit and exactly one of them
323
332
  proceeds; the rest come back `ok: false` with a reason worded to stop the model rather
324
333
  than invite another attempt.
325
334
 
326
- The permit is bound to the exact subject that was authorized — the action, its target,
327
- the actor, the environment, the end-user and every fact you sent — so changing the
335
+ The permit is bound to the exact subject that was authorized: the action, its target,
336
+ the actor, the environment, the end-user and every fact you sent: so changing the
328
337
  amount between the approval and the execution leaves you with no permit for the action
329
338
  you now want to run. `protect()` then records `succeeded` or `failed` against it; a
330
339
  process that dies mid-action leaves the permit unresolved, which is visible and
@@ -425,3 +434,14 @@ A positive `timeoutMs` covers creation and polling. `timeoutMs: 0` retains creat
425
434
  ### Upgrading to 2.0
426
435
 
427
436
  Upgrade the framework adapter alongside this SDK. Durable helpers now require an explicit `idempotencyKey` tied to the run and step. The 0.3 LangGraph, Mastra and OpenAI adapters support this contract. Earlier adapters must keep the 1.x SDK until migrated. Independent blocking calls no longer deduplicate by question text.
437
+
438
+ ### Cancel decision HTTP work
439
+
440
+ `decisions.create(data, { signal })`, `decisions.get(id, { wait, signal })`, and
441
+ `decisions.cancel(id, { signal })` accept an optional `AbortSignal`. The HTTP
442
+ `requestTimeoutMs` still applies when a caller signal is present, including while
443
+ reading the response body. Caller cancellation preserves its abort reason;
444
+ HTTP deadline expiry raises `TimeoutError`. These calls do not retry internally.
445
+ Aborting HTTP work does not withdraw a server-side decision: cancel its known ID
446
+ with a separate cleanup signal. A lost create response may leave a decision until
447
+ its configured expiration.
package/dist/adapters.cjs CHANGED
@@ -97,13 +97,28 @@ var handleResponse = async (response) => {
97
97
  var createRequest = (ctx) => async (method, path, body, options) => {
98
98
  const isGet = method === "GET";
99
99
  const url = isGet && body ? buildUrl(ctx.baseUrl, path, body) : `${ctx.baseUrl}${path}`;
100
- const response = await fetch(url, {
101
- method,
102
- signal: options?.signal ?? AbortSignal.timeout(ctx.timeoutMs ?? 65e3),
103
- headers: ctx.headers,
104
- body: !isGet && body ? JSON.stringify(body) : void 0
105
- });
106
- return handleResponse(response);
100
+ options?.signal?.throwIfAborted();
101
+ const controller = new AbortController();
102
+ const abort = () => controller.abort(options?.signal?.reason);
103
+ options?.signal?.addEventListener("abort", abort, { once: true });
104
+ const timer = setTimeout(() => controller.abort(new DOMException("Pushary request timed out", "TimeoutError")), ctx.timeoutMs ?? 65e3);
105
+ try {
106
+ const response = await fetch(url, {
107
+ method,
108
+ signal: controller.signal,
109
+ headers: ctx.headers,
110
+ body: !isGet && body ? JSON.stringify(body) : void 0
111
+ });
112
+ const result = await handleResponse(response);
113
+ controller.signal.throwIfAborted();
114
+ return result;
115
+ } catch (error) {
116
+ controller.signal.throwIfAborted();
117
+ throw error;
118
+ } finally {
119
+ clearTimeout(timer);
120
+ options?.signal?.removeEventListener("abort", abort);
121
+ }
107
122
  };
108
123
 
109
124
  // src/resources/subscribers.ts
@@ -277,12 +292,12 @@ var createDecisionsResource = (request) => {
277
292
  }
278
293
  };
279
294
  return Object.freeze({
280
- create: (data) => request("POST", "/decisions", data),
281
- get: (id, opts) => request("GET", `/decisions/${id}`, opts?.wait ? { wait: opts.wait } : void 0),
295
+ create: (data, options) => request("POST", "/decisions", data, options),
296
+ get: (id, opts) => request("GET", `/decisions/${id}`, opts?.wait ? { wait: opts.wait } : void 0, opts),
282
297
  list: (opts) => request("GET", "/decisions", opts ? { ...opts } : void 0),
283
298
  ask,
284
299
  answer: (id, answer) => request("POST", `/decisions/${id}`, { answer }),
285
- cancel: (id) => request("DELETE", `/decisions/${id}`),
300
+ cancel: (id, options) => request("DELETE", `/decisions/${id}`, void 0, options),
286
301
  getWebhookSecret: () => request("GET", "/webhook-secret"),
287
302
  rotateWebhookSecret: () => request("POST", "/webhook-secret")
288
303
  });
@@ -395,10 +410,10 @@ var createRecordExecution = (request) => (receipt) => request("POST", `/authoriz
395
410
  var DEFAULT_BASE_URL = "https://pushary.com/api/v1/server";
396
411
  var validateApiKey = (apiKey) => {
397
412
  if (!apiKey) {
398
- throw new Error("API key is required. Get your API key from https://pushary.com/dashboard/settings");
413
+ throw new Error("API key is required. Get your API key from https://pushary.com/dashboard/agent/settings");
399
414
  }
400
415
  if (!apiKey.includes(".")) {
401
- throw new Error("Invalid API key format. Use the full API key (pk_xxx.sk_xxx)");
416
+ throw new Error("Invalid API key format. Use the full API key (pk_xxx.xxx)");
402
417
  }
403
418
  };
404
419
  var createContext = (config) => {