@decidio/sdk 0.1.0 → 0.1.1

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
@@ -4,83 +4,107 @@ One line to put a human-or-policy approval gate in front of any AI-agent action
4
4
 
5
5
  ## Quickstart — seven steps to your first sealed receipt
6
6
 
7
- Work through these in order; a competent engineer lands the whole loop in under 30 minutes.
7
+ Under 30 minutes, no help needed. You'll need Node 20+, a terminal, and a Decidio workspace
8
+ (request one at [decidioai.com](https://decidioai.com/#early-access) — the hosted sandbox is
9
+ synthetic data, safe to experiment in).
8
10
 
9
11
  **1. Install**
10
12
 
11
13
  ```bash
14
+ mkdir decidio-quickstart && cd decidio-quickstart
12
15
  npm install @decidio/sdk
13
16
  ```
14
17
 
15
- **2. Register your agent**
18
+ **2. Point at your Decidio and register your agent**
16
19
 
17
20
  ```bash
21
+ export DECIDIO_API_URL=https://decidio-api.onrender.com # the hosted sandbox
18
22
  npx @decidio/sdk init my-agent
19
23
  ```
20
24
 
21
- `init` signs you into your Decidio workspace (email + password, or paste the sign-in link from
22
- your workspace email), generates the agent's Ed25519 keypair **locally** (only the public
23
- `did:key` is sent), registers the agent, mints its **floor-limited API token** (it can call the
24
- agent gate and nothing else — your workspace session never touches the agent's disk), and writes
25
- `.env`. No workspace yet? Request access at [decidioai.com](https://decidioai.com/#early-access).
25
+ `init` signs you into your workspace (email + password, or paste the sign-in link from your
26
+ workspace email), generates the agent's Ed25519 keypair **locally** (only the public `did:key`
27
+ is sent), registers the agent, mints its **floor-limited API token** (it can request approvals
28
+ and nothing else — your workspace session never touches the agent's disk), and writes `.env`.
29
+ Every later command reads `.env` from this directory automatically.
26
30
 
27
- **3. Protect one function** — rename the raw function, wrap it under the original name:
31
+ **3. Protect one function** — save this as `quickstart.mjs`:
28
32
 
29
- ```ts
33
+ ```js
30
34
  import { guard } from "@decidio/sdk";
31
35
 
32
- const createOpportunityRaw = (o) => sf.create("Opportunity", o);
36
+ // Your real action a Salesforce write, a payment, a DB change. Here: a stand-in.
37
+ const payInvoiceRaw = async (invoice) => ({ paid: true, id: invoice.id });
33
38
 
34
- const createOpportunity = guard.protect(
35
- createOpportunityRaw,
36
- (o) => ({ action: "createOpportunity", amount: o.Amount, scope: "Opportunity" }),
37
- );
39
+ // One line: wrap it. `describe` tells Decidio what is being asked.
40
+ const payInvoice = guard.protect(
41
+ payInvoiceRaw,
42
+ (invoice) => ({ action: "payInvoice", amount: invoice.amount, scope: "Invoice" }),
43
+ { mode: "blocking" }, // blocking = watch the whole loop live in one terminal sitting.
44
+ ); // Production agents use durable mode instead — see below.
45
+
46
+ const result = await payInvoice({ id: "INV-2026-001", amount: 86_000 });
47
+ console.log("executed after approval:", result);
38
48
  ```
39
49
 
40
- **4. Trigger a request:**
50
+ **4. Trigger it:**
41
51
 
42
- ```ts
43
- await createOpportunity({ Amount: 86_000 });
52
+ ```bash
53
+ node --env-file=.env quickstart.mjs
44
54
  ```
45
55
 
46
56
  A brand-new agent matches **no** auto-approve rule, so Decidio's deny-by-default **routes every
47
57
  request to a human** — that is the point: nothing executes without either a named policy rule or
48
- a person. In durable mode the call parks its arguments agent-side and throws
49
- `DecidioSuspendedError`; the process may exit. (To see a policy `proceed`, register the agent in
50
- the sandbox's pre-policied lane — `DECIDIO_SOURCE_SYSTEM=salesforce npx @decidio/sdk init` which
51
- auto-approves small amounts under a named, versioned rule when the request carries the agent's
52
- signed identity proof.)
58
+ a person. The script prints the decision id and waits. (To later see a policy `proceed`,
59
+ register a **new** agent in the sandbox's pre-policied lane an existing agent's lane can't be
60
+ changed: `DECIDIO_SOURCE_SYSTEM=salesforce npx @decidio/sdk init my-sf-agent`. That lane's
61
+ named rule auto-approves amounts under $1M when the request carries the agent's signed
62
+ identity proof, so even this $86k example would proceed there.)
53
63
 
54
- **5. Approve it** — the request is now in your Decidio queue (web or phone), or from the terminal:
64
+ **5. Approve it** — in a second terminal (same directory):
55
65
 
56
66
  ```bash
57
- npx @decidio/sdk approvals # list pending
67
+ npx @decidio/sdk approvals # see it pending
58
68
  npx @decidio/sdk approvals approve <decisionId>
59
69
  ```
60
70
 
61
- On approval Decidio signs a verdict back to your resume route (or `resume.worker()` picks it
62
- up), and **your own function** executesDecidio never holds your credentials.
71
+ The moment you approve, the first terminal wakes: **your own function executes** Decidio never
72
+ holds your credentials or runs your code and the SDK reports the captured result back, which
73
+ becomes the record's execution evidence.
63
74
 
64
- **6. Inspect the receipt** open the sealed record in the Decidio app → **Verify** →
65
- **Download receipt (.json)**, then verify it offline with the open, zero-dependency verifier:
75
+ **6. Download the receipt and verify it offline:**
66
76
 
67
77
  ```bash
68
- npx @decidio/verify receipt.json
78
+ npx @decidio/sdk receipt <decisionId>
79
+ npx @decidio/verify decidio-receipt-<decisionId>.json
69
80
  ```
70
81
 
71
- The signature, content binding, and chain position verify with no Decidio account and no
72
- network — the evidence is yours, not ours.
82
+ The receipt is a W3C Verifiable Credential: the signature and content binding verify with no
83
+ Decidio account and no network (after the one-time `npx` package download) — the evidence is
84
+ yours, not ours. To also prove *who* issued it, pin your workspace's issuer:
85
+ `npx @decidio/verify --issuer <did:key:...> receipt.json` (the DID is shown in your app's
86
+ record verify panel). One receipt proves itself; chain order across receipts is verified when
87
+ you pass several files at once.
88
+
89
+ What the credential contains, honestly: the sealed **authority decision** — who decided, what
90
+ was authorized, when, under which policy. It seals at decision time and is immutable, so
91
+ execution evidence that arrives *after* the seal (your function's captured result) lives on the
92
+ **record**, not inside the credential bytes: your Decidio record page shows the evidence tier
93
+ (`application_confirmed` once the wrapper's signed report lands). Immutable seal + accruing
94
+ evidence is the design, not an omission — corrections and later facts supersede, they never
95
+ rewrite.
73
96
 
74
- **7. Prove a replay is rejected** — the approval you just used is single-use. Try to use it again:
97
+ **7. Prove a replay is rejected:**
75
98
 
76
99
  ```bash
77
- npx @decidio/sdk approvals approve <the same decisionId> # → refused: already sealed
78
- curl -X POST http://localhost:4100/decidio/resume \
79
- -H 'content-type: application/json' -d '{"decisionId":"<id>","verdict":"approved"}'
80
- # → refused: the resume handler fails closed without a verified x-decidio-signature,
81
- # and the parked entry was deleted on first completion — a duplicate signal can't double-write.
100
+ npx @decidio/sdk approvals approve <the same decisionId>
82
101
  ```
83
102
 
103
+ Decisions are single-use: the CLI reports "already resolved — nothing changed" and exits
104
+ non-zero. The server answers replays idempotently — a second approve can never re-execute the
105
+ action. (The durable resume path has the same property: parked actions are claimed exactly
106
+ once, and its webhook handler fails closed without a verified signature.)
107
+
84
108
  That's the whole contract: gate → human decision → your execution → owned, verifiable evidence.
85
109
 
86
110
  ## What happens on a call
@@ -90,70 +114,84 @@ That's the whole contract: gate → human decision → your execution → owned,
90
114
  - **proceed** — your function runs immediately (auto-approved under a named, versioned rule).
91
115
  - **route** — the action suspends (durable) or waits (blocking) for a human decision in Decidio's queue, then runs your function (or throws `DecidioRejectedError`).
92
116
  - **block** — the wrapper throws `DecidioBlockedError`; your function never runs.
93
- 3. After your function runs, the wrapper reports the **real response it captured** back to Decidio, which seals an `application_confirmed` receipt (Decidio minimizes + tokenizes before sealing — the immutable record never stores raw payloads).
117
+ 3. After your function runs, the wrapper reports the **real response it captured** back to Decidio, which records it as `application_confirmed` execution evidence (Decidio minimizes + tokenizes before storing — the immutable record never stores raw payloads).
94
118
 
95
119
  Your agent executes its own action. **Decidio holds no write credentials for your system** — it authorizes the decision, records it, and (optionally) independently verifies it.
96
120
 
97
- ## Real approvals take time — durable async resume
98
-
99
- A real human approval takes minutes to days. Nobody watches a 60-second polling window, so blocking the call is only honest for short, supervised approvals. For real ones, add a **resume controller** and the *same wrap* becomes durable: a routed action **suspends** instead of blocking — it parks its call args in an **agent-side store** (Decidio stores none of your downstream payload) and throws `DecidioSuspendedError`. The requesting process can exit. When a human approves, **Decidio POSTs a signed verdict** to your agent's resume URL; the controller re-runs *your own function* and confirms the result. Decidio signals — it never executes.
100
-
101
- ```ts
102
- import { withApproval, createDecidioResume, FilePendingStore } from "@decidio/sdk";
103
- import { createServer } from "node:http";
121
+ ## Production mode: durable async resume
104
122
 
105
- const resume = createDecidioResume({
106
- apiUrl: process.env.DECIDIO_API_URL!,
107
- apiToken: process.env.DECIDIO_API_TOKEN,
108
- webhookSecret: process.env.DECIDIO_WEBHOOK_SECRET, // Decidio signs callbacks (HMAC); verified before re-exec
109
- resumeUrl: "https://my-agent.internal/decidio/resume", // where Decidio calls back on approval
110
- store: new FilePendingStore(".decidio-pending"), // swap for Redis/Postgres/your queue
111
- });
123
+ Real approvals take minutes to days, and nobody keeps a terminal open for them. Omit
124
+ `mode: "blocking"` (durable is the default) and a routed action **suspends** instead of
125
+ waiting: it parks its call arguments agent-side (`.decidio-pending/` by default — Decidio
126
+ stores none of your downstream payload) and throws `DecidioSuspendedError`. The process may
127
+ exit. Two ways the parked action resumes when a human decides:
112
128
 
113
- const createOpportunity = withApproval(createOpportunityRaw, describe, { ...guard, resume });
129
+ **The worker (self-serve start here).** One line in any long-lived process:
114
130
 
115
- // mount the resume listener (production webhook transport):
116
- createServer((req, res) => resume.webhookHandler()(req, res)).listen(4100);
131
+ ```js
132
+ import { guard } from "@decidio/sdk";
133
+ guard.worker(); // polls Decidio outward, re-executes parked actions on approval, exactly once
117
134
  ```
118
135
 
119
- No inbound URL (CLI / batch worker)? Use `resume.worker()` instead — a long-lived watcher that polls `/agent/status` for parked decisions and re-executes on approval. That polling is a durable *worker*, not the request path being kept alive. This is the same shape as HumanLayer's approval webhooks, Temporal Signals, Inngest `waitForEvent`, and Decidio's own A2A `input-required` resume. Re-execution is **single-use** (the parked entry is deleted on completion, so a duplicate signal can't double-write), and the webhook handler **fails closed** without a verified signature.
136
+ No inbound URL, no shared secrets, survives restarts. This is the transport to use against the
137
+ hosted sandbox.
120
138
 
121
- ### Already on a durable engine? Use its native wait (Inngest adapter)
139
+ **The signed webhook (operator deployments).** Decidio POSTs a signed verdict to your agent's
140
+ `resumeUrl`; the handler verifies the HMAC signature fail-closed, then re-runs your function:
122
141
 
123
- If your agent runs on Inngest/Temporal/LangGraph, the engine *is* the durable store — you don't need the file store at all. `@decidio/sdk/inngest` maps the gate onto Inngest's native `step.waitForEvent`, so a routed action durably suspends on the engine (zero compute, the worker can die for days) and resumes from its checkpoint:
142
+ ```js
143
+ // Express: app.post("/decidio/resume", guard.resumeHandler())
144
+ // Next.js: export const POST = (req) => guard.resumeFetchHandler()(req)
145
+ ```
124
146
 
125
- ```ts
126
- import { decidioGateStep, createInngestResumeBridge } from "@decidio/sdk/inngest";
147
+ Honest requirement: webhook signing uses a **shared secret configured on both sides** — your
148
+ `DECIDIO_WEBHOOK_SECRET` must equal the Decidio server's, and self-hosted production deployments
149
+ also allow-list resume hosts (default-deny). That's operator territory: if you run your own
150
+ Decidio (or we run a pilot with you), the webhook is set up then. Against the hosted sandbox,
151
+ use the worker — a webhook secret your server never learned would (correctly) fail closed.
127
152
 
128
- const fn = inngest.createFunction({ id: "create-opp", triggers: [{ event: "salesops/opportunity.requested" }] },
129
- async ({ event, step }) => decidioGateStep({
130
- step, config: { ...guard, resumeUrl: "https://my-agent/decidio/resume" },
131
- ctx: { action: "createOpportunity", amount: event.data.Amount, scope: "Opportunity" },
132
- run: () => sf.create("Opportunity", event.data), // YOUR write, on resume
133
- }));
153
+ Either way, re-execution is **single-use** the parked entry is claimed on completion, so a
154
+ duplicate signal can't double-write.
134
155
 
135
- // Decidio keeps its ONE signed-webhook contract; this bridge translates it into the
136
- // Inngest event that wakes the suspended run:
137
- const bridge = createInngestResumeBridge({ inngest, webhookSecret: process.env.DECIDIO_WEBHOOK_SECRET });
138
- ```
156
+ ### Already on a durable engine? Use its native wait
139
157
 
140
- `inngest` is an **optional peer dependency** the core SDK never imports it, so plain users pay nothing. See `example/inngest-agent.ts` in the repo for a runnable end-to-end (Inngest dev server + the bridge).
158
+ If your agent runs on Inngest/Temporal/LangGraph, the engine *is* the durable store.
159
+ `@decidio/sdk/inngest` maps the gate onto `step.waitForEvent`; `@decidio/sdk/langgraph` is a
160
+ drop-in via `interrupt()`; `@decidio/sdk/temporal` uses `condition` + signal;
161
+ `@decidio/sdk/openai` gates RunState approvals. All optional peer dependencies — the core SDK
162
+ imports none of them. See each adapter's header for its wiring, and `example/` in the repo for
163
+ runnable end-to-ends.
141
164
 
142
165
  ## One surface, every runtime — and Python too
143
166
 
144
- - **`guard.protect(fn, describe, { adapter })`** is the same call everywhere. Engine adapters map it onto each engine's native durable wait: `@decidio/sdk/langgraph` (drop-in via `interrupt()`), `@decidio/sdk/inngest` (`waitForEvent`), `@decidio/sdk/temporal` (`condition` + signal), `@decidio/sdk/openai` (RunState approvals). No engine? The core webhook/worker resume is the universal path.
167
+ - **`guard.protect(fn, describe, { adapter })`** is the same call everywhere; the engine adapters above translate it onto each engine's native durable wait. No engine? The worker/webhook resume is the universal path.
145
168
  - **Python twin:** `pip install decidio` exposes the identical `guard.protect` (plus the `@guard.approve` decorator, adapters, and the offline verifier); a conformance suite asserts both languages emit an identical request + receipt.
146
169
 
147
170
  ## CLI
148
171
 
149
- `npx @decidio/sdk <cmd>` — `init [agentId]` (sign in, register, mint the agent token, write `.env`) · `doctor` (config + connectivity + token-scope check) · `approvals [approve|reject <id> [reason]]` · `dev [--target URL]` (signal-only relay for a local agent). Commands that administer your workspace (`init`, `approvals`) use a **session** sign-in held in memory for the run; the only credential written to disk is the agent's floor-limited token.
172
+ `npx @decidio/sdk <cmd>`:
173
+
174
+ - `init [agentId]` — sign in, register, mint the agent token, write `.env` (+ `.gitignore` entries)
175
+ - `doctor` — config, connectivity, and what your current token can do
176
+ - `approvals` / `approvals approve|reject <id> [reason]` — the human side, from your terminal
177
+ - `receipt <id>` — download a sealed decision's Authority Receipt to a `.json` file
178
+ - `dev [--target URL]` — local relay for the resume webhook (operator setups)
179
+
180
+ Commands that administer your workspace (`init`, `approvals`, `receipt`) use a **session**
181
+ sign-in held in memory for that run. What lands on disk in `.env`: the agent's floor-limited
182
+ API token, its locally-generated private signing key, and a webhook secret (used only in
183
+ operator deployments) — `init` adds `.env` to `.gitignore` for you; move secrets to a secret
184
+ manager for production.
150
185
 
151
186
  ## Errors
152
187
 
153
188
  - `DecidioBlockedError` — policy blocked the action (incl. the universal deny-by-default backstop).
154
189
  - `DecidioRejectedError` — a human rejected the routed action.
155
- - `DecidioSuspendedError` — (resume mode) the action was routed and is now parked for async approval; the process may exit and resume later. Not a failure.
156
- - `DecidioTimeoutError` — (blocking mode only) no human decided within `pollTimeoutMs` (default 10 min).
190
+ - `DecidioSuspendedError` — (durable mode) the action was routed and is parked for async approval; the process may exit and resume later. Not a failure.
191
+ - `DecidioTimeoutError` — (blocking mode only) no human decided within `pollTimeoutMs` (default 10 min); the decision stays open in Decidio, nothing executed locally.
192
+
193
+ Every error carries the `decisionId`, so a suspended or timed-out action is always findable in
194
+ your queue.
157
195
 
158
196
  ## Notes
159
197
 
package/dist/cli.js CHANGED
@@ -3,26 +3,72 @@
3
3
  // init [agentId] sign in, register the agent, mint its API token, write .env
4
4
  // doctor check config + connectivity + durability readiness
5
5
  // approvals list pending agent approvals; `approvals approve|reject <id> [reason]`
6
+ // receipt <id> download a sealed decision's Authority Receipt to a .json file
6
7
  // dev [--target U] signal-only relay: forwards Decidio's webhook to your local agent URL
7
8
  //
8
9
  // TWO TOKEN KINDS, deliberately (self-serve design 2026-08-28): commands that ADMINISTER a
9
- // workspace (init's register+mint, approvals) need the owner's SESSION token — acquired
10
+ // workspace (init's register+mint, approvals, receipt) need the owner's SESSION token — acquired
10
11
  // interactively per run or from DECIDIO_SESSION_TOKEN, held in memory, NEVER written to disk.
11
12
  // What lands in .env as DECIDIO_API_TOKEN is the floor-limited AGENT token the runtime SDK
12
13
  // needs (it can call /agent/* and nothing else). A session-capable secret must never live in
13
14
  // an agent host's .env — that was the pre-publish design's hole.
14
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
15
+ //
16
+ // MESSAGE DISCIPLINE (founder bar 2026-08-29, from the Codex acceptance run): every failure
17
+ // tells the developer WHAT happened, WHY, and the EXACT next command. Every success says what
18
+ // changed, where, and what to do next. No stack traces for expected failures; no green ✔ for a
19
+ // no-op (the replay-approve bug: the server's idempotent alreadyResolved was reported as a
20
+ // fresh success — an authority CLI may never dress a no-op as an action).
21
+ import { readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs";
15
22
  import { randomBytes } from "node:crypto";
16
23
  import { createServer } from "node:http";
17
24
  import { createInterface } from "node:readline";
18
25
  import { generateKeypair } from "./signing.js";
26
+ // ---- environment: load the CWD .env FIRST (Codex finding: init writes .env, but the next
27
+ // command ignored it and pointed at localhost). Process env always wins — .env only fills gaps.
28
+ function readEnvFile() {
29
+ const out = {};
30
+ if (existsSync(".env"))
31
+ for (const line of readFileSync(".env", "utf8").split(/\r?\n/)) {
32
+ const m = line.match(/^\s*([A-Z_]+)=(.*)$/);
33
+ if (m)
34
+ out[m[1]] = m[2];
35
+ }
36
+ return out;
37
+ }
38
+ for (const [k, v] of Object.entries(readEnvFile())) {
39
+ // NEVER autoload the session token from disk: the whole design splits session (memory-only)
40
+ // from the floor-limited agent token (.env). Silently honoring a parked session token would
41
+ // reward exactly the credential-at-rest the split exists to prevent (guard catch 2026-08-29).
42
+ if (k === "DECIDIO_SESSION_TOKEN") {
43
+ console.log("⚠ ignoring DECIDIO_SESSION_TOKEN found in .env — session tokens belong in your shell or CI secret store, never on disk. Remove that line; export it in the shell instead.");
44
+ continue;
45
+ }
46
+ if (process.env[k] === undefined)
47
+ process.env[k] = v;
48
+ }
19
49
  const API = (process.env.DECIDIO_API_URL ?? "http://localhost:4000").replace(/\/$/, "");
20
50
  const TOKEN = process.env.DECIDIO_API_TOKEN;
21
51
  const ENV_PATH = ".env";
22
52
  const log = (...a) => console.log(...a);
53
+ /** Fetch wrapper that turns transport failures into guidance instead of a stack trace. */
23
54
  async function api(path, init = {}, bearer) {
24
55
  const auth = bearer ?? TOKEN;
25
- const res = await fetch(API + path, { ...init, headers: { "content-type": "application/json", ...(auth ? { authorization: `Bearer ${auth}` } : {}), ...(init.headers ?? {}) } });
56
+ let res;
57
+ try {
58
+ res = await fetch(API + path, { ...init, headers: { "content-type": "application/json", ...(auth ? { authorization: `Bearer ${auth}` } : {}), ...(init.headers ?? {}) } });
59
+ }
60
+ catch (e) {
61
+ log(`✘ could not reach Decidio at ${API} (${e?.cause?.code ?? e?.message ?? "network error"}).`);
62
+ if (API.includes("localhost")) {
63
+ log(` You're pointed at localhost — for the hosted sandbox run:`);
64
+ log(` export DECIDIO_API_URL=https://decidio-api.onrender.com`);
65
+ log(` (or put that line in .env), then re-run this command.`);
66
+ }
67
+ else {
68
+ log(` Check your network, then re-run. If the URL is wrong, set DECIDIO_API_URL and retry.`);
69
+ }
70
+ process.exit(1);
71
+ }
26
72
  const text = await res.text();
27
73
  let body = null;
28
74
  try {
@@ -33,22 +79,23 @@ async function api(path, init = {}, bearer) {
33
79
  }
34
80
  return { status: res.status, body };
35
81
  }
36
- function readEnv() {
37
- const out = {};
38
- if (existsSync(ENV_PATH))
39
- for (const line of readFileSync(ENV_PATH, "utf8").split(/\r?\n/)) {
40
- const m = line.match(/^\s*([A-Z_]+)=(.*)$/);
41
- if (m)
42
- out[m[1]] = m[2];
43
- }
44
- return out;
45
- }
46
82
  function upsertEnv(updates) {
47
- const cur = readEnv();
83
+ const cur = readEnvFile();
48
84
  const merged = { ...cur, ...updates };
49
85
  const body = Object.entries(merged).map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
50
86
  writeFileSync(ENV_PATH, body);
51
87
  }
88
+ /** Keep secrets out of git without nagging: append the two entries to .gitignore when they're
89
+ * not already covered. Creating the file in a non-git directory is harmless. */
90
+ function ensureGitignore() {
91
+ const entries = [".env", ".decidio-pending/"];
92
+ const existing = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
93
+ const missing = entries.filter((e) => !existing.split(/\r?\n/).some((l) => l.trim() === e || l.trim() === e.replace(/\/$/, "")));
94
+ if (!missing.length)
95
+ return;
96
+ appendFileSync(".gitignore", (existing && !existing.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n");
97
+ log(`✔ added ${missing.join(" + ")} to .gitignore (your agent key and parked actions stay out of git)`);
98
+ }
52
99
  // ---- interactive input (session sign-in) ---------------------------------------------------
53
100
  function ask(question, mute = false) {
54
101
  return new Promise((resolve) => {
@@ -67,21 +114,27 @@ function ask(question, mute = false) {
67
114
  /** Owner SESSION token, memory-only. Sources in order: DECIDIO_SESSION_TOKEN env, interactive
68
115
  * email+password (`/demo/login`), pasted sign-in link (`/demo/login-consume` — for the
69
116
  * password-less accounts the welcome flow creates). Never persisted. */
70
- async function acquireSession() {
117
+ async function acquireSession(purpose) {
71
118
  if (process.env.DECIDIO_SESSION_TOKEN)
72
119
  return process.env.DECIDIO_SESSION_TOKEN;
73
120
  // Same rule the server applies to resume URLs: credentials never ride plaintext http to a
74
121
  // non-loopback host. Refuse before prompting, not after collecting a password.
75
122
  if (/^http:\/\//i.test(API) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]|$)/i.test(API)) {
76
- log(`✘ refusing to send sign-in credentials over plaintext http to ${API} — use https (or localhost for local dev).`);
123
+ log(`✘ refusing to send sign-in credentials over plaintext http to ${API}.`);
124
+ log(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
77
125
  return null;
78
126
  }
79
127
  if (!process.stdin.isTTY) {
80
- log("✘ a Decidio sign-in is needed and this terminal is non-interactive.");
81
- log(" Set DECIDIO_SESSION_TOKEN (grab a sign-in link from your workspace email; its ?login= value is the token).");
128
+ log(`✘ ${purpose} needs a Decidio workspace sign-in, and this terminal can't prompt for one.`);
129
+ log(` Fix: set DECIDIO_SESSION_TOKEN and re-run. To mint one:`);
130
+ log(` 1. Request a sign-in link from your workspace's sign-in screen (or use your welcome email).`);
131
+ log(` 2. POST its ?login= value — the response's "token" is your session token:`);
132
+ log(` curl -X POST ${API}/demo/login-consume -H "content-type: application/json" -d '{"token":"<login value>"}'`);
82
133
  return null;
83
134
  }
84
- log(`Sign in to your Decidio workspace (${API}). Press Enter at the password prompt to use an emailed sign-in link instead.`);
135
+ log(`${purpose} needs your Decidio workspace sign-in (${API}).`);
136
+ log(` The session stays in memory for this command only — it is never written to disk.`);
137
+ log(` Press Enter at the password prompt to use an emailed sign-in link instead.`);
85
138
  const email = await ask(" email: ");
86
139
  if (email) {
87
140
  const password = await ask(" password (Enter to skip): ", true);
@@ -89,27 +142,30 @@ async function acquireSession() {
89
142
  const r = await api("/demo/login", { method: "POST", body: JSON.stringify({ email, password }) }, "");
90
143
  if (r.status >= 200 && r.status < 300 && r.body?.token)
91
144
  return r.body.token;
92
- log(` ✘ sign-in failed (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}. Trying the link path…`);
145
+ log(` ✘ password sign-in failed (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}. No problem — the link path works for every account:`);
93
146
  }
94
147
  }
95
- log(" Request a sign-in link from the app's sign-in screen (or use your welcome email), then paste the LINK or its token here.");
148
+ log(` Request a sign-in link from the app's sign-in screen, then paste the LINK (or just its token) here.`);
96
149
  const pasted = await ask(" link or token: ");
97
- if (!pasted)
150
+ if (!pasted) {
151
+ log(" ✘ nothing pasted — re-run when you have the link.");
98
152
  return null;
153
+ }
99
154
  const m = pasted.match(/[?&]login=([^&\s]+)/);
100
155
  const linkToken = m ? decodeURIComponent(m[1]) : pasted;
101
156
  const r = await api("/demo/login-consume", { method: "POST", body: JSON.stringify({ token: linkToken }) }, "");
102
157
  if (r.status >= 200 && r.status < 300 && r.body?.token)
103
158
  return r.body.token;
104
- log(` ✘ link sign-in failed (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}`);
159
+ log(` ✘ that link didn't work (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}.`);
160
+ log(` Sign-in links are single-use and expire in 15 minutes — request a fresh one and re-run.`);
105
161
  return null;
106
162
  }
107
163
  async function cmdInit(agentId) {
108
164
  log(`Registering agent "${agentId}" with Decidio at ${API}…`);
109
165
  // CLIENT-SIDE KEYGEN: generate the Ed25519 keypair LOCALLY and register only the PUBLIC
110
166
  // did:key. The private key NEVER crosses the wire — it goes straight to this host's env.
111
- const existingKey = readEnv().DECIDIO_AGENT_KEY;
112
- const sourceSystem = process.env.DECIDIO_SOURCE_SYSTEM || readEnv().DECIDIO_SOURCE_SYSTEM;
167
+ const existingKey = readEnvFile().DECIDIO_AGENT_KEY;
168
+ const sourceSystem = process.env.DECIDIO_SOURCE_SYSTEM || readEnvFile().DECIDIO_SOURCE_SYSTEM;
113
169
  const { did: localDid, privateKeyBase64: localKey } = generateKeypair();
114
170
  const registerBody = JSON.stringify({ agentId, did: localDid, ...(sourceSystem ? { sourceSystem } : {}) });
115
171
  // First try whatever bearer the env already carries (the local-dev / operator path — a static
@@ -117,29 +173,35 @@ async function cmdInit(agentId) {
117
173
  let session = null;
118
174
  let r = await api("/api/agents/register", { method: "POST", body: registerBody });
119
175
  if (r.status === 401 || r.status === 403) {
120
- session = await acquireSession();
121
- if (!session) {
122
- log("✘ unauthorized — sign in (or set DECIDIO_SESSION_TOKEN / DECIDIO_API_TOKEN) and retry.");
176
+ session = await acquireSession("Registering an agent");
177
+ if (!session)
123
178
  return 1;
124
- }
125
179
  r = await api("/api/agents/register", { method: "POST", body: registerBody }, session);
126
180
  }
127
181
  let did, privateKey;
128
182
  if (r.status === 409) {
129
183
  // Already registered to a different (server-held) did. Reuse it; the local key we just
130
184
  // minted does NOT match, so don't write it — the operator must supply the existing key.
131
- log(`• agent already registered (did ${r.body?.did}); reusing it.`);
132
185
  did = r.body?.did;
133
- if (!existingKey)
134
- log("⚠ this agent was registered earlier — set its existing DECIDIO_AGENT_KEY to sign (auto-approve needs a proof).");
186
+ log(`• "${agentId}" is already registered in this workspace (did ${did}) — reusing it.`);
187
+ if (sourceSystem)
188
+ log(` Note: sourceSystem can't change on an existing agent — the registered lane stands. To use the "${sourceSystem}" lane, register a NEW name: npx @decidio/sdk init ${agentId}-${sourceSystem}`);
189
+ if (existingKey)
190
+ log(` Your .env already carries a DECIDIO_AGENT_KEY; if it's this agent's original key, signing keeps working.`);
191
+ else {
192
+ log(` ⚠ Its private key lives wherever this agent was FIRST initialized — keys never leave the machine that made them.`);
193
+ log(` Either restore that DECIDIO_AGENT_KEY into .env, or start fresh under a new name:`);
194
+ log(` npx @decidio/sdk init ${agentId}-2`);
195
+ }
135
196
  }
136
197
  else if (r.status >= 200 && r.status < 300) {
137
198
  did = r.body?.did;
138
199
  privateKey = localKey;
139
- log(`✔ registered (did ${did}) — key generated locally, public did only sent to Decidio`);
200
+ log(`✔ registered (did ${did}) — key generated locally; only the public half went to Decidio`);
140
201
  }
141
202
  else {
142
- log(`✘ register failed (${r.status}): ${JSON.stringify(r.body)}`);
203
+ log(`✘ registration failed (${r.status}): ${JSON.stringify(r.body)}`);
204
+ log(` Fix the cause above and re-run — init is safe to repeat.`);
143
205
  return 1;
144
206
  }
145
207
  // Mint the runtime credential: the floor-limited AGENT token (can call /agent/* only). This is
@@ -153,17 +215,18 @@ async function cmdInit(agentId) {
153
215
  if (mint.status >= 200 && mint.status < 300 && mint.body?.token) {
154
216
  agentToken = mint.body.token;
155
217
  workspaceId = mint.body.workspaceId;
156
- log(`✔ minted the agent API token (shown once server-side; expires ${mint.body.expiresAt ?? "per policy"})`);
218
+ log(`✔ minted the agent's API token floor-limited: it can request approvals and nothing else${mint.body.expiresAt ? ` (expires ${mint.body.expiresAt})` : ""}`);
157
219
  }
158
220
  else if (session) {
159
- log(`✘ could not mint the agent token (${mint.status}): ${JSON.stringify(mint.body)} — the SDK has no runtime credential; fix and re-run init.`);
221
+ log(`✘ could not mint the agent's API token (${mint.status}): ${JSON.stringify(mint.body)}`);
222
+ log(` Without it the SDK has no runtime credential. Fix the cause above and re-run init — it's safe to repeat.`);
160
223
  return 1;
161
224
  }
162
225
  else {
163
226
  log(`• no agent token minted (${mint.status}) — assuming local/operator auth via the existing DECIDIO_API_TOKEN.`);
164
227
  }
165
228
  }
166
- const secret = readEnv().DECIDIO_WEBHOOK_SECRET || randomBytes(32).toString("hex");
229
+ const secret = readEnvFile().DECIDIO_WEBHOOK_SECRET || randomBytes(32).toString("hex");
167
230
  const envOut = { DECIDIO_API_URL: API, DECIDIO_AGENT_ID: agentId, DECIDIO_WEBHOOK_SECRET: secret };
168
231
  if (sourceSystem)
169
232
  envOut.DECIDIO_SOURCE_SYSTEM = sourceSystem;
@@ -176,52 +239,59 @@ async function cmdInit(agentId) {
176
239
  if (workspaceId)
177
240
  envOut.DECIDIO_WORKSPACE_ID = workspaceId;
178
241
  upsertEnv(envOut);
179
- log(`✔ wrote ${ENV_PATH} (DECIDIO_API_URL, DECIDIO_AGENT_ID, DECIDIO_WEBHOOK_SECRET${did ? ", DECIDIO_AGENT_DID" : ""}${privateKey ? ", DECIDIO_AGENT_KEY" : ""}${agentToken ? ", DECIDIO_API_TOKEN [agent-scope], DECIDIO_WORKSPACE_ID" : ""})`);
242
+ log(`✔ wrote ${ENV_PATH} every decidio command and the SDK read it from this directory`);
243
+ ensureGitignore();
180
244
  if (privateKey)
181
- log(`\n✔ signing key stored locally as DECIDIO_AGENT_KEY in ${ENV_PATH} (keep that file out of git; move it to your secret manager for production).\n`);
182
- log("Add one line to your agent:");
183
- log(` import { guard } from "@decidio/sdk";`);
184
- log(` const createOpp = guard.protect(createOppRaw, o => ({ action: "createOpportunity", amount: o.Amount, scope: "Opportunity" }));`);
185
- log("Mount the resume route (durable async approval):");
186
- log(` // Express: app.post("/decidio/resume", guard.resumeHandler())`);
187
- log(` // Next.js: export const POST = (req) => guard.resumeFetchHandler()(req)`);
245
+ log(` (the agent's private signing key is in there for production, move it to your secret manager)`);
246
+ log("");
247
+ log("Next: protect one function and watch it route. Two steps:");
248
+ log("");
249
+ log(" 1. Save this as quickstart.mjs:");
250
+ log("");
251
+ log(` import { guard } from "@decidio/sdk";`);
252
+ log(` const payInvoiceRaw = async (inv) => ({ paid: true, id: inv.id }); // your real action`);
253
+ log(` const payInvoice = guard.protect(payInvoiceRaw,`);
254
+ log(` (inv) => ({ action: "payInvoice", amount: inv.amount, scope: "Invoice" }),`);
255
+ log(` { mode: "blocking" }); // blocking = watch it happen live; durable is the production mode`);
256
+ log(` await payInvoice({ id: "INV-1", amount: 86000 });`);
257
+ log("");
258
+ log(" 2. Run it, approve it, watch it complete:");
259
+ log("");
260
+ log(` node --env-file=.env quickstart.mjs # routes to a human and waits`);
261
+ log(` npx @decidio/sdk approvals # (second terminal) see it pending`);
262
+ log(` npx @decidio/sdk approvals approve <id> # approve -> your function runs -> sealed`);
188
263
  log("");
189
264
  return cmdDoctor();
190
265
  }
191
266
  async function cmdDoctor() {
192
- const env = readEnv();
267
+ const env = readEnvFile();
193
268
  let ok = true;
194
269
  const line = (good, label) => { log(`${good ? "✔" : "✘"} ${label}`); ok = ok && good; };
195
- line(!!(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL), `API URL: ${API}`);
196
- line(!!(process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID), `agent id: ${process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID || "(unset)"}`);
270
+ line(!!(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL), `API URL: ${API}${(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL) ? "" : " (unset — defaulting to localhost; the hosted sandbox is https://decidio-api.onrender.com)"}`);
271
+ line(!!(process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID), `agent id: ${process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID || "(unset — run: npx @decidio/sdk init <agent-name>)"}`);
197
272
  const secret = process.env.DECIDIO_WEBHOOK_SECRET || env.DECIDIO_WEBHOOK_SECRET;
198
- line(!!secret, secret ? "webhook secret: set (durable resume will fail-closed verify)" : "webhook secret: MISSING — resume webhook will fail closed");
199
- // Signing key — REQUIRED for auto-approve. Without it the SDK can't prove identity, so the
200
- // gate routes EVERY request to a human (safe, but surprising). Surface it loudly.
273
+ line(!!secret, secret ? "webhook secret: set (durable resume will fail-closed verify)" : "webhook secret: MISSING — the resume webhook will fail closed (init generates one)");
274
+ // Signing key — REQUIRED to be ELIGIBLE for auto-approve. Whether anything auto-approves is
275
+ // the workspace policy's call, not this key's (Codex finding: the old wording claimed
276
+ // "auto-approve is in effect" while deny-by-default routed everything).
201
277
  const signing = (process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY) && (process.env.DECIDIO_AGENT_DID || env.DECIDIO_AGENT_DID);
202
278
  if (signing)
203
- log("✔ signing key: set — agent proves identity, so policy auto-approve is in effect");
279
+ log("✔ signing key: set — the agent can prove its identity, making it ELIGIBLE for policy auto-approve. Whether a rule matches is your workspace policy's decision; with no matching rule every request routes to a human (deny-by-default).");
204
280
  else
205
- log("⚠ signing key: MISSING (DECIDIO_AGENT_KEY/DID) — without it the agent can't prove identity, so EVERY request routes to a human. Run `npx @decidio/sdk init` to generate one.");
281
+ log("⚠ signing key: MISSING (DECIDIO_AGENT_KEY/DID) — the agent can't prove identity, so EVERY request routes to a human. Run `npx @decidio/sdk init` to generate one.");
206
282
  // Reachability and token role are SEPARATE checks (the old probe called any <500 green, so a
207
283
  // wrong-kind token looked healthy). /api/health is unauthenticated truth about reachability;
208
- // /api/records then classifies the token: 2xx = session/admin scope, 401 = agent-floor scope
209
- // (correct for the runtime; init/approvals need a sign-in).
210
- try {
211
- const h = await api("/api/health", {}, "");
212
- line(h.status >= 200 && h.status < 300, `connectivity: API reachable (${h.status})`);
213
- const rec = await api("/api/records");
214
- if (rec.status >= 200 && rec.status < 300)
215
- log("✔ token scope: session/admin (init + approvals will work from this env)");
216
- else if (rec.status === 401)
217
- log("✔ token scope: agent floor correct for the runtime SDK (approvals/init will prompt for a sign-in)");
218
- else
219
- log(`⚠ token scope: unexpected ${rec.status} from /api/records`);
220
- }
221
- catch (e) {
222
- line(false, `connectivity: cannot reach ${API} — ${e?.message}`);
223
- }
224
- log(ok ? "\ndoctor: all green — durable resume ready." : "\ndoctor: fix the ✘ items above.");
284
+ // /api/records then classifies the token.
285
+ const h = await api("/api/health", {}, "");
286
+ line(h.status >= 200 && h.status < 300, `connectivity: API reachable (${h.status})`);
287
+ const rec = await api("/api/records");
288
+ if (rec.status >= 200 && rec.status < 300)
289
+ log("✔ token scope: session/admin (init, approvals and receipt work from this env)");
290
+ else if (rec.status === 401)
291
+ log("✔ token scope: agent floor correct for the runtime SDK (approvals/receipt will ask you to sign in)");
292
+ else
293
+ log(`⚠ token scope: unexpected ${rec.status} from /api/records`);
294
+ log(ok ? "\ndoctor: all green." : "\ndoctor: fix the ✘ items above, then re-run `npx @decidio/sdk doctor`.");
225
295
  return ok ? 0 : 1;
226
296
  }
227
297
  async function cmdApprovals(sub, id, reason) {
@@ -230,31 +300,51 @@ async function cmdApprovals(sub, id, reason) {
230
300
  let bearer;
231
301
  const probe = await api("/api/decisions/cards");
232
302
  if (probe.status === 401 || probe.status === 403) {
233
- const session = await acquireSession();
234
- if (!session) {
235
- log("✘ approvals need a workspace sign-in.");
303
+ const session = await acquireSession("Reviewing approvals");
304
+ if (!session)
236
305
  return 1;
237
- }
238
306
  bearer = session;
239
307
  }
240
308
  if (sub === "approve" || sub === "reject") {
241
309
  if (!id) {
242
- log(`usage: approvals ${sub} <decisionId> [reason]`);
310
+ log(`usage: npx @decidio/sdk approvals ${sub} <decisionId> [reason]`);
243
311
  return 2;
244
312
  }
245
313
  const r = await api(`/api/decisions/${encodeURIComponent(id)}/${sub === "approve" ? "approve" : "reject"}`, { method: "POST", body: JSON.stringify({ reason: reason ?? undefined }) }, bearer);
246
314
  if (r.status >= 200 && r.status < 300) {
315
+ // HONESTY over green checkmarks (Codex finding: the server resolves each decision exactly
316
+ // once and answers replays idempotently — a replay is a NO-OP and must read as one, never
317
+ // as a fresh approval). Non-zero exit so scripts can tell the difference.
318
+ if (r.body?.alreadyResolved) {
319
+ log(`• ${id} was already resolved — nothing changed.`);
320
+ log(` Decisions are single-use: a second ${sub} can never re-execute the action (that's the replay protection working).`);
321
+ log(` See its sealed record: npx @decidio/sdk receipt ${id}`);
322
+ return 1;
323
+ }
247
324
  log(`✔ ${sub}d ${id}${r.body?.resumeSignal ? ` — agent signalled (${r.body.resumeSignal.ok ? "ok" : r.body.resumeSignal.error})` : ""}`);
325
+ if (sub === "approve")
326
+ log(` The agent's function runs next — a blocking call resumes now; a durable one when its worker or resume route picks this up.`);
327
+ else
328
+ log(` The agent's action will NOT run; the rejection seals as a verifiable record.`);
329
+ log(` Download the receipt: npx @decidio/sdk receipt ${id}`);
248
330
  return 0;
249
331
  }
332
+ if (r.status === 404) {
333
+ log(`✘ no decision "${id}" in this workspace — check the id (npx @decidio/sdk approvals lists pending ones).`);
334
+ return 1;
335
+ }
250
336
  log(`✘ ${sub} failed (${r.status}): ${JSON.stringify(r.body)}`);
251
337
  return 1;
252
338
  }
253
339
  const r = bearer ? await api("/api/decisions/cards", {}, bearer) : probe;
340
+ if (!(r.status >= 200 && r.status < 300)) {
341
+ log(`✘ could not list approvals (${r.status}): ${JSON.stringify(r.body)}`);
342
+ return 1;
343
+ }
254
344
  const cards = r.body?.cards ?? [];
255
345
  const pending = cards.filter((c) => c.isAgentRequest && c.status !== "sealed");
256
346
  if (!pending.length) {
257
- log("No pending agent approvals.");
347
+ log("No pending agent approvals. Trigger one from your protected function, then re-run this.");
258
348
  return 0;
259
349
  }
260
350
  log(`Pending agent approvals (${pending.length}):`);
@@ -262,7 +352,44 @@ async function cmdApprovals(sub, id, reason) {
262
352
  const ar = c.agentRequest ?? {};
263
353
  log(` ${c.id} ${ar.action ?? "?"}${ar.amount != null ? ` $${Number(ar.amount).toLocaleString()}` : ""} — ${c.title ?? ""}`);
264
354
  }
265
- log(`\nApprove: decidio approvals approve <id> [reason] · Reject: decidio approvals reject <id> [reason]`);
355
+ log(`\nApprove: npx @decidio/sdk approvals approve <id> [reason]`);
356
+ log(`Reject: npx @decidio/sdk approvals reject <id> [reason]`);
357
+ return 0;
358
+ }
359
+ async function cmdReceipt(id) {
360
+ if (!id) {
361
+ log("usage: npx @decidio/sdk receipt <decisionId>");
362
+ log(" Downloads the decision's sealed Authority Receipt (a W3C Verifiable Credential) to a .json file.");
363
+ log(" Find decision ids via `npx @decidio/sdk approvals` or in your Decidio app.");
364
+ return 2;
365
+ }
366
+ // Receipts are workspace data — session scope, like approvals. (The record-detail /verify
367
+ // route carries the credential; the plain record route does not — Codex finding.)
368
+ let bearer;
369
+ let r = await api(`/api/records/${encodeURIComponent(id)}/verify`);
370
+ if (r.status === 401 || r.status === 403) {
371
+ const session = await acquireSession("Downloading a receipt");
372
+ if (!session)
373
+ return 1;
374
+ bearer = session;
375
+ r = await api(`/api/records/${encodeURIComponent(id)}/verify`, {}, bearer);
376
+ }
377
+ if (r.status === 404) {
378
+ log(`✘ no sealed record for "${id}" in this workspace.`);
379
+ log(` Receipts exist once a decision is SEALED (approved or rejected). Still pending? npx @decidio/sdk approvals`);
380
+ return 1;
381
+ }
382
+ if (!(r.status >= 200 && r.status < 300) || !r.body?.credential) {
383
+ log(`✘ could not fetch the receipt (${r.status}): ${JSON.stringify(r.body).slice(0, 300)}`);
384
+ return 1;
385
+ }
386
+ const name = `decidio-receipt-${id.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
387
+ writeFileSync(name, JSON.stringify(r.body.credential, null, 2) + "\n");
388
+ log(`✔ wrote ${name}`);
389
+ log(` Verify it offline — no Decidio account or network needed:`);
390
+ log(` npx @decidio/verify ${name}`);
391
+ log(` To also prove WHO issued it, pin your workspace's issuer DID (shown in your app's record verify panel):`);
392
+ log(` npx @decidio/verify --issuer <did:key:...> ${name}`);
266
393
  return 0;
267
394
  }
268
395
  function cmdDev(target, port) {
@@ -292,12 +419,33 @@ function cmdDev(target, port) {
292
419
  }).listen(port);
293
420
  return 0;
294
421
  }
422
+ const USAGE = `decidio — the Decidio SDK command line
423
+
424
+ npx @decidio/sdk init [agentId] sign in, register the agent, mint its API token, write .env
425
+ npx @decidio/sdk doctor check config, connectivity, and what your token can do
426
+ npx @decidio/sdk approvals list pending agent approvals
427
+ npx @decidio/sdk approvals approve <id> [reason]
428
+ npx @decidio/sdk approvals reject <id> [reason]
429
+ npx @decidio/sdk receipt <id> download a sealed decision's Authority Receipt (.json)
430
+ npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
431
+
432
+ Docs: the README that shipped with this package (quickstart, durable resume, adapters).`;
295
433
  async function main(argv) {
296
434
  const [cmd, a, b, ...rest] = argv.slice(2);
435
+ const wantsHelp = (s) => s === "help" || s === "--help" || s === "-h";
436
+ if (!cmd || wantsHelp(cmd)) {
437
+ log(USAGE);
438
+ return 0;
439
+ }
440
+ if (wantsHelp(a)) {
441
+ log(USAGE);
442
+ return 0;
443
+ }
297
444
  switch (cmd) {
298
- case "init": return cmdInit(a ?? process.env.DECIDIO_AGENT_ID ?? "salesops-agent");
445
+ case "init": return cmdInit((a && !a.startsWith("-") ? a : undefined) ?? process.env.DECIDIO_AGENT_ID ?? "my-agent");
299
446
  case "doctor": return cmdDoctor();
300
447
  case "approvals": return cmdApprovals(a, b, rest.join(" ") || undefined);
448
+ case "receipt": return cmdReceipt(a);
301
449
  case "dev": {
302
450
  const args = argv.slice(3);
303
451
  const flag = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
@@ -306,9 +454,9 @@ async function main(argv) {
306
454
  return cmdDev(target, port);
307
455
  }
308
456
  default:
309
- log("decidio <init|doctor|approvals|dev>\n init [agentId] · doctor · approvals [approve|reject <id> [reason]] · dev [--target URL] [--port N]");
310
- return cmd ? 1 : 0;
457
+ log(`✘ unknown command "${cmd}"\n\n${USAGE}`);
458
+ return 1;
311
459
  }
312
460
  }
313
461
  main(process.argv).then((code) => { if (code !== 0)
314
- process.exitCode = code; }).catch((e) => { console.error(e); process.exit(1); });
462
+ process.exitCode = code; }).catch((e) => { console.error(`✘ unexpected error: ${e?.message ?? e}`); process.exit(1); });
package/openapi.yaml CHANGED
@@ -107,6 +107,11 @@ components:
107
107
  OUTBOUND — Decidio → your resumeUrl when a routed decision is decided. The raw body is
108
108
  HMAC-signed; verify `x-decidio-signature: sha256=<hex>` with your DECIDIO_WEBHOOK_SECRET
109
109
  BEFORE acting (the SDK resume controllers fail closed). Re-execution is single-use.
110
+ OPERATOR DEPLOYMENTS ONLY: signing uses a shared secret configured on both the Decidio
111
+ server and the agent, and self-hosted production allow-lists resume hosts (default-deny).
112
+ Self-serve agents against the hosted sandbox use the SDK's poll worker (guard.worker())
113
+ instead — a locally generated secret the server never learned would (correctly) fail
114
+ closed here.
110
115
  required: [decisionId, verdict]
111
116
  properties:
112
117
  decisionId: { type: string }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decidio/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "One-line approval gate for AI-agent actions. Wrap a risky tool call with guard.protect(); Decidio's policy engine decides proceed | route | block, a human approves routed actions, and every outcome is sealed as a verifiable Authority Receipt.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -42,7 +42,7 @@
42
42
  "LICENSE"
43
43
  ],
44
44
  "engines": {
45
- "node": ">=20"
45
+ "node": ">=20.6"
46
46
  },
47
47
  "keywords": [
48
48
  "ai-agents",