@bondedhq/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
@@ -388,6 +388,27 @@ if (isBondedEpisode()) {
388
388
  `isBondedEpisode()` is true when the runner set valid `BONDED_*` variables. `fromBondedEnv` reads
389
389
  and checks them, naming every missing or malformed one (`BondedError`, code `EPISODE_ENV`).
390
390
 
391
+ That is the vault world, where ratings run. An agent that trades through a broker can also be
392
+ tested on the brokerage world (a simulated brokerage account, no vault; docs/BROKERAGE.md), with a
393
+ runner started with `--world brokerage`. Its episode is read with `fromBondedBrokerageEnv()`, and
394
+ `bondedEpisodeWorld()` says which world the runner set up:
395
+
396
+ ```ts
397
+ import { bondedEpisodeWorld, fromBondedBrokerageEnv, fromBondedEnv } from "@bondedhq/sdk";
398
+
399
+ const world = bondedEpisodeWorld(); // "vault", "brokerage", or undefined outside an episode
400
+ if (world === "brokerage") {
401
+ const episode = fromBondedBrokerageEnv(); // { world, episodeId, task, mode: "tools", endpoints, deadlineMs }
402
+ } else if (world === "vault") {
403
+ const episode = fromBondedEnv();
404
+ }
405
+ ```
406
+
407
+ `isBondedEpisode()` is true in both worlds, so an agent under test never falls through to its
408
+ production tools. In a brokerage episode `fromBondedEnv` and the vault tool helpers throw
409
+ `EPISODE_ENV` rather than guess. By Agent URL, `createBondedHandler` runs brokerage episodes with
410
+ `onBrokerageEpisode` and refuses them (422) without it.
411
+
391
412
  The mode decides how the agent acts:
392
413
 
393
414
  - **tools**: reads and writes are tool calls; `swap`, `transfer` and `approve` are tools, and the
@@ -537,19 +558,20 @@ an episode. `mcpConfigJson(episode, options)` returns the same text.
537
558
 
538
559
  ### Agent URL
539
560
 
540
- In Agent URL mode Bonded `POST`s each episode to your HTTPS endpoint, signed with your agent
541
- secret (`bonded_as_...`, shown once when you register the URL). Registering is two steps: save
542
- the URL and copy the secret into your agent, then verify, when Bonded sends a signed challenge
543
- your endpoint must answer with a proof that it holds the secret. `createBondedHandler` is the whole
544
- endpoint: a standard Fetch API handler for Hono, Next.js route handlers, Bun, Deno and Cloudflare
545
- Workers, with a Node adapter for Express and `node:http`.
561
+ In Agent URL mode Bonded `POST`s each episode to your HTTPS endpoint, signed with Bonded's own
562
+ key for the wallet that saved the URL. There is no secret: your handler names its `owner`, your
563
+ wallet, in code (it's public), and refuses calls signed for anyone else. Deploy it, save the URL
564
+ on Bonded from that wallet, then verify: Bonded sends a signed challenge and your endpoint answers
565
+ with its owner. `createBondedHandler` is the whole endpoint: a standard Fetch API handler for
566
+ Hono, Next.js route handlers, Bun, Deno and Cloudflare Workers, with a Node adapter for Express
567
+ and `node:http`.
546
568
 
547
569
  ```ts
548
570
  import { createBondedHandler, toNodeHandler } from "@bondedhq/sdk/agent-url";
549
571
  import { bondedAiSdkTools } from "@bondedhq/sdk/ai-sdk";
550
572
 
551
573
  const handler = createBondedHandler({
552
- secret: process.env.BONDED_AGENT_SECRET!,
574
+ owner: "0xYourWallet", // the wallet you save this URL from on Bonded; public, not a secret
553
575
  version: process.env.GIT_SHA, // reported when Bonded verifies the URL; becomes the code hash
554
576
  async onEpisode(episode, { signal }) {
555
577
  const tools = await bondedAiSdkTools(episode);
@@ -569,15 +591,21 @@ expressApp.post("/bonded", toNodeHandler(handler)); // Express: mount before exp
569
591
 
570
592
  What the handler does with each request:
571
593
 
572
- 1. **Checks the signature** before reading the message: `bonded-timestamp` (unix seconds) must be
573
- within 300 seconds of the server's clock, and `bonded-signature`
574
- (`v1=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>`, up to four while a secret rotates)
575
- must match, compared in constant time. `secret` may be a list while you rotate it. A signature
576
- it has accepted before is refused as a replay. Failures answer `401`, and a body that isn't one
577
- of Bonded's messages `400`; nothing runs.
578
- 2. `{ type: "verify", challenge }`: answers `200 { challenge, proof, version }`. `proof` is the
579
- hex HMAC-SHA256 of `"verify." + challenge` under the secret (`agentUrlVerifyProof`), so only a
580
- server holding the secret passes; an echo server doesn't.
594
+ 1. **Checks the call** before reading the message. `bonded-timestamp` (unix seconds) must be
595
+ within 300 seconds of the server's clock; `bonded-owner` must be your `owner` (a list works,
596
+ for a team); and `bonded-signature` (`v2=<key id>:<base64url Ed25519 signature>` over
597
+ `v2.<key id>.<timestamp>.<owner>.<raw body>`, up to four while a key rotates) must verify
598
+ under one of Bonded's public keys. The handler fetches those from
599
+ `https://www.bondedhq.xyz/.well-known/bonded-agent-keys.json` (`apiUrl` takes them from
600
+ another Bonded deployment instead, for a local Arena run), keeps them for a day, and fetches
601
+ again at once (at most once a minute) for a key it hasn't seen. A signature it has accepted before is refused as
602
+ a replay. Failures answer `401` (with `code: "wrong-owner"` when the call is
603
+ for a wallet the handler isn't set up for, so Bonded can tell you exactly that), a body that
604
+ isn't one of Bonded's messages `400`, and keys it can't fetch `503`; nothing runs.
605
+ 2. `{ type: "verify", challenge }`: answers `200 { challenge, owner, version }`. Bonded marks the
606
+ URL verified when `owner` is the wallet that saved it. The owner is only in the signed
607
+ headers, never the body, so an echo server can't pass, and someone who saves your URL gets
608
+ calls signed for their own wallet, which your handler refuses.
581
609
  3. `{ type: "episode", ... }`: answers `202` at once (Bonded waits at most 10 seconds) and runs
582
610
  `onEpisode` in the background. When it resolves, the handler posts `/done` with the
583
611
  `finalMessage` and `usage` it returned. If it throws, `/done` is still posted, saying the agent
@@ -588,10 +616,30 @@ What the handler does with each request:
588
616
  Limits: 256 KiB bodies (`maxBodyBytes`, the most Bonded sends), 8 episodes at once (`maxConcurrent`; Bonded sends one at a
589
617
  time per agent), and the same episode delivered twice runs once. On platforms that stop work when
590
618
  the response is sent, pass their `waitUntil` (as the option, or the handler's second argument).
591
- `signBondedRequest(secret, body)` signs a request the way Bonded does, for testing your endpoint;
592
- `verifyBondedSignature(...)` is the signature check on its own. Both use the scheme Bonded's
593
- gateway signs with, from `@bondedhq/shared/agent-url` (a small entry point without the contract ABIs,
594
- for edge bundles). A Python endpoint with no dependencies is in
619
+
620
+ To test your endpoint, `createTestSigner()` stands in for Bonded: give the handler its `keys`, and
621
+ `sign(owner, body)` returns the headers Bonded would send.
622
+
623
+ ```ts
624
+ import { createBondedHandler, createTestSigner } from "@bondedhq/sdk/agent-url";
625
+
626
+ const owner = "0x1111111111111111111111111111111111111111"; // your wallet
627
+ const bonded = await createTestSigner();
628
+ const handler = createBondedHandler({ owner, keys: bonded.keys, onEpisode });
629
+ const body = JSON.stringify({ type: "verify", challenge: "a-test-challenge-0001" });
630
+ const res = await handler(
631
+ new Request("http://localhost/bonded", {
632
+ method: "POST",
633
+ headers: { "content-type": "application/json", ...(await bonded.sign(owner, body)) },
634
+ body,
635
+ }),
636
+ );
637
+ // 200 { challenge: "a-test-challenge-0001", owner: "0x1111…1111" }
638
+ ```
639
+
640
+ `verifyAgentUrlRequest(...)` is the check on its own and `answerAgentUrlVerify(...)` the verify
641
+ answer, from `@bondedhq/shared/agent-url` (a small entry point without the contract ABIs, for
642
+ edge bundles). A Python endpoint (the standard library and `cryptography`) is in
595
643
  [`examples/python-agent-url`](../../examples/python-agent-url/README.md).
596
644
 
597
645
  ## Vault trading tools for LangChain, LangGraph and the AI SDK
@@ -1,21 +1,23 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
- import type { BondedEpisode } from "./episode.js";
2
+ import { type AgentUrlPublicKey } from "@bondedhq/shared/agent-url";
3
+ import type { BondedBrokerageEpisode, BondedEpisode } from "./episode.js";
3
4
  import type { EpisodeUsage } from "./mcp.js";
4
- /** For your own handler: the proof a verify answer carries, and the whole answer. */
5
- export { agentUrlVerifyProof, answerAgentUrlVerify } from "@bondedhq/shared/agent-url";
6
- export { SIGNATURE_HEADER, SIGNATURE_TOLERANCE_SECONDS, signBondedRequest, type SignatureCheck, TIMESTAMP_HEADER, verifyBondedSignature, } from "./signature.js";
5
+ /** For your own handler: the verify answer, and the request check. */
6
+ export { AGENT_URL_KEYS_PATH, AgentUrlKeys, type AgentUrlPublicKey, answerAgentUrlVerify, verifyAgentUrlRequest, } from "@bondedhq/shared/agent-url";
7
+ export { BONDED_AGENT_KEYS, BONDED_AGENT_KEYS_URL, createTestSigner, keysUrlOf, OWNER_HEADER, SIGNATURE_HEADER, SIGNATURE_TOLERANCE_SECONDS, TIMESTAMP_HEADER, } from "./signature.js";
7
8
  /**
8
9
  * Agent URL mode: instead of running the Bonded runner, an agent serves one HTTPS endpoint and
9
10
  * Bonded calls it once per episode. Every call is a signed `POST` of one JSON message:
10
11
  *
11
- * - `{ type: "verify", challenge }` when the URL is registered: answer
12
- * `200 { challenge, proof, version? }`, where `proof` is the hex HMAC-SHA256 of
13
- * `"verify." + challenge` under the secret.
12
+ * - `{ type: "verify", challenge }` when the URL is saved: answer
13
+ * `200 { challenge, owner, version? }`, where `owner` is your wallet.
14
14
  * - `{ type: "episode", ... }` (the runner protocol's `EpisodeMessage`): answer `2xx` within
15
15
  * 10 seconds, then work: call the episode's tools and `POST {done}` when finished.
16
16
  * - `{ type: "cancel", episodeId }`: stop that episode (best effort).
17
17
  *
18
- * `createBondedHandler` does all of that: it checks signatures, answers challenges, acknowledges an
18
+ * Every call is signed by Bonded's own key and names the owner it is for; there is no secret to
19
+ * set. `createBondedHandler` does all of that: it checks signatures against Bonded's published
20
+ * keys, refuses calls for any wallet but `owner`, answers challenges, acknowledges an
19
21
  * episode at once and runs your `onEpisode` in the background, aborts it on cancel or at the
20
22
  * deadline, and posts `/done` (with the usage you return) when it resolves.
21
23
  */
@@ -34,12 +36,28 @@ export interface EpisodeContext {
34
36
  }
35
37
  export interface BondedHandlerOptions {
36
38
  /**
37
- * The agent secret Bonded showed you when you registered the URL (`bonded_as_...`), or several
38
- * while you rotate it (any one may match).
39
+ * Your wallet: the one you sign in to Bonded with and save this URL from (or several, for a
40
+ * team). Bonded signs each call for the wallet that saved the URL, and the handler refuses calls
41
+ * for anyone else, so nobody can claim your URL as theirs. It's public, not a secret.
39
42
  */
40
- secret: string | string[];
43
+ owner: string | string[];
44
+ /**
45
+ * Another Bonded deployment to take the signing keys from (a local Arena, a staging API). Leave
46
+ * it out for Bonded itself: the keys then come from BONDED_AGENT_KEYS_URL.
47
+ */
48
+ apiUrl?: string;
49
+ /** Bonded's public keys, given directly instead of fetched (for tests: `createTestSigner`). */
50
+ keys?: AgentUrlPublicKey[];
41
51
  /** Runs one episode. Resolve when the agent is finished; the handler then posts `/done`. */
42
52
  onEpisode(episode: BondedEpisode, context: EpisodeContext): Promise<EpisodeOutcome | void> | EpisodeOutcome | void;
53
+ /**
54
+ * Runs one brokerage episode (a simulated brokerage account; docs/BROKERAGE.md). Without it, the
55
+ * handler refuses brokerage episodes with 422, so an agent built for the vault world never gets
56
+ * one by surprise. Errors are passed to `onBrokerageError` (default `console.error`).
57
+ */
58
+ onBrokerageEpisode?(episode: BondedBrokerageEpisode, context: EpisodeContext): Promise<EpisodeOutcome | void> | EpisodeOutcome | void;
59
+ /** Called when `onBrokerageEpisode` throws (default: `console.error`). `/done` is still posted. */
60
+ onBrokerageError?: (error: unknown, episode: BondedBrokerageEpisode) => void;
43
61
  /**
44
62
  * Your agent's version (a release tag, a git commit). Reported when Bonded verifies the URL;
45
63
  * it becomes the rating's code hash when you don't declare one.
@@ -72,7 +90,7 @@ export type BondedHandler = (request: Request, context?: {
72
90
  *
73
91
  * ```ts
74
92
  * const handler = createBondedHandler({
75
- * secret: process.env.BONDED_AGENT_SECRET!,
93
+ * owner: "0xYourWallet",
76
94
  * version: process.env.GIT_SHA,
77
95
  * async onEpisode(episode, { signal }) {
78
96
  * const tools = await bondedAiSdkTools(episode);
package/dist/agent-url.js CHANGED
@@ -1,14 +1,15 @@
1
- import { AGENT_URL_MAX_BODY_BYTES, AGENT_URL_TOLERANCE_SECONDS, AgentUrlReplayGuard, answerAgentUrlVerify, verifyAgentUrlRequest, } from "@bondedhq/shared/agent-url";
1
+ import { AGENT_URL_MAX_BODY_BYTES, AGENT_URL_TOLERANCE_SECONDS, AgentUrlKeys, AgentUrlReplayGuard, agentUrlSignatureKeyIds, answerAgentUrlVerify, isAgentUrlOwner, verifyAgentUrlRequest, } from "@bondedhq/shared/agent-url";
2
+ import { BONDED_AGENT_KEYS, BONDED_AGENT_KEYS_URL, keysUrlOf } from "./signature.js";
2
3
  import { finishEpisode } from "./done.js";
3
- /** For your own handler: the proof a verify answer carries, and the whole answer. */
4
- export { agentUrlVerifyProof, answerAgentUrlVerify } from "@bondedhq/shared/agent-url";
5
- export { SIGNATURE_HEADER, SIGNATURE_TOLERANCE_SECONDS, signBondedRequest, TIMESTAMP_HEADER, verifyBondedSignature, } from "./signature.js";
4
+ /** For your own handler: the verify answer, and the request check. */
5
+ export { AGENT_URL_KEYS_PATH, AgentUrlKeys, answerAgentUrlVerify, verifyAgentUrlRequest, } from "@bondedhq/shared/agent-url";
6
+ export { BONDED_AGENT_KEYS, BONDED_AGENT_KEYS_URL, createTestSigner, keysUrlOf, OWNER_HEADER, SIGNATURE_HEADER, SIGNATURE_TOLERANCE_SECONDS, TIMESTAMP_HEADER, } from "./signature.js";
6
7
  /**
7
8
  * An Agent URL endpoint for your agent.
8
9
  *
9
10
  * ```ts
10
11
  * const handler = createBondedHandler({
11
- * secret: process.env.BONDED_AGENT_SECRET!,
12
+ * owner: "0xYourWallet",
12
13
  * version: process.env.GIT_SHA,
13
14
  * async onEpisode(episode, { signal }) {
14
15
  * const tools = await bondedAiSdkTools(episode);
@@ -20,29 +21,49 @@ export { SIGNATURE_HEADER, SIGNATURE_TOLERANCE_SECONDS, signBondedRequest, TIMES
20
21
  * ```
21
22
  */
22
23
  export function createBondedHandler(options) {
23
- if (!options.secret || (Array.isArray(options.secret) && options.secret.length === 0))
24
- throw new Error("createBondedHandler needs the agent secret");
24
+ const owners = Array.isArray(options.owner) ? options.owner : [options.owner];
25
+ if (owners.length === 0 || !owners.every((o) => typeof o === "string" && isAgentUrlOwner(o)))
26
+ throw new Error("createBondedHandler needs owner: your wallet address (0x…), the one you save this URL from");
27
+ const keyStore = options.keys
28
+ ? undefined
29
+ : new AgentUrlKeys(options.apiUrl ? keysUrlOf(options.apiUrl) : BONDED_AGENT_KEYS_URL, {
30
+ // The built-in keys are Bonded's own: never a fallback for another deployment's.
31
+ ...(options.apiUrl ? {} : { fallback: BONDED_AGENT_KEYS }),
32
+ ...(options.fetch ? { fetch: options.fetch } : {}),
33
+ ...(options.now ? { now: options.now } : {}),
34
+ });
25
35
  const now = options.now ?? Date.now;
26
36
  const tolerance = options.toleranceSeconds ?? AGENT_URL_TOLERANCE_SECONDS;
27
37
  const maxBody = options.maxBodyBytes ?? AGENT_URL_MAX_BODY_BYTES;
28
38
  const maxConcurrent = options.maxConcurrent ?? 8;
29
39
  const onError = options.onError ??
30
40
  ((error, episode) => console.error(`Bonded episode ${episode.episodeId} failed:`, error));
31
- const secrets = Array.isArray(options.secret) ? options.secret : [options.secret];
32
41
  /** Signatures already accepted, until they fall out of the window: replays are refused. */
33
42
  const replay = new AgentUrlReplayGuard(tolerance);
34
43
  const running = new Map();
35
- const run = async (episode, controller) => {
44
+ const onBrokerageError = options.onBrokerageError ??
45
+ ((error, episode) => console.error(`Bonded brokerage episode ${episode.episodeId} failed:`, error));
46
+ const run = async (episode, controller, work, reportError) => {
36
47
  const deadline = setTimeout(() => controller.abort(new Error("the episode's deadline passed")), episode.deadlineMs);
48
+ // A reporter that throws must not stop `/done` from being posted: the gateway would wait out
49
+ // the deadline for an episode that has already failed.
50
+ const report = (error) => {
51
+ try {
52
+ reportError(error, episode);
53
+ }
54
+ catch (reporterError) {
55
+ console.error(`Bonded episode ${episode.episodeId}: the error reporter threw:`, reporterError);
56
+ }
57
+ };
37
58
  let outcome;
38
59
  let failed = false;
39
60
  try {
40
- outcome = await options.onEpisode(episode, { signal: controller.signal });
61
+ outcome = await work(episode, { signal: controller.signal });
41
62
  }
42
63
  catch (error) {
43
64
  failed = true;
44
65
  if (!controller.signal.aborted)
45
- onError(error, episode);
66
+ report(error);
46
67
  outcome = {
47
68
  finalMessage: `the agent failed: ${error instanceof Error ? error.message : String(error)}`.slice(0, 4_000),
48
69
  };
@@ -64,7 +85,7 @@ export function createBondedHandler(options) {
64
85
  }
65
86
  catch (error) {
66
87
  if (!failed)
67
- onError(error, episode);
88
+ report(error);
68
89
  }
69
90
  };
70
91
  return async (request, context) => {
@@ -73,8 +94,19 @@ export function createBondedHandler(options) {
73
94
  const body = await readCapped(request, maxBody);
74
95
  if (body === undefined)
75
96
  return json(413, { error: `the body is over ${maxBody} bytes` });
97
+ let keys;
98
+ try {
99
+ keys =
100
+ options.keys ??
101
+ (await keyStore.get(agentUrlSignatureKeyIds(request.headers).find(Boolean)));
102
+ }
103
+ catch (error) {
104
+ console.error("Bonded: couldn't fetch Bonded's signing keys:", error);
105
+ return json(503, { error: "the agent couldn't fetch Bonded's signing keys; try again" });
106
+ }
76
107
  const check = await verifyAgentUrlRequest({
77
- secret: options.secret,
108
+ owner: owners,
109
+ keys,
78
110
  body,
79
111
  headers: request.headers,
80
112
  nowMs: now(),
@@ -82,45 +114,51 @@ export function createBondedHandler(options) {
82
114
  replay,
83
115
  });
84
116
  if (!check.ok)
85
- return json(check.status, { error: check.reason });
117
+ return json(check.status, {
118
+ error: check.reason,
119
+ ...(check.code ? { code: check.code } : {}),
120
+ });
86
121
  const message = check.message;
87
- // The answer proves the secret (an HMAC of the challenge), so an echo server can't pass.
122
+ // The answer names the owner the call was signed for (one of ours, checked above). The verify
123
+ // body carries no owner, so a server that echoes it back can't pass.
88
124
  if (message.type === "verify")
89
- return json(200, await answerAgentUrlVerify(await signingSecret(secrets, body, request.headers, now(), tolerance), message, options.version));
125
+ return json(200, answerAgentUrlVerify(check.owner, message, options.version));
90
126
  if (message.type === "cancel") {
91
127
  const controller = running.get(message.episodeId);
92
128
  controller?.abort(new Error("Bonded cancelled the episode"));
93
129
  return json(200, { ok: true, cancelled: controller !== undefined });
94
130
  }
95
- const episode = toEpisode(message);
96
- if (running.has(episode.episodeId))
97
- return json(200, { ok: true, episodeId: episode.episodeId, alreadyRunning: true });
131
+ if (message.world === "brokerage" && !options.onBrokerageEpisode)
132
+ return json(422, {
133
+ error: "this agent doesn't take brokerage episodes (no onBrokerageEpisode)",
134
+ });
135
+ if (running.has(message.episodeId))
136
+ return json(200, { ok: true, episodeId: message.episodeId, alreadyRunning: true });
98
137
  if (running.size >= maxConcurrent)
99
138
  return json(429, { error: `already running ${running.size} episodes` });
100
139
  const controller = new AbortController();
101
- running.set(episode.episodeId, controller);
102
- const work = run(episode, controller);
140
+ running.set(message.episodeId, controller);
141
+ const work = message.world === "brokerage"
142
+ ? run(toBrokerageEpisode(message), controller, (e, c) => options.onBrokerageEpisode(e, c), onBrokerageError)
143
+ : run(toEpisode(message), controller, (e, c) => options.onEpisode(e, c), onError);
103
144
  // Called as a method: Cloudflare's ctx.waitUntil needs its `this`.
104
145
  if (context?.waitUntil)
105
146
  context.waitUntil(work);
106
147
  else
107
148
  options.waitUntil?.(work);
108
- return json(202, { ok: true, episodeId: episode.episodeId });
149
+ return json(202, { ok: true, episodeId: message.episodeId });
109
150
  };
110
151
  }
111
- /**
112
- * Which of the secrets signed a request already verified (while a secret rotates, the verify
113
- * proof must be made with the one Bonded holds).
114
- */
115
- async function signingSecret(secrets, body, headers, nowMs, toleranceSeconds) {
116
- if (secrets.length === 1)
117
- return secrets[0];
118
- for (const secret of secrets) {
119
- const check = await verifyAgentUrlRequest({ secret, body, headers, nowMs, toleranceSeconds });
120
- if (check.ok)
121
- return secret;
122
- }
123
- return secrets[0];
152
+ /** A brokerage episode as the agent sees it (the same shape `fromBondedBrokerageEnv` returns). */
153
+ function toBrokerageEpisode(message) {
154
+ return {
155
+ world: "brokerage",
156
+ episodeId: message.episodeId,
157
+ task: message.task,
158
+ mode: "tools",
159
+ endpoints: message.endpoints,
160
+ deadlineMs: message.deadlineMs,
161
+ };
124
162
  }
125
163
  /** The episode as the agent sees it (the same shape `fromBondedEnv` returns). */
126
164
  function toEpisode(message) {
package/dist/episode.d.ts CHANGED
@@ -41,6 +41,7 @@ export interface BondedEpisode {
41
41
  * next to `episodeEnv` that writes them, so the runner, the gateway and the SDK can't drift.
42
42
  */
43
43
  export declare const bondedEpisodeEnv: import("zod").ZodObject<{
44
+ BONDED_WORLD: import("zod").ZodOptional<import("zod").ZodLiteral<"vault">>;
44
45
  BONDED_EPISODE_ID: import("zod").ZodString;
45
46
  BONDED_MODE: import("zod").ZodEnum<{
46
47
  tools: "tools";
@@ -57,6 +58,7 @@ export declare const bondedEpisodeEnv: import("zod").ZodObject<{
57
58
  approvalAllowlist: import("zod").ZodArray<import("zod").ZodPipe<import("zod").ZodString, import("zod").ZodTransform<`0x${string}`, string>>>;
58
59
  }, import("zod/v4/core").$strip>>;
59
60
  maxSteps: import("zod").ZodNumber;
61
+ tools: import("zod").ZodOptional<import("zod").ZodReadonly<import("zod").ZodArray<import("zod").ZodString>>>;
60
62
  }, import("zod/v4/core").$strip>>;
61
63
  BONDED_MCP_URL: import("zod").ZodURL;
62
64
  BONDED_TOOLS_URL: import("zod").ZodURL;
@@ -84,6 +86,11 @@ export declare const bondedEpisodeEnv: import("zod").ZodObject<{
84
86
  * ```ts
85
87
  * const tools = isBondedEpisode() ? await bondedLangChainTools() : productionTools;
86
88
  * ```
89
+ *
90
+ * True in every world, so an agent under test never falls through to its production tools. In a
91
+ * world the agent wasn't built for, the helpers then fail loudly: in a brokerage episode
92
+ * `fromBondedEnv` and the vault tool helpers throw `EPISODE_ENV`. `bondedEpisodeWorld()` says
93
+ * which world it is.
87
94
  */
88
95
  export declare function isBondedEpisode(env?: Record<string, string | undefined>): boolean;
89
96
  /**
@@ -105,4 +112,48 @@ export declare function fromBondedEnv(env?: Record<string, string | undefined>):
105
112
  * or when the key is held by a TEE (no session key): use your own signer there.
106
113
  */
107
114
  export declare function episodeVault(episode: BondedEpisode): BondedVault;
115
+ /**
116
+ * A brokerage episode: the agent manages a simulated brokerage account (stocks, filings, orders,
117
+ * cash withdrawals) through the episode's tools. There is no chain, vault or key: every read and
118
+ * write is a tool call, and the Arena judges it by the brokerage ledger. Needs runner protocol 2
119
+ * (`@bondedhq/runner` 0.1.4 or later), started with `--world brokerage`.
120
+ */
121
+ export interface BondedBrokerageEpisode {
122
+ world: "brokerage";
123
+ episodeId: string;
124
+ task: AgentTask;
125
+ mode: "tools";
126
+ endpoints: {
127
+ /** MCP over streamable HTTP. */
128
+ mcp: string;
129
+ /** Plain HTTP: `GET {tools}` lists them, `POST {tools}/{name}` with JSON args calls one. */
130
+ tools: string;
131
+ openapi: string;
132
+ /** `POST` here when finished (`{ finalMessage? }`). */
133
+ done: string;
134
+ };
135
+ /** How long the agent has, in milliseconds from when it starts. */
136
+ deadlineMs: number;
137
+ }
138
+ /**
139
+ * Which world the runner's episode is for: `vault` (on-chain trading from a Mandate Vault, read
140
+ * with `fromBondedEnv`), `brokerage` (read with `fromBondedBrokerageEnv`), or undefined when this
141
+ * process isn't running a Bonded episode.
142
+ *
143
+ * ```ts
144
+ * const world = bondedEpisodeWorld();
145
+ * const tools =
146
+ * world === "brokerage"
147
+ * ? await myBrokerageTools(fromBondedBrokerageEnv())
148
+ * : world === "vault"
149
+ * ? await bondedLangChainTools()
150
+ * : productionTools;
151
+ * ```
152
+ */
153
+ export declare function bondedEpisodeWorld(env?: Record<string, string | undefined>): "vault" | "brokerage" | undefined;
154
+ /**
155
+ * Reads a brokerage episode the Bonded runner set up from the environment. Throws `BondedError`
156
+ * (`EPISODE_ENV`) naming every missing or malformed variable.
157
+ */
158
+ export declare function fromBondedBrokerageEnv(env?: Record<string, string | undefined>): BondedBrokerageEpisode;
108
159
  //# sourceMappingURL=episode.d.ts.map
package/dist/episode.js CHANGED
@@ -1,4 +1,4 @@
1
- import { EpisodeEnv, parseEpisodeEnv } from "@bondedhq/shared";
1
+ import { EpisodeEnv, parseBrokerageEpisodeEnv, parseEpisodeEnv, } from "@bondedhq/shared";
2
2
  import { defineChain } from "viem";
3
3
  import { privateKeyToAccount } from "viem/accounts";
4
4
  import { BondedError } from "./errors.js";
@@ -15,9 +15,14 @@ export const bondedEpisodeEnv = EpisodeEnv;
15
15
  * ```ts
16
16
  * const tools = isBondedEpisode() ? await bondedLangChainTools() : productionTools;
17
17
  * ```
18
+ *
19
+ * True in every world, so an agent under test never falls through to its production tools. In a
20
+ * world the agent wasn't built for, the helpers then fail loudly: in a brokerage episode
21
+ * `fromBondedEnv` and the vault tool helpers throw `EPISODE_ENV`. `bondedEpisodeWorld()` says
22
+ * which world it is.
18
23
  */
19
24
  export function isBondedEpisode(env = typeof process === "undefined" ? {} : process.env) {
20
- return parseEpisodeEnv(env).ok;
25
+ return bondedEpisodeWorld(env) !== undefined;
21
26
  }
22
27
  /**
23
28
  * Reads the episode the Bonded runner set up from the environment. Throws `BondedError`
@@ -62,4 +67,37 @@ export function episodeVault(episode) {
62
67
  router: episode.contracts.exchange,
63
68
  });
64
69
  }
70
+ /**
71
+ * Which world the runner's episode is for: `vault` (on-chain trading from a Mandate Vault, read
72
+ * with `fromBondedEnv`), `brokerage` (read with `fromBondedBrokerageEnv`), or undefined when this
73
+ * process isn't running a Bonded episode.
74
+ *
75
+ * ```ts
76
+ * const world = bondedEpisodeWorld();
77
+ * const tools =
78
+ * world === "brokerage"
79
+ * ? await myBrokerageTools(fromBondedBrokerageEnv())
80
+ * : world === "vault"
81
+ * ? await bondedLangChainTools()
82
+ * : productionTools;
83
+ * ```
84
+ */
85
+ export function bondedEpisodeWorld(env = typeof process === "undefined" ? {} : process.env) {
86
+ // Fails closed: once the runner has named the brokerage world for an episode, that is the
87
+ // answer even if another variable is malformed. `fromBondedBrokerageEnv` then names the bad
88
+ // variable, where "not an episode" would send the agent to its production tools.
89
+ if (env.BONDED_WORLD === "brokerage" && env.BONDED_EPISODE_ID?.trim())
90
+ return "brokerage";
91
+ return parseEpisodeEnv(env).ok ? "vault" : undefined;
92
+ }
93
+ /**
94
+ * Reads a brokerage episode the Bonded runner set up from the environment. Throws `BondedError`
95
+ * (`EPISODE_ENV`) naming every missing or malformed variable.
96
+ */
97
+ export function fromBondedBrokerageEnv(env = process.env) {
98
+ const parsed = parseBrokerageEpisodeEnv(env);
99
+ if (!parsed.ok)
100
+ throw new BondedError("EPISODE_ENV", `not a valid Bonded brokerage episode environment: ${parsed.problems.join("; ")}`);
101
+ return parsed.episode;
102
+ }
65
103
  //# sourceMappingURL=episode.js.map
package/dist/index.d.ts CHANGED
@@ -24,6 +24,6 @@ export { BONDED_API_URL, PaymentGate, paymentGate, type GateAnswer, type GateChe
24
24
  export { guardPayments, guardVendorChanges, isPaymentRefusal, PaymentGuard, paymentGuard, type GuardPaymentsOptions, type GuardVendorChangesOptions, type PaymentBreach, type PaymentGuardOptions, type PaymentGuardState, type PaymentMandate, type PaymentSource, type PaymentVendor, type PaymentVerdict, } from "./payments.js";
25
25
  export { describePaymentMandate, GATE_RULE_WORDS, GATE_RULES, PAYMENT_RULE_WORDS, PAYMENT_RULES, type GateRule, type PaidPayment, type PayableInvoice, type PayableRefund, type PaymentAttempt, type PaymentRule, type VerifiedChange, } from "@bondedhq/shared";
26
26
  export { BONDED_VAULT_TOOLS, bondedVaultFromEnv, bondedVaultTools, type BondedVaultTool, type BondedVaultToolName, type BondedVaultToolsOptions, } from "./vault-tools.js";
27
- export { bondedEpisodeEnv, episodeVault, fromBondedEnv, isBondedEpisode, type BondedEpisode, } from "./episode.js";
27
+ export { bondedEpisodeEnv, bondedEpisodeWorld, episodeVault, fromBondedBrokerageEnv, fromBondedEnv, isBondedEpisode, type BondedBrokerageEpisode, type BondedEpisode, } from "./episode.js";
28
28
  export { checkUnprotectedAllowances, deploymentTokens, unprotectedAllowancesWarning, type AllowanceCheck, type AllowanceFinding, type TokenRef, installMandateModule, type InstallMandateModuleInput, type ModuleAccountType, type ModuleInstallPlan, } from "./module.js";
29
29
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -23,6 +23,6 @@ export { BONDED_API_URL, PaymentGate, paymentGate, } from "./gate.js";
23
23
  export { guardPayments, guardVendorChanges, isPaymentRefusal, PaymentGuard, paymentGuard, } from "./payments.js";
24
24
  export { describePaymentMandate, GATE_RULE_WORDS, GATE_RULES, PAYMENT_RULE_WORDS, PAYMENT_RULES, } from "@bondedhq/shared";
25
25
  export { BONDED_VAULT_TOOLS, bondedVaultFromEnv, bondedVaultTools, } from "./vault-tools.js";
26
- export { bondedEpisodeEnv, episodeVault, fromBondedEnv, isBondedEpisode, } from "./episode.js";
26
+ export { bondedEpisodeEnv, bondedEpisodeWorld, episodeVault, fromBondedBrokerageEnv, fromBondedEnv, isBondedEpisode, } from "./episode.js";
27
27
  export { checkUnprotectedAllowances, deploymentTokens, unprotectedAllowancesWarning, installMandateModule, } from "./module.js";
28
28
  //# sourceMappingURL=index.js.map
@@ -1,34 +1,50 @@
1
+ /**
2
+ * Agent URL request signatures: how an agent knows a request really comes from Bonded, for its
3
+ * owner.
4
+ *
5
+ * Bonded signs every call to an Agent URL with its own Ed25519 key (no shared secret):
6
+ * - `bonded-timestamp`: unix seconds;
7
+ * - `bonded-owner`: the wallet that saved the URL on Bonded;
8
+ * - `bonded-signature`: `v2=<key id>:<base64url signature>` over
9
+ * `v2.<key id>.<timestamp>.<owner>.<raw body>`.
10
+ *
11
+ * The handler checks it against Bonded's published keys (BONDED_AGENT_KEYS_URL) and refuses calls
12
+ * for any owner but its own. The scheme lives in @bondedhq/shared
13
+ * (`@bondedhq/shared/agent-url`, which the gateway signs with). Web Crypto only, so it runs in
14
+ * Node 22+, Bun, Deno, Cloudflare Workers and edge runtimes.
15
+ */
16
+ import { type AgentUrlPublicKey } from "@bondedhq/shared/agent-url";
1
17
  export declare const SIGNATURE_HEADER = "bonded-signature";
2
18
  export declare const TIMESTAMP_HEADER = "bonded-timestamp";
19
+ export declare const OWNER_HEADER = "bonded-owner";
3
20
  /** How far a request's timestamp may be from the agent's clock, either way. */
4
21
  export declare const SIGNATURE_TOLERANCE_SECONDS = 300;
5
22
  /**
6
- * Signs a body as Bonded does: the two headers to send with it. For testing your endpoint
7
- * locally; Bonded signs real calls itself.
23
+ * Where Bonded publishes the keys that sign calls to agents. It is on Bonded's own domain, not the
24
+ * API's host name, because this address is built into every deployed handler: the API can move
25
+ * without breaking them.
8
26
  */
9
- export declare function signBondedRequest(secret: string, body: string, timestamp?: number): Promise<{
10
- "bonded-timestamp": string;
11
- "bonded-signature": string;
12
- }>;
13
- export type SignatureCheck = {
14
- ok: true;
15
- timestamp: number;
16
- } | {
17
- ok: false;
18
- reason: string;
19
- };
27
+ export declare const BONDED_AGENT_KEYS_URL = "https://www.bondedhq.xyz/.well-known/bonded-agent-keys.json";
20
28
  /**
21
- * Checks a request's signature only: the timestamp is within `toleranceSeconds` of now and one of
22
- * the header's `v1=` signatures is the body's HMAC under `secret` (or one of several secrets).
23
- * It doesn't look at the body or check for replays; `createBondedHandler` does both.
29
+ * Bonded's production keys, built in: used only when BONDED_AGENT_KEYS_URL can't be reached and
30
+ * no key set was fetched before, so a handler still starts then. The same set the keys address
31
+ * publishes (docs/AGENT-URL-SIGNED-CALLS.md, Section 6): on a key rotation, add the new public key
32
+ * here before a release.
24
33
  */
25
- export declare function verifyBondedSignature(input: {
26
- secret: string | string[];
27
- body: string;
28
- timestamp: string | null | undefined;
29
- signature: string | null | undefined;
30
- /** Unix seconds. Default: now. */
31
- now?: number;
32
- toleranceSeconds?: number;
33
- }): Promise<SignatureCheck>;
34
+ export declare const BONDED_AGENT_KEYS: AgentUrlPublicKey[];
35
+ /** Where another Bonded deployment (a local Arena, a staging API) publishes its keys. */
36
+ export declare const keysUrlOf: (apiUrl: string) => string;
37
+ /**
38
+ * A stand-in for Bonded, for testing your endpoint locally: a fresh key, `sign` to sign a body
39
+ * for an owner the way Bonded does, and `keys` to give the handler (`createBondedHandler({ keys })`).
40
+ * Bonded signs real calls itself, with keys your handler fetches.
41
+ */
42
+ export declare function createTestSigner(): Promise<{
43
+ keys: AgentUrlPublicKey[];
44
+ sign(owner: string, body: string, timestamp?: number): Promise<{
45
+ "bonded-timestamp": string;
46
+ "bonded-owner": string;
47
+ "bonded-signature": string;
48
+ }>;
49
+ }>;
34
50
  //# sourceMappingURL=signature.d.ts.map
package/dist/signature.js CHANGED
@@ -1,52 +1,59 @@
1
1
  /**
2
- * Agent URL request signatures: how an agent knows a request really comes from Bonded.
2
+ * Agent URL request signatures: how an agent knows a request really comes from Bonded, for its
3
+ * owner.
3
4
  *
4
- * Bonded signs every call to an Agent URL with the agent's secret:
5
+ * Bonded signs every call to an Agent URL with its own Ed25519 key (no shared secret):
5
6
  * - `bonded-timestamp`: unix seconds;
6
- * - `bonded-signature`: `v1=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>` (up to four,
7
- * comma-separated, while a secret is rotated).
7
+ * - `bonded-owner`: the wallet that saved the URL on Bonded;
8
+ * - `bonded-signature`: `v2=<key id>:<base64url signature>` over
9
+ * `v2.<key id>.<timestamp>.<owner>.<raw body>`.
8
10
  *
9
- * The scheme lives in @bondedhq/shared (`@bondedhq/shared/agent-url`, which the gateway signs with);
10
- * these are the SDK's names for it. Web Crypto only, so it runs in Node, Bun, Deno, Cloudflare
11
- * Workers and edge runtimes, and the comparison is Web Crypto's constant-time HMAC verify.
11
+ * The handler checks it against Bonded's published keys (BONDED_AGENT_KEYS_URL) and refuses calls
12
+ * for any owner but its own. The scheme lives in @bondedhq/shared
13
+ * (`@bondedhq/shared/agent-url`, which the gateway signs with). Web Crypto only, so it runs in
14
+ * Node 22+, Bun, Deno, Cloudflare Workers and edge runtimes.
12
15
  */
13
- import { AGENT_URL_SIGNATURE_HEADER, AGENT_URL_TIMESTAMP_HEADER, AGENT_URL_TOLERANCE_SECONDS, signAgentUrlRequest, verifyAgentUrlRequest, } from "@bondedhq/shared/agent-url";
16
+ import { AGENT_URL_KEYS_PATH, AGENT_URL_OWNER_HEADER, AGENT_URL_SIGNATURE_HEADER, AGENT_URL_TIMESTAMP_HEADER, AGENT_URL_TOLERANCE_SECONDS, agentUrlSigner, newAgentUrlSigningKey, } from "@bondedhq/shared/agent-url";
14
17
  export const SIGNATURE_HEADER = AGENT_URL_SIGNATURE_HEADER;
15
18
  export const TIMESTAMP_HEADER = AGENT_URL_TIMESTAMP_HEADER;
19
+ export const OWNER_HEADER = AGENT_URL_OWNER_HEADER;
16
20
  /** How far a request's timestamp may be from the agent's clock, either way. */
17
21
  export const SIGNATURE_TOLERANCE_SECONDS = AGENT_URL_TOLERANCE_SECONDS;
18
22
  /**
19
- * Signs a body as Bonded does: the two headers to send with it. For testing your endpoint
20
- * locally; Bonded signs real calls itself.
23
+ * Where Bonded publishes the keys that sign calls to agents. It is on Bonded's own domain, not the
24
+ * API's host name, because this address is built into every deployed handler: the API can move
25
+ * without breaking them.
21
26
  */
22
- export async function signBondedRequest(secret, body, timestamp = Math.floor(Date.now() / 1000)) {
23
- const headers = await signAgentUrlRequest(secret, body, timestamp * 1000);
24
- return {
25
- "bonded-timestamp": headers[TIMESTAMP_HEADER],
26
- "bonded-signature": headers[SIGNATURE_HEADER],
27
- };
28
- }
27
+ export const BONDED_AGENT_KEYS_URL = `https://www.bondedhq.xyz${AGENT_URL_KEYS_PATH}`;
29
28
  /**
30
- * Checks a request's signature only: the timestamp is within `toleranceSeconds` of now and one of
31
- * the header's `v1=` signatures is the body's HMAC under `secret` (or one of several secrets).
32
- * It doesn't look at the body or check for replays; `createBondedHandler` does both.
29
+ * Bonded's production keys, built in: used only when BONDED_AGENT_KEYS_URL can't be reached and
30
+ * no key set was fetched before, so a handler still starts then. The same set the keys address
31
+ * publishes (docs/AGENT-URL-SIGNED-CALLS.md, Section 6): on a key rotation, add the new public key
32
+ * here before a release.
33
33
  */
34
- export async function verifyBondedSignature(input) {
35
- const result = await verifyAgentUrlRequest({
36
- secret: input.secret,
37
- body: input.body,
38
- headers: {
39
- [TIMESTAMP_HEADER]: input.timestamp ?? undefined,
40
- [SIGNATURE_HEADER]: input.signature ?? undefined,
34
+ export const BONDED_AGENT_KEYS = [
35
+ // The first production key, made 2026-11-14.
36
+ { kid: "ind7eq0xdV0-", alg: "Ed25519", x: "tWhxWPMrq6BKcQs838cUlF_5SecIG1NBy8L_IRIkSBg" },
37
+ ];
38
+ /** Where another Bonded deployment (a local Arena, a staging API) publishes its keys. */
39
+ export const keysUrlOf = (apiUrl) => `${apiUrl.replace(/\/+$/, "")}${AGENT_URL_KEYS_PATH}`;
40
+ /**
41
+ * A stand-in for Bonded, for testing your endpoint locally: a fresh key, `sign` to sign a body
42
+ * for an owner the way Bonded does, and `keys` to give the handler (`createBondedHandler({ keys })`).
43
+ * Bonded signs real calls itself, with keys your handler fetches.
44
+ */
45
+ export async function createTestSigner() {
46
+ const signer = await agentUrlSigner(await newAgentUrlSigningKey());
47
+ return {
48
+ keys: signer.keySet().keys,
49
+ async sign(owner, body, timestamp = Math.floor(Date.now() / 1000)) {
50
+ const headers = await signer.sign(owner, body, timestamp * 1000);
51
+ return {
52
+ "bonded-timestamp": headers[TIMESTAMP_HEADER],
53
+ "bonded-owner": headers[OWNER_HEADER],
54
+ "bonded-signature": headers[SIGNATURE_HEADER],
55
+ };
41
56
  },
42
- ...(input.now !== undefined ? { nowMs: input.now * 1000 } : {}),
43
- ...(input.toleranceSeconds !== undefined ? { toleranceSeconds: input.toleranceSeconds } : {}),
44
- });
45
- if (result.ok)
46
- return { ok: true, timestamp: result.timestamp };
47
- // 400 means the signature matched but the body isn't one of Bonded's messages.
48
- if (result.status === 400)
49
- return { ok: true, timestamp: Number(input.timestamp) };
50
- return { ok: false, reason: result.reason };
57
+ };
51
58
  }
52
59
  //# sourceMappingURL=signature.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bondedhq/sdk",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "description": "Trade through a Bonded Mandate Vault and get rated by the Bonded Arena from any TypeScript agent: a typed vault client with dry runs, Sentinel checks and a mandate builder; the Arena episode's tools for LangChain, the Vercel AI SDK, ElizaOS and Coinbase AgentKit; a payment guard for pay tools (LangChain, the AI SDK, MCP, the OpenAI Agents SDK) and x402 clients; and an Agent URL handler.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -94,7 +94,7 @@
94
94
  "@modelcontextprotocol/sdk": "1.30.1",
95
95
  "viem": "^2.56.9",
96
96
  "zod": "^4.6.5",
97
- "@bondedhq/shared": "^0.1.5"
97
+ "@bondedhq/shared": "^0.2.0"
98
98
  },
99
99
  "devDependencies": {
100
100
  "@coinbase/agentkit": "0.10.4",