@ziggs-ai/api-client 0.14.3 → 0.15.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.
@@ -122,10 +122,27 @@ const COUNTERPARTY_PARAM = {
122
122
  required: true,
123
123
  description: 'Agent or user id of the counterparty.',
124
124
  };
125
+ /**
126
+ * Optional, because the server has always accepted a proposal without one and
127
+ * the client was the only thing refusing.
128
+ *
129
+ * The room is where a conversation about the terms happens; it is not how the
130
+ * counterparty is TOLD. A directed proposal is delivered to the party slots
131
+ * (the create-time agreement event targets parties, not a chat), so a chatless
132
+ * proposal lands in their inbox exactly like any other. What is lost is
133
+ * narrower than it sounds: the notice posted INTO a room, which is worth
134
+ * nothing when there is no room.
135
+ *
136
+ * Requiring it here meant a bookkeeping agreement between parties who have no
137
+ * conversation had to invent a chat id to exist. The lab's own self-hire did:
138
+ * it passed `lab-hire-<run>`, a chat that does not exist, which the server
139
+ * tolerates by falling back to the credential's tenant. A fiction the caller
140
+ * was forced to write down is worse than an absent field.
141
+ */
125
142
  const CHAT_PARAM = {
126
143
  type: 'string',
127
- required: true,
128
- description: 'The room this is proposed in — the counterparty reads it there.',
144
+ required: false,
145
+ description: 'The room this is proposed in, when there is one — the counterparty can read and discuss the terms there. Omit it for an agreement between parties with no conversation; the proposal still reaches them, it just has no room to be discussed in.',
129
146
  };
130
147
  /** Marketplace follow-up: a published listing is claimed, never countered. */
