@ziggs-ai/api-client 0.37.16 → 0.37.17

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/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.37.17
6
+
7
+ - Clarify the shared hiring instructions: a mandate governs the work; agent-initiated hires need a covering grant or approval on the existing agreement.
8
+
9
+ - Resume an existing hire into its room with `chat_open({chatId, agreementId})`, using the same backend admission operation as the room UI. Retries do not hire or charge again.
10
+ - Normalize current `ref` and cached typed resource identifiers in one client path for `open` and `access_explain`.
11
+ - Share structured refusal formatting across MCP and agent runtimes, preserving recovery calls, eligible approvers, choices, and wrapped API errors. Pending invitations identify their approvers and approval room.
12
+ - Preserve every server error code and empty approver list; stop guessing resource-access requests from the word "scope" in a 403 message. Human-only refusals direct the caller to the account owner.
13
+
5
14
  ## 0.37.16
6
15
 
7
16
  - `messages` can ask for the newest page. A forward delta still needs a timestamp.
@@ -85,22 +85,10 @@ function termsFrom(args) {
85
85
  function engagementKindFrom(args) {
86
86
  return args['engagementKind'] ?? 'service';
87
87
  }
88
- /**
89
- * The job an agent declares it is acting inside.
90
- *
91
- * The consent gate excuses a formation that sits inside a job a human already
92
- * approved, and it verifies the claim against the acting agent. What did not
93
- * exist was a way to MAKE the claim: `parentAgreementId` rode only on internal
94
- * service calls, so every agreement an agent formed arrived at the gate as an
95
- * outsider and waited on a human — including the ordinary case of buying the
96
- * help the job it was hired for needs.
97
- *
98
- * Declared by the caller, never guessed by the server. The SDK fills it in from
99
- * the task it is executing, so an agent doing assigned work pays nothing for it.
100
- */
88
+ /** The declared job governs the work; hiring consent uses grants or agreement approval. */
101
89
  const MANDATE_PARAM = {
102
90
  type: 'string',
103
- description: "The active agreement whose work this is part of — the job you are doing. It becomes this engagement's parent, so it ends when the job ends, and it is what lets you commit without waiting on your human: work inside a job they already approved needs no second approval. Name only an agreement you are actually a party to; the server checks, and a wrong name simply earns nothing. Leave it out for work that belongs to no job. It never names who you act for — the server reads that off the room or contract that woke you.",
91
+ description: "The active agreement whose work this is part of — the job you are doing. It becomes this engagement's parent, so it ends when the job ends, but does not itself authorize a hire. Agent-initiated hires require a covering approval-authority grant or approval on the agreement. Name only an agreement you are actually a party to; the server checks, and a wrong name simply earns nothing. Leave it out for work that belongs to no job. It never names who you act for — the server reads that off the room or contract that woke you.",
104
92
  };
105
93
  /** Pull the declared mandate off a validated arg bag, as the wire field. */
106
94
  function mandateFrom(args) {
@@ -1,3 +1,4 @@
1
+ import { presentResourceCall } from './resourceActions.js';
1
2
  import { agreementAppUrl } from '../utils/appUrls.js';
2
3
  import { claimOpenAgreement } from '../http/agreementFlows.js';
3
4
  import { linkIsReachOnly, webAppOrigin } from './links.js';
@@ -20,7 +21,11 @@ export function presentClaimResult(agreement, kind, env, room) {
20
21
  const out = presentClaim(agreement, kind, env);
21
22
  if (!room)
22
23
  return out;
23
- return { ...out, message: `${out.message} ${roomWords(room)}`, room };
24
+ const recovery = room.recovery;
25
+ const presented = recovery?.tool
26
+ ? { ...room, recovery: presentResourceCall({ ...recovery, tool: recovery.tool, args: recovery.args ?? {} }, env.surface) }
27
+ : room;
28
+ return { ...out, message: `${out.message} ${roomWords(presented)}`, room: presented };
24
29
  }
25
30
  /**
26
31
  * The room the hire was made in, in words. "Ready" is said only
@@ -32,10 +37,15 @@ function roomWords(room) {
32
37
  return `The agent is in room ${room.chatId} now.`;
33
38
  }
34
39
  if (room.status === 'awaiting_approval') {
35
- return `Once it is approved, the agent joins room ${room.chatId} with no further step; the room shows the proposal to the person who can answer it.`;
40
+ return room.chatId
41
+ ? `Once approved, the agent joins room ${room.chatId}; the room shows who can answer.`
42
+ : 'The hire is waiting for approval; its conversation opens after approval.';
36
43
  }
37
- const retry = room.recovery ? ` Resume with ${room.recovery.method} ${room.recovery.path}.` : '';
38
- return `The hire is active, but the agent is not in room ${room.chatId} yet${room.message ? ` (${room.message})` : ''}. Do not claim again.${retry}`;
44
+ const recovery = room.recovery;
45
+ const retry = recovery?.tool
46
+ ? ` Resume with ${recovery.tool} using ${JSON.stringify(recovery.args)}.`
47
+ : recovery ? ` Resume with ${recovery.method} ${recovery.path}.` : '';
48
+ return `${room.message ?? 'The hire conversation is not ready.'} Do not claim again.${retry}`;
39
49
  }
40
50
  function presentClaim(agreement, kind, env) {
41
51
  const workContext = workContextFromEnv(env);
@@ -83,7 +93,7 @@ export const agreementClaimCapability = {
83
93
  names: { sdk: 'agreement_claim', mcp: 'ziggs_agreement_claim' },
84
94
  title: 'Claim a posted agreement',
85
95
  descriptions: {
86
- sdk: 'Claim an open broadcast agreement by id — the DEFAULT way to engage: terms are already posted, consent is the claim and there are no negotiation turns. Claims a request (you do the work, the publisher pays), a standing offer (you buy, the publisher works), a hand-off (providerPinned: the publisher\'s hired agent works FOR you — you become the customer, and the payer when it is priced), or a link invite (bilateral trust forms immediately). You become the open party. It activates at once when you can consent for your own side — an agent claiming help for a job it is already doing should name that job with mandateAgreementId; without it the formation waits on your human before anything can be spawned under it. A hand-off is claimable only after its provider has accepted (409 until then). Find requests/offers with marketplace_view; listings are take-it-or-leave-it — never counter one. Direct proposals are approved with agreement_respond instead, not claimed. To hire an agent into a room, claim its listing with that room\'s chatId.',
96
+ sdk: 'Claim an open broadcast agreement by id — the DEFAULT way to engage: terms are already posted, consent is the claim and there are no negotiation turns. Claims a request (you do the work, the publisher pays), a standing offer (you buy, the publisher works), a hand-off (providerPinned: the publisher\'s hired agent works FOR you — you become the customer, and the payer when it is priced), or a link invite (bilateral trust forms immediately). You become the open party. It activates at once when you can consent for your own side — an agent-initiated hire needs a covering approval-authority grant or the existing agreement waits for the responsible principal to approve. Name the job with mandateAgreementId to keep its work governed by that job; the job alone does not authorize a hire. A hand-off is claimable only after its provider has accepted (409 until then). Find requests/offers with marketplace_view; listings are take-it-or-leave-it — never counter one. Direct proposals are approved with agreement_respond instead, not claimed. To hire an agent into a room, claim its listing with that room\'s chatId.',
87
97
  },
88
98
  annotation: 'destructive',
89
99
  params: {
@@ -92,15 +102,11 @@ export const agreementClaimCapability = {
92
102
  required: true,
93
103
  description: 'The open agreement to claim (request / offer / link invite id)',
94
104
  },
95
- // The job this claim is part of. A claim is the DEFAULT way to engage, so
96
- // without this the ordinary case — an agent claiming the help the job it is
97
- // doing needs — waited on a human every time. It authorizes; it does not
98
- // re-parent: the row belongs to whoever posted it, and a claimer cannot move
99
- // somebody else's agreement into its own tree.
105
+ // Declare the governing work separately from consent to hire.
100
106
  parentAgreementId: { type: 'string', description: 'Place the new service-listing claim under this governing relationship or work agreement. Requires separate consent to the posted terms.' },
101
107
  mandateAgreementId: {
102
108
  type: 'string',
103
- description: 'The active agreement whose work this claim is part of — the job you are doing. Claiming for a job your human already approved needs no second approval from them, but you have to name the job: the server checks you are a party to it, and a wrong name simply earns nothing. Leave it out when the claim belongs to no job.',
109
+ description: 'The active agreement whose work this claim is part of — the job you are doing. The server checks you are a party to the job. A hire still needs a covering approval-authority grant or approval on the agreement; naming the job does not bypass consent. Leave it out when the claim belongs to no job.',
104
110
  },
105
111
  chatId: {
106
112
  type: 'string',
@@ -61,9 +61,9 @@ export function presentSendResult(result, env) {
61
61
  export const openConversationCapability = {
62
62
  key: 'chat_open',
63
63
  names: { sdk: 'chat_open', mcp: 'ziggs_chat_open' },
64
- title: 'Start or reuse a conversation',
64
+ title: 'Open, invite to, or resume a conversation',
65
65
  descriptions: {
66
- sdk: 'Open a conversation or explicitly invite people to a room. participantId opens/reuses your person’s conversation with one person or agent, the same room each of their agents finds; newChat opens a separate subject, which can take a name. participantIds creates a separate group with the acting caller and those people; include a stable idempotencyKey for retries. chatId plus participantIds invites people into that exact room, requiring room access and admission authority. Every person needs a valid contact basis: shared organization membership, a link, or an applicable agreement. Each participant keeps their own agent and work relationships; opening a group selects no service agreement. Group creation never expands an existing private DM. Invited teammates read from admission onward. participantIds may name agents, when creating a group or inviting into one: each must be reachable (your own, one of your company, published, or already engaged). An agent you add joins at once if your person gave you approval authority; otherwise it is a proposal in the room, and the first person in the room to say yes admits it. The result lists each id as added, already-here or pending. To bring in an agent you would hire, claim its listing with the room\'s chatId instead. Your own missing seat is asked for with access_request. Reach another person through their user id, not an unpublished agent of theirs. If you cannot reach someone your person knows, ask your person to introduce you in a room. Opening does not send a message; chat_send must name its intended receiverId to guarantee a target. To list accessible rooms, use grant_list with scopeKind ["chat"].',
66
+ sdk: 'Open a conversation or explicitly invite people to a room. To resume admission of an existing hire, pass only chatId and agreementId; this uses the same recovery as the room UI and never hires or charges again. participantId opens/reuses your person’s conversation with one person or agent, the same room each of their agents finds; newChat opens a separate subject, which can take a name. participantIds creates a separate group with the acting caller and those people; include a stable idempotencyKey for retries. chatId plus participantIds invites people into that exact room, requiring room access and admission authority. Every person needs a valid contact basis: shared organization membership, a link, or an applicable agreement. Each participant keeps their own agent and work relationships; opening a group selects no service agreement. Group creation never expands an existing private DM. Invited teammates read from admission onward. participantIds may name agents, when creating a group or inviting into one: each must be reachable (your own, one of your company, published, or already engaged). An agent you add joins at once if your person gave you approval authority; otherwise it is a proposal in the room, and the first person in the room to say yes admits it. The result lists each id as added, already-here or pending. To bring in an agent you would hire, claim its listing with the room\'s chatId instead. Your own missing seat is asked for with access_request. Reach another person through their user id, not an unpublished agent of theirs. If you cannot reach someone your person knows, ask your person to introduce you in a room. Opening does not send a message; chat_send must name its intended receiverId to guarantee a target. To list accessible rooms, use grant_list with scopeKind ["chat"].',
67
67
  },
68
68
  annotation: 'write',
69
69
  params: {
@@ -72,7 +72,7 @@ export const openConversationCapability = {
72
72
  description: 'Known user or agent id from search, a directory, a chat, or an agreement. For a teammate or a linked person, their user id, not an agent of theirs. No existing chat is required; do not guess ids.',
73
73
  },
74
74
  participantIds: { type: 'array', items: { type: 'string' }, description: '1–20 known ids. With or without chatId, people and agents; an agent without your approval authority waits for one person in the room to say yes. Use instead of participantId. Each invitation is authorized separately.' },
75
- chatId: { type: 'string', description: 'Existing room to invite participantIds into. Never creates another room. Omit creation options and agreementId.' },
75
+ chatId: { type: 'string', description: 'Existing room. With participantIds: invite into it. With agreementId only: resume the approved hire admission without another hire or charge.' },
76
76
  idempotencyKey: { type: 'string', description: 'Required for group creation: a unique key for this conversation, reused unchanged on retries. A different participant set needs a new key.' },
77
77
  name: { type: 'string', description: 'Name for a new group or a newChat room, 1–80 characters. Pair rooms and team channels take no name. A retry of the same idempotencyKey keeps the first name. Rename later is PATCH /chats/:chatId by someone who can admit people.' },
78
78
  createIfMissing: { type: 'boolean', description: 'Set false to reuse only an existing room with this participant. A missing room is refused without creating a replacement.' },
@@ -86,12 +86,22 @@ export const openConversationCapability = {
86
86
  },
87
87
  agreementId: {
88
88
  type: 'string',
89
- description: 'The hire this open is the working room for. On a store-claim kickoff, pass the ' +
89
+ description: 'With chatId: resume that existing hire’s room admission. Otherwise, the hire this open is the working room for. On a store-claim kickoff, pass the ' +
90
90
  'claimed agreement id so the payer is stamped from-start in that room.',
91
91
  },
92
92
  },
93
93
  needsAgentId: true,
94
94
  handler: async (args, env) => {
95
+ if (args.chatId && args.agreementId) {
96
+ const result = await openConversation(args, fullCreds(env));
97
+ return {
98
+ ...result,
99
+ appUrl: chatAppUrl(webAppOrigin(env), result.chatId),
100
+ message: result.status === 'ready'
101
+ ? 'The hired agent is admitted under this agreement. No new hire or charge was made.'
102
+ : `Admission is still incomplete${result.reason ? `: ${result.reason}` : ''}. Retry this same call; do not claim again.`,
103
+ };
104
+ }
95
105
  if (!args.participantId && !Array.isArray(args.participantIds)) {
96
106
  throw new Error('participantId is required, or use participantIds to invite people');
97
107
  }
@@ -1,5 +1,4 @@
1
1
  import { isContextAccessRequest } from '../shared/contextAccessRequest.js';
2
- import { looksLikeHandle, parseRef } from '../handles.js';
3
2
  import { presentResourceActions } from './resourceActions.js';
4
3
  import { ContextReadClient, CONTEXT_READ_TYPES, CONTEXT_READ_VIA, parseVia, viaHint, } from '../http/ContextReadClient.js';
5
4
  import { ContextOpenClient } from '../http/ContextOpenClient.js';
@@ -259,29 +258,15 @@ export const contextDelegateCapability = {
259
258
  const OPEN_REF_PARAMS = {
260
259
  ref: {
261
260
  type: 'string',
262
- required: true,
263
261
  description: 'What to open: a handle such as T7K2QXD (T task, A agreement, C chat, F artifact) in any case, a Ziggs web URL holding one, or an id from a prior response.',
264
262
  },
263
+ // Cached connector schemas still send these. Both surfaces normalize them
264
+ // here; the server remains the only authority on the resource and access.
265
+ ...Object.fromEntries(['artifactId', 'chatId', 'taskId', 'agreementId'].map(key => [key, {
266
+ type: 'string',
267
+ description: 'Use instead of ref; exactly one reference.',
268
+ }])),
265
269
  };
266
- function openBodyFromArgs(args) {
267
- // A mistyped handle is refused here, before any call: its check letter
268
- // fails. Any other id goes to the server, which knows older shapes.
269
- const raw = typeof args.ref === 'string' ? args.ref.trim() : '';
270
- const invalid = (message) => Object.assign(new Error(message), { code: 'INVALID_INPUT' });
271
- if (!raw)
272
- throw invalid('ref is required: a handle such as T7K2QXD, a Ziggs URL, or an id from a prior response');
273
- const ref = parseRef(raw);
274
- if (!ref && looksLikeHandle(raw))
275
- throw invalid(`${raw} is not a handle: its check letter does not match. Check it for a typo`);
276
- const body = { ref: ref?.id ?? raw };
277
- if (typeof args['cursor'] === 'string')
278
- body.cursor = args['cursor'];
279
- if (typeof args['after'] === 'string')
280
- body.after = args['after'];
281
- if (typeof args['limit'] === 'number')
282
- body.limit = args['limit'];
283
- return body;
284
- }
285
270
  function presentOpenResult(result, env) {
286
271
  // Resource-routed OAuth calls let the backend select the assistant. Their
287
272
  // local credentials have no agent id, so only the response can name it.
@@ -364,7 +349,7 @@ export const openCapability = {
364
349
  handler: async (args, env) => {
365
350
  const creds = fullCreds(env);
366
351
  const client = new ContextOpenClient(creds.operatorKey, creds.agentId, env.baseUrl, creds.laneId);
367
- return presentOpenResult(await client.open(openBodyFromArgs(args)), env);
352
+ return presentOpenResult(await client.open(args), env);
368
353
  },
369
354
  };
370
355
  export const accessExplainCapability = {
@@ -380,7 +365,7 @@ export const accessExplainCapability = {
380
365
  handler: async (args, env) => {
381
366
  const creds = fullCreds(env);
382
367
  const client = new ContextOpenClient(creds.operatorKey, creds.agentId, env.baseUrl, creds.laneId);
383
- const result = await client.explain(openBodyFromArgs(args));
368
+ const result = await client.explain(args);
384
369
  return presentOpenResult(result, env);
385
370
  },
386
371
  };
@@ -418,7 +403,7 @@ export const accessRequestCapability = {
418
403
  title: 'Request access for yourself or named readers',
419
404
  annotation: 'destructive', needsAgentId: true,
420
405
  descriptions: {
421
- sdk: 'Request optional bounded access to one artifact, chat, agreement context or task. Creates only a pending approval; never grants access or approves. Name who to ask in recipientId: your work brief offers them for this wake — workBrief.actingFor.customer.id (the customer of the hire you are working in, for their own accounts and resources) or workBrief.actingFor.operator.id (your operator, for tools you bring). Anyone else is refused, and the refusal lists who you can ask. Artifact requests also need chatId, the conversation they approve in. To create work under an agreement you can read but not work on (a task create refused with WORK_ACCESS_REQUIRED), request permission=work on it; only someone who can work on it may approve, and it lasts until the agreement ends. Use scopeId for a known artifact; or scopeKind=artifact with agreementId of your active hire to request its system-maintained workspace summary (workspace name/type and agents, including future updates, not private conversations or document bodies). Send the returned appUrl, wait for human approval, then open the returned scope. To arrange reading of one artifact for others, name them in holders (agents by id, people or workspaces by id or the participant reference this room shows you); include yourself only if you need to read it too. The owner approves that exact list once and each reader gets its own grant; readers who can already read it come back as already_readable. To see which rooms a person is in (to find the room they mean), request scopeKind=person, scopeId=their person id, permission=list, from that person; once they approve, your chat list shows their rooms, each marked readable or not, and reading one still needs its own request. Hiring alone grants no source access. Revocation and expiry are checked by the system.',
406
+ sdk: 'Request optional bounded access to one artifact, chat, agreement context or task. Creates only a pending approval; never grants access or approves. Choose recipientId when several approvers are offered; with one, it is selected automatically: your work brief offers them for this wake — workBrief.actingFor.customer.id (the customer of the hire you are working in, for their own accounts and resources) or workBrief.actingFor.operator.id (your operator, for tools you bring). Anyone else is refused, and the refusal lists who you can ask. Artifact requests also need chatId, the conversation they approve in. To create work under an agreement you can read but not work on (a task create refused with WORK_ACCESS_REQUIRED), request permission=work on it; only someone who can work on it may approve, and it lasts until the agreement ends. Use scopeId for a known artifact; or scopeKind=artifact with agreementId of your active hire to request its system-maintained workspace summary (workspace name/type and agents, including future updates, not private conversations or document bodies). Send the returned appUrl, wait for human approval, then open the returned scope. To arrange reading of one artifact for others, name them in holders (agents by id, people or workspaces by id or the participant reference this room shows you); include yourself only if you need to read it too. The owner approves that exact list once and each reader gets its own grant; readers who can already read it come back as already_readable. To see which rooms a person is in (to find the room they mean), request scopeKind=person, scopeId=their person id, permission=list, from that person; once they approve, your chat list shows their rooms, each marked readable or not, and reading one still needs its own request. Hiring alone grants no source access. Revocation and expiry are checked by the system.',
422
407
  },
423
408
  params: {
424
409
  scopeKind: { type: 'string', required: true, enum: ['artifact', 'chat', 'agreement', 'task', 'person'] },
@@ -432,7 +417,7 @@ export const accessRequestCapability = {
432
417
  // Not required here: the server requires it, and a request without it
433
418
  // comes back refused with the people this agent may ask. Required in the
434
419
  // schema, a host refused the call before that list could arrive.
435
- recipientId: { type: 'string', description: 'Who to ask: workBrief.actingFor.customer.id (the customer of this hire) or workBrief.actingFor.operator.id (your operator). Without it, the refusal lists who you can ask.' },
420
+ recipientId: { type: 'string', description: 'Who to ask: workBrief.actingFor.customer.id (the customer of this hire) or workBrief.actingFor.operator.id (your operator). Omit when only one approver is available; otherwise the response lists choices.' },
436
421
  holders: {
437
422
  type: 'array',
438
423
  description: 'Artifact only, read only: up to 20 readers, each { kind: "agent" | "user" | "org", id }. Leave yourself out to arrange it for others. Omit to ask for yourself.',
@@ -448,7 +433,7 @@ export const accessRequestCapability = {
448
433
  idempotencyKey: { type: 'string', description: 'Reuse for the same logical request' },
449
434
  },
450
435
  handler: async (args, env) => {
451
- // recipientId is left to the server: it refuses a request without one
436
+ // recipientId is resolved by the server: it picks the sole eligible recipient
452
437
  // and lists who this agent may ask, which nothing on this side knows.
453
438
  for (const key of ['scopeKind', 'reason']) {
454
439
  if (typeof args[key] !== 'string' || !String(args[key]).trim())
@@ -1,3 +1,4 @@
1
- import type { ResourceAction } from '../http/ContextOpenClient.js';
1
+ import type { ResourceAction, ResourceActionCall } from '../http/ContextOpenClient.js';
2
+ export declare function presentResourceCall<T extends ResourceActionCall>(value: T, surface: 'sdk' | 'mcp'): T;
2
3
  /** Bind canonical tool names without changing the backend's decision or scope. */
3
4
  export declare function presentResourceActions(actions: ResourceAction[], surface: 'sdk' | 'mcp'): ResourceAction[];
@@ -1,9 +1,11 @@
1
+ export function presentResourceCall(value, surface) {
2
+ const name = value.tool.replace(/^ziggs_/, '');
3
+ return { ...value, tool: surface === 'mcp' ? `ziggs_${name}` : name };
4
+ }
1
5
  /** Bind canonical tool names without changing the backend's decision or scope. */
2
6
  export function presentResourceActions(actions, surface) {
3
- const toolName = (name) => surface === 'mcp' && !name.startsWith('ziggs_') ? `ziggs_${name}` : name;
4
- const call = (value) => ({ ...value, tool: toolName(value.tool) });
5
7
  return actions.map(action => ({ ...action,
6
- ...(action.tool ? { tool: toolName(action.tool) } : {}),
7
- ...(action.recovery ? { recovery: call(action.recovery) } : {}),
8
+ ...(action.tool ? { tool: presentResourceCall({ tool: action.tool, args: action.args ?? {} }, surface).tool } : {}),
9
+ ...(action.recovery ? { recovery: presentResourceCall(action.recovery, surface) } : {}),
8
10
  }));
9
11
  }
@@ -351,12 +351,14 @@ export interface ClaimOptions {
351
351
  * resumes it without hiring or charging again.
352
352
  */
353
353
  export interface ClaimRoom {
354
- chatId: string;
355
- status: 'awaiting_approval' | 'ready' | 'admission_failed';
354
+ chatId?: string;
355
+ status: 'awaiting_approval' | 'ready' | 'admission_failed' | 'preparation_failed' | 'unavailable';
356
356
  message?: string;
357
357
  recovery?: {
358
358
  method: string;
359
359
  path: string;
360
+ tool?: string;
361
+ args?: Record<string, unknown>;
360
362
  };
361
363
  }
362
364
  export declare function claimAgreement(agreementId: string, creds: Creds, opts?: ClaimOptions): Promise<{
@@ -21,6 +21,14 @@ export interface ChatOpenInvite {
21
21
  /** pending: an agent's joining waits for the people in the room to approve it. */
22
22
  status: 'added' | 'already-here' | 'pending';
23
23
  admissionId?: string;
24
+ approvers?: Array<{
25
+ id: string;
26
+ name: string | null;
27
+ }>;
28
+ approveIn?: {
29
+ kind: 'chat';
30
+ chatId: string;
31
+ };
24
32
  }
25
33
  export interface OpenConversationInput {
26
34
  participantId?: string;
@@ -33,16 +41,20 @@ export interface OpenConversationInput {
33
41
  agreementId?: string;
34
42
  createIfMissing?: boolean;
35
43
  }
36
- export declare function openConversation(participant: string | OpenConversationInput, creds: Creds, { newChat, agreementId, createIfMissing }?: {
37
- newChat?: boolean;
38
- agreementId?: string;
39
- createIfMissing?: boolean;
40
- }): Promise<{
44
+ export interface OpenConversationResult {
41
45
  chatId: string;
42
46
  reused?: boolean;
43
47
  invited?: ChatOpenInvite[];
44
48
  name?: string;
45
- }>;
49
+ agreementId?: string;
50
+ status?: 'ready' | 'admission_failed';
51
+ reason?: string;
52
+ }
53
+ export declare function openConversation(participant: string | OpenConversationInput, creds: Creds, { newChat, agreementId, createIfMissing }?: {
54
+ newChat?: boolean;
55
+ agreementId?: string;
56
+ createIfMissing?: boolean;
57
+ }): Promise<OpenConversationResult>;
46
58
  export interface SendChatMessageInput {
47
59
  /**
48
60
  * Chat to send in. Required unless `to` names a person you already
@@ -168,12 +180,7 @@ export declare class ChatClient {
168
180
  newChat?: boolean;
169
181
  agreementId?: string;
170
182
  createIfMissing?: boolean;
171
- }): Promise<{
172
- chatId: string;
173
- reused?: boolean;
174
- invited?: ChatOpenInvite[];
175
- name?: string;
176
- }>;
183
+ }): Promise<OpenConversationResult>;
177
184
  addMember(input: AddChatMemberInput): Promise<AddChatMemberResult>;
178
185
  sendMessage(input: SendChatMessageInput): Promise<SendChatMessageResult>;
179
186
  listMine(): Promise<ChatReadDto[]>;
@@ -15,16 +15,23 @@ export async function openConversation(participant, creds, { newChat = false, ag
15
15
  const input = typeof participant === 'string'
16
16
  ? { participantId: participant, ...(newChat ? { newChat } : {}), ...(agreementId ? { agreementId } : {}), ...(createIfMissing !== undefined ? { createIfMissing } : {}) }
17
17
  : participant;
18
- if (!input || (!input.participantId && !input.participantIds?.length)) {
18
+ const resumeHire = !!input?.chatId && !!input.agreementId;
19
+ if (resumeHire && (input.participantId || input.participantIds || input.newChat !== undefined || input.name !== undefined || input.idempotencyKey !== undefined || input.createIfMissing !== undefined)) {
20
+ throw new Error('To resume hire admission, pass only chatId and agreementId');
21
+ }
22
+ if (!input || (!resumeHire && !input.participantId && !input.participantIds?.length)) {
19
23
  throw new Error('participantId is required, or use participantIds to invite people');
20
24
  }
21
25
  assertCreds(creds, 'open conversation');
22
- const res = await fetch(`${getBackendUrl()}/chats`, {
26
+ const route = resumeHire
27
+ ? `/chats/${encodeURIComponent(input.chatId)}/hires/${encodeURIComponent(input.agreementId)}/admission`
28
+ : '/chats';
29
+ const res = await fetch(`${getBackendUrl()}${route}`, {
23
30
  method: 'POST',
24
31
  headers: buildHeaders(creds),
25
32
  // Only sent when asked for: an older backend ignores the field, so a
26
33
  // caller that never wants a separate room behaves identically either way.
27
- body: JSON.stringify(input),
34
+ body: JSON.stringify(resumeHire ? {} : input),
28
35
  });
29
36
  if (!res.ok) {
30
37
  const body = await res.text().catch(() => '');
@@ -38,6 +45,9 @@ export async function openConversation(participant, creds, { newChat = false, ag
38
45
  // send it. A backend from before this field cannot be assumed to have created
39
46
  // the room — defaulting to false would report a reused conversation as fresh,
40
47
  // which is worse than saying nothing.
48
+ if (resumeHire && (data.chatId !== input.chatId || data.agreementId !== input.agreementId || !['ready', 'admission_failed'].includes(String(data.status)))) {
49
+ throw new Error('Invalid hire-admission response: expected the selected chat, hire and admission status');
50
+ }
41
51
  const reused = data['reused'];
42
52
  const name = data['name'];
43
53
  const invited = Array.isArray(data['invited'])
@@ -45,6 +55,11 @@ export async function openConversation(participant, creds, { newChat = false, ag
45
55
  : undefined;
46
56
  return {
47
57
  chatId: data['chatId'],
58
+ ...(resumeHire ? {
59
+ agreementId: input.agreementId,
60
+ status: data.status,
61
+ ...(typeof data.reason === 'string' ? { reason: data.reason } : {}),
62
+ } : {}),
48
63
  ...(typeof reused === 'boolean' ? { reused } : {}),
49
64
  ...(typeof name === 'string' && name ? { name } : {}),
50
65
  ...(invited ? { invited } : {}),
@@ -21,6 +21,12 @@ export interface ResourceActionCall {
21
21
  tool: string;
22
22
  args: Record<string, unknown>;
23
23
  required?: string[];
24
+ unavailableReason?: string;
25
+ approvers?: Array<{
26
+ id: string;
27
+ name: string | null;
28
+ role: string;
29
+ }>;
24
30
  }
25
31
  export interface ResourceAction extends Partial<ResourceActionCall> {
26
32
  action: string;
@@ -90,3 +96,4 @@ export declare class ContextOpenClient {
90
96
  explain(body: ContextOpenBody): Promise<ContextExplainResult>;
91
97
  private post;
92
98
  }
99
+ export declare function normalizeContextOpenBody(args: Record<string, unknown>): ContextOpenBody;
@@ -1,3 +1,4 @@
1
+ import { looksLikeHandle, parseRef } from '../handles.js';
1
2
  import { getBackendUrl } from '../utils/urlUtils.js';
2
3
  import { pollSurfaceError } from '../shared/rateLimit.js';
3
4
  import { buildOperatorHeaders } from './operatorHeaders.js';
@@ -31,19 +32,14 @@ export class ContextOpenClient {
31
32
  return this.post('/context/access/explain', body);
32
33
  }
33
34
  async post(path, body) {
34
- const filled = ['ref', 'artifactId', 'chatId', 'taskId', 'agreementId'].filter((key) => typeof body[key] === 'string' && body[key].trim());
35
- if (filled.length !== 1) {
36
- // A caller's input mistake, named in the caller's words: the shared
37
- // INVALID_INPUT code, and no internal class name in the sentence.
38
- throw Object.assign(new Error('Pass ref (a handle such as T7K2QXD, a Ziggs URL, or an id), or exactly one of artifactId, chatId, taskId, agreementId.'), { code: 'INVALID_INPUT' });
39
- }
35
+ const normalized = normalizeContextOpenBody({ ...body });
40
36
  const url = `${this.baseUrl || getBackendUrl()}${path}`;
41
37
  const res = await fetch(url, {
42
38
  method: 'POST',
43
39
  headers: {
44
40
  ...buildOperatorHeaders(this.operatorKey, this.agentId, { 'Content-Type': 'application/json' }, this.laneId),
45
41
  },
46
- body: JSON.stringify(body),
42
+ body: JSON.stringify(normalized),
47
43
  });
48
44
  const text = await res.text().catch(() => '');
49
45
  if (!res.ok) {
@@ -52,3 +48,26 @@ export class ContextOpenClient {
52
48
  return JSON.parse(text);
53
49
  }
54
50
  }
51
+ export function normalizeContextOpenBody(args) {
52
+ // A mistyped handle is refused here, before any call: its check letter
53
+ // fails. Any other id goes to the server, which knows older shapes.
54
+ const invalid = (message) => Object.assign(new Error(message), { code: 'INVALID_INPUT' });
55
+ const keys = ['ref', 'artifactId', 'chatId', 'taskId', 'agreementId'].filter(key => args[key] !== undefined);
56
+ if (keys.length !== 1)
57
+ throw invalid('Pass exactly one resource reference: ref, artifactId, chatId, taskId, or agreementId');
58
+ const key = keys[0];
59
+ const raw = typeof args[key] === 'string' ? args[key].trim() : '';
60
+ if (!raw)
61
+ throw invalid(`${key} must be a non-empty resource reference`);
62
+ const ref = parseRef(raw);
63
+ if (!ref && looksLikeHandle(raw))
64
+ throw invalid(`${raw} is not a handle: its check letter does not match. Check it for a typo`);
65
+ const body = { [key]: ref?.id ?? raw };
66
+ if (typeof args['cursor'] === 'string')
67
+ body.cursor = args['cursor'];
68
+ if (typeof args['after'] === 'string')
69
+ body.after = args['after'];
70
+ if (typeof args['limit'] === 'number')
71
+ body.limit = args['limit'];
72
+ return body;
73
+ }
package/dist/index.d.ts CHANGED
@@ -33,3 +33,4 @@ export { isContextAccessRequest } from './shared/contextAccessRequest.js';
33
33
  export { AssistantConnectionClient, type AssistantWorkspace, } from './http/AssistantConnectionClient.js';
34
34
  export { withAssistantRequest, assistantRequestHeaders } from './http/operatorHeaders.js';
35
35
  export { HANDLE_ALPHABET, findHandles, looksLikeHandle, parseHandle, parseRef, type HandleKind } from './handles.js';
36
+ export { describeOperationError, type OperationError } from './shared/operationError.js';
package/dist/index.js CHANGED
@@ -31,3 +31,4 @@ export { AssistantConnectionClient, } from './http/AssistantConnectionClient.js'
31
31
  export { withAssistantRequest, assistantRequestHeaders } from './http/operatorHeaders.js';
32
32
  // Short handles: T…, A…, C…, F… (task, agreement, chat, artifact).
33
33
  export { HANDLE_ALPHABET, findHandles, looksLikeHandle, parseHandle, parseRef } from './handles.js';
34
+ export { describeOperationError } from './shared/operationError.js';
@@ -0,0 +1,39 @@
1
+ /**
2
+ * One refusal shape for MCP, SDK tools, and agent runtimes.
3
+ *
4
+ * classify from the thrown {@link ApiError} fields (`status`,
5
+ * `code`, `body` / `message`). No client-prefix strip, no HTTP-status regex,
6
+ * no re-parsing a JSON body that was concatenated into the message string.
7
+ */
8
+ export interface OperationError {
9
+ code: string;
10
+ message: string;
11
+ hint?: string;
12
+ /**
13
+ * What the caller may pick from, when the refusal is a choice: the
14
+ * agreements of WORK_SELECTION_REQUIRED, the workspaces of
15
+ * WORKSPACE_SELECTION_REQUIRED. Carried through verbatim so an assistant can
16
+ * ask its human instead of re-reading the message.
17
+ */
18
+ choices?: unknown[];
19
+ /**
20
+ * The call that is the next step, when the server sent one: the access
21
+ * request of WORK_ACCESS_REQUIRED, ready to send. Carried through verbatim,
22
+ * the same shape an open's action recovery has.
23
+ */
24
+ recovery?: Record<string, unknown>;
25
+ /** hidden | unreadable, the same outcome open and access_explain give. */
26
+ outcome?: string;
27
+ /** The resource the outcome is about. */
28
+ ref?: {
29
+ kind: string;
30
+ id: string;
31
+ };
32
+ /** For an unreadable resource: the request to make, as open lists it. */
33
+ actions?: unknown[];
34
+ }
35
+ /**
36
+ * Classify a thrown client error (or a plain string this layer raised) into a
37
+ * stable shape on either tool surface.
38
+ */
39
+ export declare function describeOperationError(input: unknown, surface?: 'sdk' | 'mcp'): OperationError;
@@ -0,0 +1,165 @@
1
+ import { isCoverageRefusal } from './apiError.js';
2
+ import { presentResourceCall } from '../capabilities/resourceActions.js';
3
+ /**
4
+ * The shared refusal vocabulary every read tool answers in (hidden,
5
+ * unreadable, invalid input), so an assistant can branch on the code instead
6
+ * of parsing each tool's sentence.
7
+ */
8
+ const SHARED_REFUSALS = new Set(['RESOURCE_HIDDEN', 'RESOURCE_UNREADABLE', 'INVALID_INPUT']);
9
+ const SCOPE_DENIED_HINT = 'For missing access to a returned artifact, chat, agreement, or task, use ' +
10
+ 'ziggs_access_request with that original resource id and your purpose. It creates ' +
11
+ 'a pending access approval agreement; it grants no access. After approval, ' +
12
+ 'ziggs_open the same resource using the same agent identity. For earlier chat ' +
13
+ 'messages request temporal=from-start; replying also requires permission=reply. ' +
14
+ 'Do not issue yourself a grant. A new room does not show this room\'s history.';
15
+ // a human-authority denial is not "try again with more scope": no
16
+ // retry by this caller can ever pass it, because the guard refuses on being an
17
+ // agent at all, before it looks at what was asked. Without a next action the
18
+ // only strategy left is repetition, which is what agents did.
19
+ const HUMAN_AUTHORITY_HINT = 'Ask the account owner to perform this action in their signed-in session. ' +
20
+ 'Requesting resource access cannot authorize a human-only operation; retrying as an agent will not change that.';
21
+ /**
22
+ * What covers a conversation, refused. These arrive as 429 or 403,
23
+ * and a 429 read as "rate limited" tells an agent to wait and retry, which can
24
+ * never help: the writer has to hire the agent, or their person has to set up
25
+ * the agreement. The backend's own code and sentence are the answer.
26
+ */
27
+ const COVERAGE_HINT = 'Retry only after the condition in this refusal changes. Use the returned recovery call when present. ' +
28
+ 'For missing resource permission, ziggs_access_request asks your customer or operator and returns who can approve; ' +
29
+ 'funding, ended agreements, and task-only terms require the responsible party to change those terms.';
30
+ function codeForStatus(status) {
31
+ if (status === 401)
32
+ return 'NOT_AUTHENTICATED';
33
+ if (status === 403)
34
+ return 'NOT_AUTHORIZED';
35
+ if (status === 404)
36
+ return 'NOT_FOUND';
37
+ if (status === 409)
38
+ return 'CONFLICT';
39
+ if (status === 429)
40
+ return 'RATE_LIMITED';
41
+ if (status >= 500)
42
+ return 'UPSTREAM_ERROR';
43
+ if (status >= 400)
44
+ return 'BAD_REQUEST';
45
+ return 'TOOL_ERROR';
46
+ }
47
+ /**
48
+ * An `ApiError` from api-client, recognised by shape rather than `instanceof`:
49
+ * a bundled or duplicated copy of the class would fail an identity check while
50
+ * carrying exactly the fields we need.
51
+ */
52
+ function thrownFields(err) {
53
+ if (typeof err === 'string') {
54
+ return { message: err, status: null, code: null, choices: null };
55
+ }
56
+ if (typeof err !== 'object' || err === null) {
57
+ return { message: String(err), status: null, code: null, choices: null };
58
+ }
59
+ const o = err;
60
+ const status = typeof o.status === 'number' && o.status >= 100 && o.status < 600
61
+ ? o.status
62
+ : null;
63
+ const code = typeof o.code === 'string' && o.code ? o.code : null;
64
+ const message = typeof o.message === 'string' && o.message ? o.message : String(err);
65
+ return { message, status, code, choices: choicesIn(o.body) };
66
+ }
67
+ /** The `recovery` call of a refusal body, when the server sent one. */
68
+ function recoveryIn(body) {
69
+ if (typeof body !== 'string' || !body)
70
+ return null;
71
+ try {
72
+ const parsed = JSON.parse(body);
73
+ const recovery = parsed?.recovery;
74
+ if (!recovery || typeof recovery !== 'object' || Array.isArray(recovery))
75
+ return null;
76
+ return recovery;
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ /** The `choices` list of a refusal body, when the server sent one. */
83
+ function choicesIn(body) {
84
+ if (typeof body !== 'string' || !body)
85
+ return null;
86
+ try {
87
+ const parsed = JSON.parse(body);
88
+ return Array.isArray(parsed?.choices)
89
+ ? parsed.choices
90
+ : null;
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
96
+ /**
97
+ * Classify a thrown client error (or a plain string this layer raised) into a
98
+ * stable shape on either tool surface.
99
+ */
100
+ export function describeOperationError(input, surface = 'sdk') {
101
+ input = unwrapApiError(input);
102
+ const shape = classify(input);
103
+ const { choices } = thrownFields(input);
104
+ const recovery = typeof input === 'object' && input !== null
105
+ ? recoveryIn(input.body)
106
+ : null;
107
+ const result = {
108
+ ...shape,
109
+ ...(choices ? { choices } : {}),
110
+ ...(recovery ? { recovery: typeof recovery.tool === 'string'
111
+ ? { ...presentResourceCall(recovery, surface) }
112
+ : recovery } : {}),
113
+ };
114
+ if (recovery)
115
+ delete result.hint;
116
+ if (surface === 'sdk' && result.hint)
117
+ result.hint = result.hint.replace(/\bziggs_(\w+)/g, '$1');
118
+ return result;
119
+ }
120
+ function classify(input) {
121
+ const { message, status, code: machineCode } = thrownFields(input);
122
+ if (machineCode && SHARED_REFUSALS.has(machineCode)) {
123
+ return { code: machineCode, message };
124
+ }
125
+ if (machineCode === 'AGENT_LACKS_HUMAN_AUTHORITY') {
126
+ return {
127
+ code: 'AGENT_LACKS_HUMAN_AUTHORITY',
128
+ message,
129
+ hint: HUMAN_AUTHORITY_HINT,
130
+ };
131
+ }
132
+ // Can read the agreement, cannot work on it: the server's sentence and its
133
+ // recovery are the answer, and the generic scope hint would send the agent
134
+ // back for the read it already has.
135
+ if (machineCode === 'WORK_ACCESS_REQUIRED') {
136
+ return { code: machineCode, message };
137
+ }
138
+ if (machineCode && isCoverageRefusal(machineCode)) {
139
+ return { code: machineCode, message, hint: COVERAGE_HINT };
140
+ }
141
+ if (machineCode === 'NOT_AUTHORIZED_FOR_SCOPE') {
142
+ return {
143
+ code: 'NOT_AUTHORIZED_FOR_SCOPE',
144
+ message,
145
+ hint: SCOPE_DENIED_HINT,
146
+ };
147
+ }
148
+ // The server owns the decision. A word such as "scope" may mean a token
149
+ // restriction, a resource grant, or a policy boundary; prose cannot tell us
150
+ // which request would authorize it. Preserve every supplied machine code.
151
+ return { code: machineCode ?? (status === null ? 'TOOL_ERROR' : codeForStatus(status)), message };
152
+ }
153
+ /** Tool runners wrap failures with context. Preserve the original API refusal. */
154
+ function unwrapApiError(input) {
155
+ const seen = new Set();
156
+ let current = input;
157
+ while (current && typeof current === 'object' && !seen.has(current)) {
158
+ seen.add(current);
159
+ const row = current;
160
+ if (typeof row.code === 'string' || typeof row.status === 'number' || typeof row.body === 'string')
161
+ return current;
162
+ current = row.cause;
163
+ }
164
+ return input;
165
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/api-client",
3
- "version": "0.37.16",
3
+ "version": "0.37.17",
4
4
  "description": "HTTP and WebSocket client for the Ziggs backend API",
5
5
  "homepage": "https://ziggsai.com/docs",
6
6
  "bugs": {