131
148
  function publishedNext(env) {
@@ -25,7 +25,7 @@ export function presentClaimResult(agreement, kind, env) {
25
25
  }
26
26
  if (agreement?.status !== 'active') {
27
27
  const held = (agreement?.approvals ?? []).find((a) => a.status === 'pending');
28
- const approveUrl = `${webAppOrigin(env)}/app/work/agreements/${agreement.agreementId}`;
28
+ const approveUrl = `${webAppOrigin(env)}/app/agreements/${agreement.agreementId}`;
29
29
  const remedy = held?.heldReason === 'contact-basis'
30
30
  ? 'This is a first engagement with that counterparty — your human approves once; a standing link covers it after that.'
31
31
  : 'Claiming for a job your human already approved activates at once — name the job with mandateAgreementId.';
@@ -1,4 +1,4 @@
1
- import { type CapabilityDefinition } from './types.js';
1
+ import { type CapabilityDefinition } from "./types.js";
2
2
  export declare const connectionProxyCapability: CapabilityDefinition;
3
3
  export declare const requestConnectionCapability: CapabilityDefinition;
4
4
  export declare const CONNECTION_CAPABILITIES: CapabilityDefinition[];
@@ -1,115 +1,138 @@
1
- import { ConnectionsClient } from '../http/ConnectionsClient.js';
2
- import { rethrowWithContext, } from './types.js';
1
+ import { ConnectionsClient } from "../http/ConnectionsClient.js";
2
+ import { rethrowWithContext, } from "./types.js";
3
3
  function client(env) {
4
- const { operatorKey, agentId } = env.creds;
4
+ // laneId, not just the key and the agent. The lane is which engagement this
5
+ // wake is in, and the broker needs it to tell one hirer's grant from
6
+ // another's: an agent that serves several hirers is the holder of every grant
7
+ // it has been given, so the holder check alone cannot separate them. Every
8
+ // other Creds-based client already carries it.
9
+ const { operatorKey, agentId, laneId } = env.creds;
5
10
  if (!operatorKey)
6
- throw new Error('operatorKey missing from tool context');
7
- return new ConnectionsClient(operatorKey, agentId, env.baseUrl);
11
+ throw new Error("operatorKey missing from tool context");
12
+ return new ConnectionsClient(operatorKey, agentId, env.baseUrl, laneId);
8
13
  }
9
14
  export const connectionProxyCapability = {
10
- key: 'connection_proxy',
11
- names: { sdk: 'connection_proxy', mcp: 'ziggs_connection_proxy' },
12
- title: 'Use a stored connection',
15
+ key: "connection_proxy",
16
+ names: { sdk: "connection_proxy", mcp: "ziggs_connection_proxy" },
17
+ title: "Use a stored connection",
13
18
  descriptions: {
14
19
  sdk: "Use a named-connector stored connection (e.g. the owner's GitHub/Jira) without ever seeing the credential. " +
15
- 'Calls the backend connections proxy with a grant the owner issued to this agent. ' +
20
+ "Calls the backend connections proxy with a grant the owner issued to this agent. " +
16
21
  'Not for remote MCP servers (provider "mcp") — those use mcp_tool_call / mcp_tools_list; proxy refuses them with "Unknown provider: mcp". ' +
17
22
  "Don't know connectionId/grantId yet? Use grant_list (scopeKind=connection) or connection_list_grants first.",
18
23
  mcp: "Use a named-connector stored connection (e.g. the owner's GitHub/Jira — NOT an agent-to-agent Link, see ziggs_link_list) without ever seeing the credential. " +
19
- 'Calls the backend connections proxy with a grant the owner issued to this agent. ' +
24
+ "Calls the backend connections proxy with a grant the owner issued to this agent. " +
20
25
  'Not for remote MCP servers (provider "mcp") — those use ziggs_mcp_tool_call / ziggs_mcp_tools_list; proxy refuses them with "Unknown provider: mcp". ' +
21
26
  "Don't know connectionId/grantId yet? Call ziggs_connection_list first.",
22
27
  },
23
- annotation: 'write',
28
+ annotation: "write",
24
29
  params: {
25
- connectionId: { type: 'string', required: true, description: 'Connection to act on' },
30
+ connectionId: {
31
+ type: "string",
32
+ required: true,
33
+ description: "Connection to act on",
34
+ },
26
35
  grantId: {
27
- type: 'string',
36
+ type: "string",
37
+ required: true,
38
+ description: "Grant the owner issued to this agent for the connection",
39
+ },
40
+ action: {
41
+ type: "string",
28
42
  required: true,
29
- description: 'Grant the owner issued to this agent for the connection',
43
+ description: "Provider action, e.g. repo:read",
44
+ },
45
+ payload: {
46
+ type: "object",
47
+ description: "Action-specific arguments (provider-defined)",
30
48
  },
31
- action: { type: 'string', required: true, description: 'Provider action, e.g. repo:read' },
32
- payload: { type: 'object', description: 'Action-specific arguments (provider-defined)' },
33
49
  },
34
50
  needsAgentId: true,
35
51
  handler: async (args, env) => {
36
- if (!args['connectionId'])
37
- throw new Error('connectionId is required');
38
- if (!args['grantId'])
39
- throw new Error('grantId is required');
40
- if (!args['action'])
41
- throw new Error('action is required');
52
+ if (!args["connectionId"])
53
+ throw new Error("connectionId is required");
54
+ if (!args["grantId"])
55
+ throw new Error("grantId is required");
56
+ if (!args["action"])
57
+ throw new Error("action is required");
42
58
  try {
43
59
  const result = await client(env).proxy({
44
- connectionId: args['connectionId'],
45
- grantId: args['grantId'],
46
- action: args['action'],
47
- payload: args['payload'],
60
+ connectionId: args["connectionId"],
61
+ grantId: args["grantId"],
62
+ action: args["action"],
63
+ payload: args["payload"],
48
64
  });
49
- return { ok: true, action: args['action'], result };
65
+ return { ok: true, action: args["action"], result };
50
66
  }
51
67
  catch (e) {
52
- rethrowWithContext(e, 'Connection proxy failed');
68
+ rethrowWithContext(e, "Connection proxy failed");
53
69
  }
54
70
  },
55
71
  };
56
72
  export const requestConnectionCapability = {
57
- key: 'connection_request',
58
- names: { sdk: 'connection_request', mcp: 'ziggs_connection_request' },
59
- title: 'Ask your principal for a connection',
73
+ key: "connection_request",
74
+ names: { sdk: "connection_request", mcp: "ziggs_connection_request" },
75
+ title: "Ask your principal for a connection",
60
76
  descriptions: {
61
- sdk: 'Ask your principal (the human) to connect a remote MCP server and grant you the listed tools. ' +
62
- 'Opens a connection-consent agreement as an approvable card in the chat you pass — the human approves it there like any other agreement. ' +
63
- 'On approval the server is connected (browser OAuth if needed) and you are granted the tools; call them with mcp_tool_call / mcp_tools_list (not connection_proxy).',
64
- mcp: 'Ask your principal (the human) to connect a remote MCP server and grant you the listed tools. ' +
65
- 'Opens a connection-consent agreement as an approvable card in the chat you pass — the human approves it there like any other agreement (there is no MCP tool to approve it, so tell them to approve it in the chat). ' +
66
- 'On approval the server is connected (browser OAuth if needed) and you are granted the tools; the result shows up in ziggs_connection_list for use with ziggs_mcp_tools_list / ziggs_mcp_tool_call.',
77
+ sdk: "Ask your principal (the human) to connect a remote MCP server and grant you the listed tools. " +
78
+ "Opens a connection-consent agreement as an approvable card in the chat you pass — the human approves it there like any other agreement. " +
79
+ "On approval the server is connected (browser OAuth if needed) and you are granted the tools; call them with mcp_tool_call / mcp_tools_list (not connection_proxy).",
80
+ mcp: "Ask your principal (the human) to connect a remote MCP server and grant you the listed tools. " +
81
+ "Opens a connection-consent agreement as an approvable card in the chat you pass — the human approves it there like any other agreement (there is no MCP tool to approve it, so tell them to approve it in the chat). " +
82
+ "On approval the server is connected (browser OAuth if needed) and you are granted the tools; the result shows up in ziggs_connection_list for use with ziggs_mcp_tools_list / ziggs_mcp_tool_call.",
67
83
  },
68
- annotation: 'write',
84
+ annotation: "write",
69
85
  params: {
70
86
  chatId: {
71
- type: 'string',
87
+ type: "string",
88
+ required: true,
89
+ description: "The chat you are working in — the consent card is opened there",
90
+ },
91
+ serverUrl: {
92
+ type: "string",
72
93
  required: true,
73
- description: 'The chat you are working in — the consent card is opened there',
94
+ description: "Remote MCP server URL (https)",
74
95
  },
75
- serverUrl: { type: 'string', required: true, description: 'Remote MCP server URL (https)' },
76
96
  tools: {
77
- type: 'array',
78
- items: { type: 'string' },
97
+ type: "array",
98
+ items: { type: "string" },
79
99
  required: true,
80
100
  description: "Tool names you want — become the grant's allowed_actions caveats",
81
101
  },
82
- reason: { type: 'string', description: 'Plain-language reason shown to the human deciding' },
102
+ reason: {
103
+ type: "string",
104
+ description: "Plain-language reason shown to the human deciding",
105
+ },
83
106
  },
84
107
  needsAgentId: true,
85
108
  handler: async (args, env) => {
86
- if (!args['chatId'])
87
- throw new Error('chatId is required');
88
- if (!args['serverUrl'])
89
- throw new Error('serverUrl is required');
90
- const tools = args['tools'];
109
+ if (!args["chatId"])
110
+ throw new Error("chatId is required");
111
+ if (!args["serverUrl"])
112
+ throw new Error("serverUrl is required");
113
+ const tools = args["tools"];
91
114
  if (!Array.isArray(tools) || tools.length === 0) {
92
- throw new Error('tools must be a non-empty array of tool names');
115
+ throw new Error("tools must be a non-empty array of tool names");
93
116
  }
94
117
  try {
95
118
  const result = await client(env).requestMcpConnection({
96
- chatId: args['chatId'],
97
- serverUrl: args['serverUrl'],
119
+ chatId: args["chatId"],
120
+ serverUrl: args["serverUrl"],
98
121
  tools: tools,
99
- reason: args['reason'],
122
+ reason: args["reason"],
100
123
  });
101
124
  return {
102
125
  ok: true,
103
126
  ...result,
104
- note: env.surface === 'mcp'
105
- ? 'A connection-consent card is now in the chat awaiting your principal. Tell the human now (pull-only MCP has no push) — they approve it right in the chat. ' +
106
- 'Once approved, the connection + grant appear in ziggs_connection_list for ziggs_mcp_tools_list / ziggs_mcp_tool_call.'
107
- : 'A connection-consent card is now in the chat awaiting your principal — they approve it right there. ' +
108
- 'Once approved, the connection + grant appear in grant_list (scopeKind=connection) / connection_list_grants for mcp_tools_list / mcp_tool_call.',
127
+ note: env.surface === "mcp"
128
+ ? "A connection-consent card is now in the chat awaiting your principal. Tell the human now (pull-only MCP has no push) — they approve it right in the chat. " +
129
+ "Once approved, the connection + grant appear in ziggs_connection_list for ziggs_mcp_tools_list / ziggs_mcp_tool_call."
130
+ : "A connection-consent card is now in the chat awaiting your principal — they approve it right there. " +
131
+ "Once approved, the connection + grant appear in grant_list (scopeKind=connection) / connection_list_grants for mcp_tools_list / mcp_tool_call.",
109
132
  };
110
133
  }
111
134
  catch (e) {
112
- rethrowWithContext(e, 'Connection request failed');
135
+ rethrowWithContext(e, "Connection request failed");
113
136
  }
114
137
  },
115
138
  };
@@ -159,7 +159,7 @@ export const proposeLinkCapability = {
159
159
  handler: async (args, env) => {
160
160
  const to = args['to']?.trim();
161
161
  const maxClaims = args['maxClaims'];
162
- const { shareUrl } = await createLink({
162
+ const { shareUrl, agreementId } = await createLink({
163
163
  ...(to ? { to } : {}),
164
164
  ...(args['message'] ? { description: args['message'] } : {}),
165
165
  ...(maxClaims == null || to ? {} : { maxClaims }),
@@ -168,9 +168,17 @@ export const proposeLinkCapability = {
168
168
  // back. That is the whole discipline: the caller knows whether it named
169
169
  // somebody, so branching on it discloses nothing, while branching on
170
170
  // anything the server learned about the target would.
171
+ //
172
+ // `agreementId` follows the same discipline: the route carries it for an
173
+ // agent id and an open invite and never for an email, so which of the two it
174
+ // is depends on what the caller passed and on nothing the server learned. It
175
+ // is what lets a caller reference the row and check on it; without it,
176
+ // several agents connecting in one run each minted a link toward the others
177
+ // in both directions, because none of them could ask.
171
178
  return {
172
179
  status: 'sent',
173
180
  shareUrl,
181
+ agreementId,
174
182
  message: to
175
183
  ? `Invitation sent to ${to}. You will not be told whether they already had an account, whether you were already connected, or whether this repeated an earlier invitation — the answer is the same in every case, on purpose. It becomes a live connection when they accept. ${linkIsReachOnly(env)}`
176
184
  : `Share link created, valid 7 days. Give your human shareUrl and nothing else: it is the whole invite. ${linkIsReachOnly(env)}`,
@@ -34,7 +34,17 @@ export interface ProposeTerms {
34
34
  /** 1:1 proposal to a specific user or agent (`proposedTo` = their id). */
35
35
  export interface ProposeDirectInput extends ProposeTerms {
36
36
  proposedTo: string;
37
- chatId: string;
37
+ /**
38
+ * The room the terms are discussed in, when there is one.
39
+ *
40
+ * Optional, matching the server, which has always accepted a proposal
41
+ * without one. Delivery does not depend on it: the create-time agreement
42
+ * event targets the party slots, so the counterparty is told either way; a
43
+ * room only gives them somewhere to talk about it. Required here was what
44
+ * forced a bookkeeping agreement between parties with no conversation to
45
+ * invent a chat id in order to exist.
46
+ */
47
+ chatId?: string;
38
48
  }
39
49
  /**
40
50
  * Open buyer-broadcast. `audience` selects who may see/claim it:
@@ -205,16 +215,29 @@ export interface CreateLinkBody {
205
215
  maxClaims?: number;
206
216
  }
207
217
  /**
208
- * The route's one answer, whatever was in `to`.
218
+ * The route's answer.
219
+ *
220
+ * `status` is constant — it went — and the two null-able fields are per-ARM,
221
+ * chosen by what the caller put in `to` and therefore known to it before the
222
+ * call. `shareUrl` is present exactly for an open invite.
223
+ *
224
+ * `agreementId` names the row for an agent id and for an open invite, and is
225
+ * null for an EMAIL. That split is what keeps the answer from becoming an
226
+ * existence oracle: the knock returns nothing for "no such person", "already
227
+ * linked", "already knocked" and "that is yourself" alike, so an id on some of
228
+ * those and not others would answer the one question the route refuses to
229
+ * answer. An agent id is different — the route resolves it through root
230
+ * authority and refuses a name that answers to nobody, so it already discloses
231
+ * that target's existence.
209
232
  *
210
- * Deliberately carries no agreement. Returning the row for an id target and a
211
- * bare "sent" for an address made one of the two an existence oracle over the
212
- * user table, so both say the same thing now: it went. `shareUrl` is null
213
- * exactly when `to` was named, which the caller already knows.
233
+ * Without it a caller could not reference what it had just created, check on it
234
+ * later, or tell a repeat from a first attempt.
214
235
  */
215
236
  export interface CreateLinkResult {
216
237
  ok: boolean;
217
238
  status: 'sent';
239
+ /** The link row, for a named agent id or an open invite. Null for an email. */
240
+ agreementId: string | null;
218
241
  shareUrl: string | null;
219
242
  }
220
243
  /**
@@ -444,9 +444,11 @@ export async function createLink(body, creds) {
444
444
  throw new Error('Invalid response: expected { ok, status: "sent", shareUrl } from POST /agreements/links');
445
445
  }
446
446
  const shareUrl = data['shareUrl'];
447
+ const agreementId = data['agreementId'];
447
448
  return {
448
449
  ok: data['ok'] === true,
449
450
  status: 'sent',
451
+ agreementId: typeof agreementId === 'string' ? agreementId : null,
450
452
  shareUrl: typeof shareUrl === 'string' ? shareUrl : null,
451
453
  };
452
454
  }
@@ -1,4 +1,4 @@
1
- import type { GrantView } from './grants.js';
1
+ import type { GrantView } from "./grants.js";
2
2
  export declare function assertNoLeakedConnectionSecret(serialized: string): void;
3
3
  /** Thrown by ConnectionsClient with the HTTP status and raw body attached. */
4
4
  export interface ConnectionsError extends Error {
@@ -56,19 +56,28 @@ export declare class ConnectionsClient {
56
56
  private readonly operatorKey;
57
57
  private readonly agentId?;
58
58
  private readonly baseUrl;
59
- constructor(operatorKey: string, agentId?: string, baseUrl?: string);
59
+ private readonly laneId?;
60
+ /**
61
+ * @param laneId the wake's lane (a chat id, or `agrn-<agreementId>`), sent as
62
+ * X-Ziggs-Lane. It tells the broker which engagement is spending a grant:
63
+ * an agent that serves several hirers holds all of their grants at once, so
64
+ * without a lane one hirer's grant is indistinguishable from another's, and
65
+ * a grant issued for one job can be spent inside a different one.
66
+ * ArtifactsClient sends the same header for the same reason.
67
+ */
68
+ constructor(operatorKey: string, agentId?: string, baseUrl?: string, laneId?: string);
60
69
  /**
61
70
  * Present a ConnectionGrant to the broker and perform a provider action.
62
71
  * Returns the provider result — never the raw OAuth token. Requires an
63
72
  * impersonated agent (the grant holder).
64
73
  */
65
- proxy({ connectionId, grantId, action, payload }: ConnectionProxyParams): Promise<unknown>;
74
+ proxy({ connectionId, grantId, action, payload, }: ConnectionProxyParams): Promise<unknown>;
66
75
  /**
67
76
  * List ConnectionGrants on a connection. When impersonating an agent the
68
77
  * server already scopes rows to that holder; the client-side filter is kept
69
78
  * as defense-in-depth (same behavior as the former ZiggsConnectClient).
70
79
  */
71
- listGrants({ connectionId }: {
80
+ listGrants({ connectionId, }: {
72
81
  connectionId: string;
73
82
  }): Promise<ConnectionGrant[]>;
74
83
  /**
@@ -1,7 +1,7 @@
1
- import { getBackendUrl } from '../utils/urlUtils.js';
2
- import { throwApiError } from '../shared/apiError.js';
3
- import { buildOperatorHeaders } from './operatorHeaders.js';
4
- import { GrantsClient } from './GrantsClient.js';
1
+ import { getBackendUrl } from "../utils/urlUtils.js";
2
+ import { throwApiError } from "../shared/apiError.js";
3
+ import { buildOperatorHeaders } from "./operatorHeaders.js";
4
+ import { GrantsClient } from "./GrantsClient.js";
5
5
  // defense-in-depth mirror of the backend leak-guard
6
6
  // (assertProxyResponseDoesNotLeakTokens). The backend strips the *specific*
7
7
  // vault token from the response; clients never see that token, so this layer
@@ -17,7 +17,7 @@ const LEAKED_SECRET_PATTERNS = [
17
17
  export function assertNoLeakedConnectionSecret(serialized) {
18
18
  for (const re of LEAKED_SECRET_PATTERNS) {
19
19
  if (re.test(serialized)) {
20
- throw new Error('connection proxy response withheld: it appears to contain a credential (token-leak guard)');
20
+ throw new Error("connection proxy response withheld: it appears to contain a credential (token-leak guard)");
21
21
  }
22
22
  }
23
23
  }
@@ -34,29 +34,39 @@ export class ConnectionsClient {
34
34
  operatorKey;
35
35
  agentId;
36
36
  baseUrl;
37
- constructor(operatorKey, agentId, baseUrl) {
37
+ laneId;
38
+ /**
39
+ * @param laneId the wake's lane (a chat id, or `agrn-<agreementId>`), sent as
40
+ * X-Ziggs-Lane. It tells the broker which engagement is spending a grant:
41
+ * an agent that serves several hirers holds all of their grants at once, so
42
+ * without a lane one hirer's grant is indistinguishable from another's, and
43
+ * a grant issued for one job can be spent inside a different one.
44
+ * ArtifactsClient sends the same header for the same reason.
45
+ */
46
+ constructor(operatorKey, agentId, baseUrl, laneId) {
38
47
  if (!operatorKey)
39
- throw new Error('ConnectionsClient: operatorKey is required');
48
+ throw new Error("ConnectionsClient: operatorKey is required");
40
49
  this.operatorKey = operatorKey;
41
50
  this.agentId = agentId;
42
51
  this.baseUrl = baseUrl || getBackendUrl();
52
+ this.laneId = laneId;
43
53
  }
44
54
  /**
45
55
  * Present a ConnectionGrant to the broker and perform a provider action.
46
56
  * Returns the provider result — never the raw OAuth token. Requires an
47
57
  * impersonated agent (the grant holder).
48
58
  */
49
- async proxy({ connectionId, grantId, action, payload }) {
59
+ async proxy({ connectionId, grantId, action, payload, }) {
50
60
  if (!connectionId)
51
- throw new Error('proxy: connectionId is required');
61
+ throw new Error("proxy: connectionId is required");
52
62
  if (!grantId)
53
- throw new Error('proxy: grantId is required');
63
+ throw new Error("proxy: grantId is required");
54
64
  if (!action)
55
- throw new Error('proxy: action is required');
65
+ throw new Error("proxy: action is required");
56
66
  if (!this.agentId) {
57
- throw new Error('proxy: agentId is required — connection broker calls must impersonate the grant holder agent');
67
+ throw new Error("proxy: agentId is required — connection broker calls must impersonate the grant holder agent");
58
68
  }
59
- const text = await this._requestRaw('POST', `/connections/${encodeURIComponent(connectionId)}/proxy`, { grantId, action, payload: payload ?? {} });
69
+ const text = await this._requestRaw("POST", `/connections/${encodeURIComponent(connectionId)}/proxy`, { grantId, action, payload: payload ?? {} });
60
70
  assertNoLeakedConnectionSecret(text);
61
71
  let parsed = text;
62
72
  try {
@@ -65,7 +75,7 @@ export class ConnectionsClient {
65
75
  catch {
66
76
  parsed = text;
67
77
  }
68
- const result = parsed?.['result'];
78
+ const result = parsed?.["result"];
69
79
  return result ?? parsed;
70
80
  }
71
81
  /**
@@ -73,15 +83,15 @@ export class ConnectionsClient {
73
83
  * server already scopes rows to that holder; the client-side filter is kept
74
84
  * as defense-in-depth (same behavior as the former ZiggsConnectClient).
75
85
  */
76
- async listGrants({ connectionId }) {
86
+ async listGrants({ connectionId, }) {
77
87
  if (!connectionId)
78
- throw new Error('listGrants: connectionId is required');
88
+ throw new Error("listGrants: connectionId is required");
79
89
  if (!this.agentId) {
80
- throw new Error('listGrants: agentId is required — grants are scoped to the impersonated agent');
90
+ throw new Error("listGrants: agentId is required — grants are scoped to the impersonated agent");
81
91
  }
82
- const res = (await this._request('GET', `/connections/${encodeURIComponent(connectionId)}/grants`, undefined));
83
- const grants = res['grants'] || [];
84
- return grants.filter((g) => g['holderId'] === this.agentId);
92
+ const res = (await this._request("GET", `/connections/${encodeURIComponent(connectionId)}/grants`, undefined));
93
+ const grants = res["grants"] || [];
94
+ return grants.filter((g) => g["holderId"] === this.agentId);
85
95
  }
86
96
  /**
87
97
  * cross-connection discovery over the unified GET /grants:
@@ -94,15 +104,15 @@ export class ConnectionsClient {
94
104
  const grantsClient = new GrantsClient(this.operatorKey, this.agentId, this.baseUrl);
95
105
  // All pages of the agent's live connection grants (not just the first page).
96
106
  const { items, unreadableRails } = await grantsClient.listAllGrantsWithRails({
97
- scopeKind: 'connection',
98
- health: 'active',
107
+ scopeKind: "connection",
108
+ health: "active",
99
109
  });
100
110
  // An unreadable rail is not an empty one. Returning [] here made
101
111
  // every caller say "no connection grant" when the truth was "this key may not
102
112
  // look" — the needs gate refused work the agent could do, and the MCP tool
103
113
  // told the owner to issue a grant that already existed. Throwing puts the
104
114
  // missing scope in the message, where the person who can fix it will read it.
105
- const railBlocked = unreadableRails.find((r) => r.rail === 'connection');
115
+ const railBlocked = unreadableRails.find((r) => r.rail === "connection");
106
116
  if (railBlocked && items.length === 0) {
107
117
  throw new Error(`Cannot read this agent's connection grants: the operator key is missing the ` +
108
118
  `"${railBlocked.requiredScope}" scope, so the grant list came back empty whether or not ` +
@@ -125,10 +135,10 @@ export class ConnectionsClient {
125
135
  /** Issue a connection grant to an agent holder (connection owner side). */
126
136
  async issueGrant({ connectionId, holderId, caveats, }) {
127
137
  if (!connectionId)
128
- throw new Error('issueGrant: connectionId is required');
138
+ throw new Error("issueGrant: connectionId is required");
129
139
  if (!holderId)
130
- throw new Error('issueGrant: holderId is required');
131
- return this._request('POST', `/connections/${encodeURIComponent(connectionId)}/grants`, {
140
+ throw new Error("issueGrant: holderId is required");
141
+ return this._request("POST", `/connections/${encodeURIComponent(connectionId)}/grants`, {
132
142
  holderId,
133
143
  caveats,
134
144
  });
@@ -141,20 +151,20 @@ export class ConnectionsClient {
141
151
  */
142
152
  async attenuateGrant({ connectionId, grantId, holderId, caveats, }) {
143
153
  if (!connectionId)
144
- throw new Error('attenuateGrant: connectionId is required');
154
+ throw new Error("attenuateGrant: connectionId is required");
145
155
  if (!grantId)
146
- throw new Error('attenuateGrant: grantId is required');
156
+ throw new Error("attenuateGrant: grantId is required");
147
157
  if (!holderId)
148
- throw new Error('attenuateGrant: holderId is required');
149
- return this._request('POST', `/connections/${encodeURIComponent(connectionId)}/grants/${encodeURIComponent(grantId)}/attenuate`, { holderId, caveats });
158
+ throw new Error("attenuateGrant: holderId is required");
159
+ return this._request("POST", `/connections/${encodeURIComponent(connectionId)}/grants/${encodeURIComponent(grantId)}/attenuate`, { holderId, caveats });
150
160
  }
151
161
  /** Revoke a single connection grant. */
152
162
  async revokeGrant({ connectionId, grantId, }) {
153
163
  if (!connectionId)
154
- throw new Error('revokeGrant: connectionId is required');
164
+ throw new Error("revokeGrant: connectionId is required");
155
165
  if (!grantId)
156
- throw new Error('revokeGrant: grantId is required');
157
- return this._request('DELETE', `/connections/${encodeURIComponent(connectionId)}/grants/${encodeURIComponent(grantId)}`, undefined);
166
+ throw new Error("revokeGrant: grantId is required");
167
+ return this._request("DELETE", `/connections/${encodeURIComponent(connectionId)}/grants/${encodeURIComponent(grantId)}`, undefined);
158
168
  }
159
169
  /**
160
170
  * agent-initiated MCP connection request: ask the principal to
@@ -164,21 +174,23 @@ export class ConnectionsClient {
164
174
  */
165
175
  async requestMcpConnection({ serverUrl, tools, reason, chatId, onBehalfOfUserId, }) {
166
176
  if (!serverUrl)
167
- throw new Error('requestMcpConnection: serverUrl is required');
168
- return (await this._request('POST', '/connections/mcp/requests', { serverUrl, tools, reason, chatId }, onBehalfOfUserId ? { 'X-On-Behalf-Of-User': onBehalfOfUserId } : undefined));
177
+ throw new Error("requestMcpConnection: serverUrl is required");
178
+ return (await this._request("POST", "/connections/mcp/requests", { serverUrl, tools, reason, chatId }, onBehalfOfUserId
179
+ ? { "X-On-Behalf-Of-User": onBehalfOfUserId }
180
+ : undefined));
169
181
  }
170
182
  async _requestRaw(method, path, body, extraHeaders) {
171
183
  const init = {
172
184
  method,
173
185
  headers: buildOperatorHeaders(this.operatorKey, this.agentId, {
174
- ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
186
+ ...(body !== undefined ? { "content-type": "application/json" } : {}),
175
187
  ...extraHeaders,
176
- }),
188
+ }, this.laneId),
177
189
  };
178
190
  if (body !== undefined)
179
191
  init.body = JSON.stringify(body);
180
192
  const response = await fetch(`${this.baseUrl}${path}`, init);
181
- const text = await response.text().catch(() => '');
193
+ const text = await response.text().catch(() => "");
182
194
  if (!response.ok) {
183
195
  // ApiError (status/body/code); ConnectionsError remains the
184
196
  // documented duck type for callers that branch on `.status`.
@@ -16,6 +16,13 @@ export interface PublishOfferPayload {
16
16
  /** Broadcast audience: 'everyone' (default, fully public) or 'org' (members of your active org only). */
17
17
  audience?: BroadcastAudience;
18
18
  metadata?: Record<string, unknown>;
19
+ /**
20
+ * Replay handle, sent as the `Idempotency-Key` header — the same way
21
+ * `proposeAgreement` sends it. Re-publishing under the same key resolves to
22
+ * the listing already on the board; a NEW key republishes and retires the
23
+ * previous one. It used to ride in the body on this one route.
24
+ */
25
+ idempotencyKey?: string;
19
26
  }
20
27
  export declare function publishOffer(payload: PublishOfferPayload, creds: Creds): Promise<Agreement>;
21
28
  export interface PullOffersOptions {
@@ -21,17 +21,26 @@ function assertCreds(creds, op) {
21
21
  }
22
22
  export async function publishOffer(payload, creds) {
23
23
  assertCreds(creds, 'marketplace offer publish');
24
+ const { idempotencyKey, ...bodyData } = payload ?? {};
25
+ const headers = buildHeaders(creds);
26
+ if (idempotencyKey)
27
+ headers['Idempotency-Key'] = idempotencyKey;
24
28
  const res = await fetch(`${getMarketplaceBaseUrl()}/offers/publish`, {
25
29
  method: 'POST',
26
- headers: buildHeaders(creds),
27
- body: JSON.stringify(payload || {}),
30
+ headers,
31
+ body: JSON.stringify(bodyData),
28
32
  });
29
33
  if (!res.ok) {
30
34
  const body = await res.text().catch(() => '');
31
35
  throwApiError(res, body, `Marketplace offer publish failed: ${res.status}`);
32
36
  }
33
37
  const data = await res.json().catch(() => null);
34
- return shapeAgreement((data?.['offer'] ?? data));
38
+ // One key name across every agreement-creating route. This used to read
39
+ // `offer`, which was this route's own name for the same thing.
40
+ if (!data?.['agreement']) {
41
+ throw new Error('Invalid response: expected { agreement } from POST /marketplace/offers/publish');
42
+ }
43
+ return shapeAgreement(data['agreement']);
35
44
  }
36
45
  export async function pullOffers(options, creds) {
37
46
  assertCreds(creds, 'marketplace offers pull');
@@ -93,21 +93,39 @@ export declare class PaymentsClient {
93
93
  userId?: string;
94
94
  agentId?: string;
95
95
  }): Promise<WalletRef | null>;
96
- transfer({ to, amount, idempotencyKey, description, paymentGrantId, }: {
96
+ transfer({ to, amount, idempotencyKey, description, paymentGrantId, agreementId, }: {
97
97
  to: string;
98
98
  amount: number;
99
99
  idempotencyKey?: string;
100
100
  description?: string;
101
101
  paymentGrantId?: string;
102
+ /** The engagement this spend belongs to, so its own grant is preferred. */
103
+ agreementId?: string;
102
104
  }): Promise<TransferResult>;
103
- hold({ amount, idempotencyKey, description, paymentGrantId, }: {
105
+ hold({ amount, idempotencyKey, description, paymentGrantId, agreementId, }: {
104
106
  amount: number;
105
107
  idempotencyKey?: string;
106
108
  description?: string;
107
109
  paymentGrantId?: string;
110
+ /** The engagement this spend belongs to, so its own grant is preferred. */
111
+ agreementId?: string;
108
112
  }): Promise<HoldResult>;
109
- /** Prefer a single active wallet grant the agent already holds. */
110
- private resolveAgentPaymentGrantId;
113
+ /**
114
+ * Choose the grant that actually covers this spend.
115
+ *
116
+ * This used to be `grants[0]` — the first active grant, matched against
117
+ * nothing. An agent holding both its standing grant and an
118
+ * agreement-conferred one presented whichever came back first, the backend
119
+ * correctly refused it against wallet or amount, and the agent was refused a
120
+ * spend it was genuinely authorized to make.
121
+ *
122
+ * The source wallet is read rather than assumed: a grant on some other
123
+ * wallet cannot authorize money leaving this one, and the agent may hold
124
+ * grants on a counterparty's wallet from an engagement. A failure to read it
125
+ * is not fatal — selection then skips the wallet test and the server still
126
+ * validates, which is the same position we were in before.
127
+ */
128
+ private selectPaymentGrant;
111
129
  release({ holdId, action, toWalletId, idempotencyKey, }: {
112
130
  holdId: string;
113
131
  action?: string;
@@ -1,6 +1,7 @@
1
1
  import { getBackendUrl } from '../utils/urlUtils.js';
2
2
  import { buildOperatorHeaders } from './operatorHeaders.js';
3
3
  import { GrantsClient } from './GrantsClient.js';
4
+ import { describeNoGrant, selectPaymentGrant, } from './paymentGrantSelection.js';
4
5
  import { throwApiError } from '../shared/apiError.js';
5
6
  function randomIdempotencyKey(prefix = 'op') {
6
7
  return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
@@ -45,18 +46,13 @@ export class PaymentsClient {
45
46
  const res = (await this._get(`/payments/wallets/resolve?${params}`));
46
47
  return res['wallet'] || null;
47
48
  }
48
- async transfer({ to, amount, idempotencyKey, description, paymentGrantId, }) {
49
+ async transfer({ to, amount, idempotencyKey, description, paymentGrantId, agreementId, }) {
49
50
  if (!to)
50
51
  throw new Error('transfer: `to` is required');
51
52
  if (!(Number.isInteger(amount) && amount > 0))
52
53
  throw new Error('transfer: `amount` must be a positive integer (cents)');
53
- // resolve a standing/active grant when the caller omitted one
54
- // (Claude/MCP ensureForUser mints it). Explicit id still wins.
55
- if (this.agentId && !paymentGrantId) {
56
- paymentGrantId = (await this.resolveAgentPaymentGrantId()) ?? undefined;
57
- }
58
- if (this.agentId && !paymentGrantId)
59
- throw new Error('transfer: paymentGrantId is required for agent-impersonated transfers. The wallet owner must have issued a payment grant to this agentId.');
54
+ // The recipient is resolved BEFORE choosing a grant: an `allowed_recipients`
55
+ // caveat cannot be checked against a name the client has not resolved yet.
60
56
  let toWalletId = to;
61
57
  if (!to.startsWith('wal_')) {
62
58
  const w = await this.resolve(to.startsWith('agent_') ? { agentId: to } : { userId: to });
@@ -64,6 +60,15 @@ export class PaymentsClient {
64
60
  throw new Error(`transfer: could not resolve wallet for "${to}"`);
65
61
  toWalletId = w.walletId;
66
62
  }
63
+ // Choose the grant that covers this spend when the caller omitted one.
64
+ // An explicit id still wins — a caller who names a grant means it.
65
+ if (this.agentId && !paymentGrantId) {
66
+ const picked = await this.selectPaymentGrant({ amount, toWalletId, agreementId });
67
+ if (!picked.grant) {
68
+ throw new Error(describeNoGrant('transfer', { amount, toWalletId, agreementId }, picked.rejected));
69
+ }
70
+ paymentGrantId = picked.grant.grantId;
71
+ }
67
72
  const result = (await this._post('/payments/transfer', {
68
73
  toWalletId,
69
74
  amount,
@@ -90,15 +95,18 @@ export class PaymentsClient {
90
95
  amount,
91
96
  };
92
97
  }
93
- async hold({ amount, idempotencyKey, description, paymentGrantId, }) {
98
+ async hold({ amount, idempotencyKey, description, paymentGrantId, agreementId, }) {
94
99
  if (!(Number.isInteger(amount) && amount > 0))
95
100
  throw new Error('hold: `amount` must be a positive integer (cents)');
96
- // agent holds reserve the owner's wallet; require a grant.
101
+ // A hold reserves the owner's money, so it needs a grant like a transfer.
102
+ // It has no counterparty, so no recipient is offered to the caveats.
97
103
  if (this.agentId && !paymentGrantId) {
98
- paymentGrantId = (await this.resolveAgentPaymentGrantId()) ?? undefined;
104
+ const picked = await this.selectPaymentGrant({ amount, agreementId });
105
+ if (!picked.grant) {
106
+ throw new Error(describeNoGrant('hold', { amount, agreementId }, picked.rejected));
107
+ }
108
+ paymentGrantId = picked.grant.grantId;
99
109
  }
100
- if (this.agentId && !paymentGrantId)
101
- throw new Error('hold: paymentGrantId is required for agent-impersonated holds. The wallet owner must have issued a payment grant to this agentId.');
102
110
  return (await this._post('/payments/hold', {
103
111
  amount,
104
112
  idempotencyKey: idempotencyKey || randomIdempotencyKey('hold'),
@@ -106,16 +114,37 @@ export class PaymentsClient {
106
114
  paymentGrantId,
107
115
  }));
108
116
  }
109
- /** Prefer a single active wallet grant the agent already holds. */
110
- async resolveAgentPaymentGrantId() {
117
+ /**
118
+ * Choose the grant that actually covers this spend.
119
+ *
120
+ * This used to be `grants[0]` — the first active grant, matched against
121
+ * nothing. An agent holding both its standing grant and an
122
+ * agreement-conferred one presented whichever came back first, the backend
123
+ * correctly refused it against wallet or amount, and the agent was refused a
124
+ * spend it was genuinely authorized to make.
125
+ *
126
+ * The source wallet is read rather than assumed: a grant on some other
127
+ * wallet cannot authorize money leaving this one, and the agent may hold
128
+ * grants on a counterparty's wallet from an engagement. A failure to read it
129
+ * is not fatal — selection then skips the wallet test and the server still
130
+ * validates, which is the same position we were in before.
131
+ */
132
+ async selectPaymentGrant(spend) {
133
+ let grants = [];
134
+ try {
135
+ grants = await this.listGrants();
136
+ }
137
+ catch {
138
+ return { grant: null, rejected: [] };
139
+ }
140
+ let fromWalletId = null;
111
141
  try {
112
- const grants = await this.listGrants();
113
- const id = grants[0]?.grantId;
114
- return typeof id === 'string' && id.length > 0 ? id : null;
142
+ fromWalletId = (await this.balance()).walletId;
115
143
  }
116
144
  catch {
117
- return null;
145
+ fromWalletId = null;
118
146
  }
147
+ return selectPaymentGrant(grants, { ...spend, fromWalletId });
119
148
  }
120
149
  async release({ holdId, action = 'complete', toWalletId, idempotencyKey, }) {
121
150
  return (await this._post(`/payments/release/${holdId}`, {
@@ -38,12 +38,18 @@ export async function proposeUnified(input, creds) {
38
38
  }, creds);
39
39
  return { agreement, shape: 'request' };
40
40
  }
41
- if (!chatId)
42
- throw new Error('chatId is required on a direct proposal');
41
+ // No room is a shape, not an error. The server has always accepted a direct
42
+ // proposal without one, and this throw was the only thing that made the answer
43
+ // depend on which client you used: an agreement between parties who have no
44
+ // conversation had to invent a chat id to be created. Delivery does not go
45
+ // through the room — the create-time event targets the party slots — so the
46
+ // counterparty is told either way.
43
47
  const agreement = await proposeDirectTo({
44
48
  ...terms,
45
49
  proposedTo,
46
- chatId,
50
+ // Omitted rather than sent empty: an absent field says "no room", and an
51
+ // empty string is one more fiction for the server to interpret.
52
+ ...(chatId ? { chatId } : {}),
47
53
  providerId: providerId?.trim() || proposedTo,
48
54
  engagementKind: engagementKind ?? 'service',
49
55
  }, creds);
@@ -0,0 +1,53 @@
1
+ import { type GrantView } from './grants.js';
2
+ /**
3
+ * Which grant to present for a spend, and — when none fits — why each candidate
4
+ * did not.
5
+ *
6
+ * The client used to take `grants[0]` from the active list. It matched nothing
7
+ * else: not the source wallet, not the amount, not the agreement the spend
8
+ * belongs to. An agent holding both its standing grant and an agreement-conferred
9
+ * one presented whichever came back first, the backend correctly refused it
10
+ * against wallet or amount, and the agent was refused a spend it was genuinely
11
+ * authorized to make.
12
+ *
13
+ * Selection is a pure function of the grants and the spend so it can be tested
14
+ * without a server, and so the refusal can name what it considered.
15
+ */
16
+ export interface SpendShape {
17
+ /** The wallet the money leaves. A grant on another wallet cannot authorize it. */
18
+ fromWalletId?: string | null;
19
+ /** Cents. Checked against `max_amount`. */
20
+ amount: number;
21
+ /** The wallet receiving it, when known. Checked against `allowed_recipients`. */
22
+ toWalletId?: string | null;
23
+ /** The engagement this spend belongs to, when the caller names one. */
24
+ agreementId?: string | null;
25
+ }
26
+ export interface RejectedGrant {
27
+ grantId: string;
28
+ reason: string;
29
+ }
30
+ export interface GrantSelection {
31
+ grant: GrantView | null;
32
+ /** Every grant that was looked at and did not fit, with the reason. */
33
+ rejected: RejectedGrant[];
34
+ }
35
+ /**
36
+ * Pick the grant to present, preferring the one that belongs to the engagement.
37
+ *
38
+ * Order among grants that all fit: the agreement's own grant first when the
39
+ * spend names one, then the standing grant. A standing grant is the buyer
40
+ * bootstrap and the right default; reaching for it while an agreement grant
41
+ * covers the same spend would bill the wrong budget.
42
+ */
43
+ export declare function selectPaymentGrant(grants: readonly GrantView[], spend: SpendShape): GrantSelection;
44
+ /**
45
+ * The sentence an agent gets when nothing fits.
46
+ *
47
+ * It names every grant considered and why each was not it, because "no payment
48
+ * grant" is a condition and an agent cannot act on a condition — it retries,
49
+ * and every retry is a full model call. Which ceiling was in the way is the
50
+ * thing that tells the agent whether to ask for a bigger grant, name the
51
+ * agreement, or stop.
52
+ */
53
+ export declare function describeNoGrant(verb: string, spend: SpendShape, rejected: readonly RejectedGrant[]): string;
@@ -0,0 +1,111 @@
1
+ import { grantCaveat } from './grants.js';
2
+ /**
3
+ * A caveat off a grant that may not carry the array at all.
4
+ *
5
+ * `grantCaveat` assumes `caveats` is populated, which the unified list read
6
+ * does. A caller handing us a partial view should get "no such caveat", not a
7
+ * crash — this runs on the spend path, where throwing would turn a missing
8
+ * field into a refused payment.
9
+ */
10
+ function caveatValue(grant, type) {
11
+ const caveats = grant.caveats;
12
+ if (!Array.isArray(caveats))
13
+ return undefined;
14
+ return grantCaveat(grant, type);
15
+ }
16
+ function numericCaveat(grant, type) {
17
+ const raw = caveatValue(grant, type);
18
+ return typeof raw === 'number' && Number.isFinite(raw) ? raw : null;
19
+ }
20
+ function recipientCaveat(grant) {
21
+ const raw = caveatValue(grant, 'allowed_recipients');
22
+ if (!Array.isArray(raw))
23
+ return null;
24
+ const ids = raw.filter((r) => typeof r === 'string');
25
+ return ids.length ? ids : null;
26
+ }
27
+ /**
28
+ * Why this grant cannot authorize this spend, or null if it can.
29
+ *
30
+ * `daily_budget` is deliberately not checked. It is a ceiling on spend already
31
+ * made, which only the server can see — guessing here would refuse a grant that
32
+ * would have worked. The server validates it inside the same transaction as the
33
+ * ledger write, which is the only place the answer is not already stale.
34
+ */
35
+ function whyNot(grant, spend) {
36
+ // A stated kind that is not `wallet` is disqualifying. An ABSENT one is not:
37
+ // `listGrants` asks the server for `scopeKind: 'wallet'`, so the rail has
38
+ // already answered, and treating a field the response did not populate as a
39
+ // wrong answer would refuse every grant. Same reading as an unread source
40
+ // wallet below — absence means unknown, never no.
41
+ if (grant.scope?.kind && grant.scope.kind !== 'wallet') {
42
+ return `is a ${grant.scope.kind} grant, not a wallet grant`;
43
+ }
44
+ if (spend.fromWalletId && grant.scope?.id && grant.scope.id !== spend.fromWalletId) {
45
+ return `is on wallet ${grant.scope.id}, and this spend leaves ${spend.fromWalletId}`;
46
+ }
47
+ const maxAmount = numericCaveat(grant, 'max_amount');
48
+ if (maxAmount !== null && spend.amount > maxAmount) {
49
+ return `caps a single spend at ${maxAmount}, and this one is ${spend.amount}`;
50
+ }
51
+ const allowed = recipientCaveat(grant);
52
+ if (allowed && spend.toWalletId && !allowed.includes(spend.toWalletId)) {
53
+ return `may only pay ${allowed.join(', ')}, and this one pays ${spend.toWalletId}`;
54
+ }
55
+ // An agreement-conferred grant is FOR that agreement's work. Spending it on
56
+ // something else would pass every caveat above and still be the wrong money:
57
+ // the ceiling was agreed for one engagement, and nothing on the wire would
58
+ // show it had been used for another.
59
+ if (grant.agreementId) {
60
+ if (!spend.agreementId) {
61
+ return `belongs to agreement ${grant.agreementId}, and this spend names no agreement`;
62
+ }
63
+ if (grant.agreementId !== spend.agreementId) {
64
+ return `belongs to agreement ${grant.agreementId}, not ${spend.agreementId}`;
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+ /**
70
+ * Pick the grant to present, preferring the one that belongs to the engagement.
71
+ *
72
+ * Order among grants that all fit: the agreement's own grant first when the
73
+ * spend names one, then the standing grant. A standing grant is the buyer
74
+ * bootstrap and the right default; reaching for it while an agreement grant
75
+ * covers the same spend would bill the wrong budget.
76
+ */
77
+ export function selectPaymentGrant(grants, spend) {
78
+ const rejected = [];
79
+ const fitting = [];
80
+ for (const grant of grants) {
81
+ const reason = whyNot(grant, spend);
82
+ if (reason)
83
+ rejected.push({ grantId: grant.grantId, reason });
84
+ else
85
+ fitting.push(grant);
86
+ }
87
+ if (fitting.length === 0)
88
+ return { grant: null, rejected };
89
+ const forAgreement = spend.agreementId
90
+ ? fitting.find((g) => g.agreementId === spend.agreementId)
91
+ : undefined;
92
+ return { grant: forAgreement ?? fitting[0], rejected };
93
+ }
94
+ /**
95
+ * The sentence an agent gets when nothing fits.
96
+ *
97
+ * It names every grant considered and why each was not it, because "no payment
98
+ * grant" is a condition and an agent cannot act on a condition — it retries,
99
+ * and every retry is a full model call. Which ceiling was in the way is the
100
+ * thing that tells the agent whether to ask for a bigger grant, name the
101
+ * agreement, or stop.
102
+ */
103
+ export function describeNoGrant(verb, spend, rejected) {
104
+ if (rejected.length === 0) {
105
+ return (`${verb}: you hold no active payment grant, so this spend cannot be ` +
106
+ `authorized. The wallet owner issues one; nothing you can do from here.`);
107
+ }
108
+ const lines = rejected.map((r) => ` - ${r.grantId} ${r.reason}`).join('\n');
109
+ return (`${verb}: none of your ${rejected.length} active payment grant(s) covers ` +
110
+ `this spend of ${spend.amount}.\n${lines}`);
111
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/api-client",
3
- "version": "0.14.3",
3
+ "version": "0.15.1",
4
4
  "description": "HTTP and WebSocket client for the Ziggs backend API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",