@ziggs-ai/ziggs-mcp 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -65,7 +65,7 @@ export function registerCapability(server, cap, creds, opts = {}) {
65
65
  return textResult(opts.transformResult ? opts.transformResult(result, args) : result);
66
66
  }
67
67
  catch (e) {
68
- return toolError(e.message);
68
+ return toolError(e);
69
69
  }
70
70
  });
71
71
  }
@@ -1,4 +1,5 @@
1
1
  import type { GrantView, ContextReadType, InboxAckResult, InboxEnvelope, Task } from '@ziggs-ai/api-client';
2
+ import { type DecisionSelfIds } from './pendingDecisions.js';
2
3
  /**
3
4
  * ZIG-634 (Step 1): a pre-filled next call. The agent can run it verbatim
4
5
  * instead of assembling args from the ids scattered through the response.
@@ -31,7 +32,7 @@ export interface ReadPlanResult {
31
32
  * Mapping honours how reads resolve server-side: messages read only via chat,
32
33
  * artifacts via the chat, agreement, or task they landed on.
33
34
  */
34
- export declare function buildReadPlan(inbox: InboxEnvelope, grantsByScope?: Map<string, ScopeGrantTag>): ReadPlanResult;
35
+ export declare function buildReadPlan(inbox: InboxEnvelope, grantsByScope?: Map<string, ScopeGrantTag>, self?: DecisionSelfIds): ReadPlanResult;
35
36
  /**
36
37
  * ZIG-634: forward-continuation for a read_context page. Built only from fields
37
38
  * already on the page (via, hasMore/nextCursor, latestSequence) plus the grant
@@ -73,4 +74,12 @@ export declare function indexReachByScope(reach: GrantView[]): Map<string, Scope
73
74
  * each read's covering grant (ZIG-635) so the agent can present
74
75
  * X-Context-Grant-Id without a separate discover round-trip.
75
76
  */
76
- export declare function formatInboxToolResult(inbox: InboxEnvelope, ack?: InboxAckResult | null, webOrigin?: string, activeTasks?: Task[], reach?: GrantView[], activeTasksError?: string): Record<string, unknown>;
77
+ export declare function formatInboxToolResult(inbox: InboxEnvelope, ack?: InboxAckResult | null, webOrigin?: string, activeTasks?: Task[], reach?: GrantView[], activeTasksError?: string,
78
+ /**
79
+ * The caller's own ids (ZIG-1087). Optional here alone: this result carries
80
+ * the session-start COUNTS and a pointer to ziggs_pending_decisions, never
81
+ * the decision items themselves, and a count does not depend on which party
82
+ * may answer. Production callers pass it regardless — if this shape ever
83
+ * starts emitting `decisions`, they must already be marked correctly.
84
+ */
85
+ self?: DecisionSelfIds): Record<string, unknown>;
@@ -1,7 +1,22 @@
1
- import { grantCaveat } from '@ziggs-ai/api-client';
1
+ import { grantCaveat, resolvePendingApprovalPartyId } from '@ziggs-ai/api-client';
2
2
  import { formatPendingDecisionsPayload, resolveWebAppOrigin, } from './pendingDecisions.js';
3
3
  /** Keep the plan bounded; the full deliveries array still carries everything. */
4
4
  const MAX_READ_PLAN = 12;
5
+ /**
6
+ * Where to read an artifact delivery from — a total function, so there is no
7
+ * "none of the above" that silently drops the row. Container first (the parties
8
+ * who can see it there are the audience the write chose), then the artifact
9
+ * itself for a free-standing one.
10
+ */
11
+ function artifactEntry(d) {
12
+ if (d.chatId)
13
+ return ['chat', d.chatId];
14
+ if (d.agreementId)
15
+ return ['agreement', d.agreementId];
16
+ if (d.taskId)
17
+ return ['task', d.taskId];
18
+ return ['artifact', d.resourceId];
19
+ }
5
20
  function readContextCall(type, kind, id, grantId) {
6
21
  // ZIG-660: pin the covering grant so the read presents the right
7
22
  // X-Context-Grant-Id without a separate discover_context round-trip.
@@ -25,7 +40,7 @@ function readContextCall(type, kind, id, grantId) {
25
40
  * Mapping honours how reads resolve server-side: messages read only via chat,
26
41
  * artifacts via the chat, agreement, or task they landed on.
27
42
  */
28
- export function buildReadPlan(inbox, grantsByScope) {
43
+ export function buildReadPlan(inbox, grantsByScope, self = { agentId: '' }) {
29
44
  const proposals = inbox.proposalsAwaitingMe ?? [];
30
45
  const connectionRequests = inbox.connectionRequestsAwaitingMe ?? [];
31
46
  const deliveries = inbox.deliveries ?? [];
@@ -43,19 +58,40 @@ export function buildReadPlan(inbox, grantsByScope) {
43
58
  };
44
59
  // Decisions first — these also drive humanAttention (pull-only: no push).
45
60
  // The decision (approve/reject) is the human's; we only pre-fill the target.
61
+ //
62
+ // ZIG-1087: only when the pending slot is OURS. A proposal bound to the
63
+ // principal's slot cannot be answered with this tool by anyone on this
64
+ // surface, so pre-filling the call would be handing over a step that fails
65
+ // every time — the plan says to carry it to the human instead.
66
+ const mayAnswer = (facts) => resolvePendingApprovalPartyId({
67
+ pendingPartyIds: facts.pendingApprovalPartyIds ?? [],
68
+ proposedTo: facts.proposedTo,
69
+ }, [self.agentId]) === self.agentId && self.agentId !== '';
46
70
  for (const p of proposals) {
47
- add(`respond:${p.agreementId}`, {
48
- tool: 'ziggs_agreement_respond',
49
- args: { agreementId: p.agreementId },
50
- why: 'agreement proposal awaiting your response — wait for the human to approve/reject',
51
- });
71
+ add(`respond:${p.agreementId}`, mayAnswer(p)
72
+ ? {
73
+ tool: 'ziggs_agreement_respond',
74
+ args: { agreementId: p.agreementId },
75
+ why: 'agreement proposal awaiting your response — wait for the human to approve/reject',
76
+ }
77
+ : {
78
+ tool: 'ziggs_pending_decisions',
79
+ args: {},
80
+ why: `proposal ${p.agreementId} is awaiting your HUMAN's approval, not yours — paste the card for them; ziggs_agreement_respond is refused for a delegate here`,
81
+ });
52
82
  }
53
83
  for (const c of connectionRequests) {
54
- add(`respond:${c.requestId}`, {
55
- tool: 'ziggs_agreement_respond',
56
- args: { agreementId: c.requestId },
57
- why: 'connection request awaiting your response — wait for the human to approve/reject',
58
- });
84
+ add(`respond:${c.requestId}`, mayAnswer(c)
85
+ ? {
86
+ tool: 'ziggs_agreement_respond',
87
+ args: { agreementId: c.requestId },
88
+ why: 'connection request awaiting your response — wait for the human to approve/reject',
89
+ }
90
+ : {
91
+ tool: 'ziggs_pending_decisions',
92
+ args: {},
93
+ why: `connection request ${c.requestId} is awaiting your HUMAN's approval, not yours — paste the card for them; ziggs_agreement_respond is refused for a delegate here`,
94
+ });
59
95
  }
60
96
  // ZIG-635: pin the covering grant for a chat/agreement read when the caller
61
97
  // holds one, so the read presents the right X-Context-Grant-Id without a
@@ -65,21 +101,43 @@ export function buildReadPlan(inbox, grantsByScope) {
65
101
  // Reads — one call per place mail actually landed. The delivery names the
66
102
  // chat, agreement or task directly, so nothing has to be inferred from a scope.
67
103
  //
68
- // The task arm is not optional: a deliverable recorded against a task alone is
69
- // the shape the protocol asks for, and it lands in no chat and no agreement.
70
- // While this only looked at chatId/agreementId, such a delivery produced an
71
- // envelope whose plan was the ack and nothing else an agent following the
72
- // plan acked the deliverable without ever reading it.
104
+ // Total over BOTH axes, and that is the whole point of this shape:
105
+ //
106
+ // - over `kind`, so a delivery kind nobody plans a read for is a compile
107
+ // error here rather than a plan that quietly ends at the ack;
108
+ // - over the anchor, so is an artifact carrying none of chat/agreement/task
109
+ // — it is read as `artifact:<id>`, the entry ZIG-1037 added for that case.
110
+ //
111
+ // The first is history: while this looked at chatId/agreementId only, a
112
+ // task-bound deliverable produced an envelope whose plan was the ack and
113
+ // nothing else, and an agent following the plan acked work it never read. The
114
+ // second is that hole closed ahead of an emitter — nothing writes an
115
+ // anchor-less delivery today (ZIG-1100), and when something does it is planned.
73
116
  for (const d of deliveries) {
74
- if (d.kind === 'message' && d.chatId)
75
- read('messages', 'chat', d.chatId);
76
- else if (d.kind === 'artifact') {
77
- if (d.chatId)
78
- read('artifacts', 'chat', d.chatId);
79
- else if (d.agreementId)
80
- read('artifacts', 'agreement', d.agreementId);
81
- else if (d.taskId)
82
- read('artifacts', 'task', d.taskId);
117
+ switch (d.kind) {
118
+ case 'message':
119
+ // A message always lands in a chat; there is nowhere else to read it.
120
+ if (d.chatId)
121
+ read('messages', 'chat', d.chatId);
122
+ break;
123
+ case 'artifact':
124
+ read('artifacts', ...artifactEntry(d));
125
+ break;
126
+ case 'task-state':
127
+ case 'agreement':
128
+ // Deliberately no read call. These arrive as standing state elsewhere on
129
+ // the envelope — `tasksAwaitingMe` and `proposalsAwaitingMe` — and both
130
+ // already contribute their own plan entries above. A read here would be
131
+ // a duplicate of a call the agent has been handed.
132
+ break;
133
+ default:
134
+ // Compile-time exhaustiveness: a kind added to the vocabulary no longer
135
+ // satisfies `never`, so it cannot be introduced without a decision being
136
+ // made here. At runtime this skips rather than throws — this client is
137
+ // installed independently of the server it talks to, and one
138
+ // unrecognised delivery must not cost the caller the whole envelope.
139
+ d.kind;
140
+ break;
83
141
  }
84
142
  }
85
143
  // ZIG-660: reserve a slot for the ack before capping, so the pre-filled ack
@@ -180,14 +238,22 @@ export function indexReachByScope(reach) {
180
238
  * each read's covering grant (ZIG-635) so the agent can present
181
239
  * X-Context-Grant-Id without a separate discover round-trip.
182
240
  */
183
- export function formatInboxToolResult(inbox, ack, webOrigin, activeTasks, reach, activeTasksError) {
241
+ export function formatInboxToolResult(inbox, ack, webOrigin, activeTasks, reach, activeTasksError,
242
+ /**
243
+ * The caller's own ids (ZIG-1087). Optional here alone: this result carries
244
+ * the session-start COUNTS and a pointer to ziggs_pending_decisions, never
245
+ * the decision items themselves, and a count does not depend on which party
246
+ * may answer. Production callers pass it regardless — if this shape ever
247
+ * starts emitting `decisions`, they must already be marked correctly.
248
+ */
249
+ self = { agentId: '' }) {
184
250
  const byScope = reach?.length ? indexReachByScope(reach) : undefined;
185
- const { plan: readPlan, truncated: readPlanTruncated } = buildReadPlan(inbox, byScope);
251
+ const { plan: readPlan, truncated: readPlanTruncated } = buildReadPlan(inbox, byScope, self);
186
252
  const origin = resolveWebAppOrigin(webOrigin);
187
253
  // ZIG-659: the inbox reports session-start counts and points to
188
254
  // ziggs_pending_decisions for the sessionChatCard — it no longer re-emits the
189
255
  // cards, so a session start doesn't ship the same card ~6× across tools.
190
- const pending = formatPendingDecisionsPayload(inbox, origin, {
256
+ const pending = formatPendingDecisionsPayload(inbox, origin, self, {
191
257
  activeTasks,
192
258
  activeTasksError,
193
259
  withSessionCard: false,
@@ -1,5 +1,22 @@
1
- import type { InboxEnvelope, Task } from '@ziggs-ai/api-client';
1
+ import { type InboxEnvelope, type Task } from '@ziggs-ai/api-client';
2
+ /**
3
+ * The two ids this delegate answers for, in the order authority is checked:
4
+ * its own agent id (what the credential impersonates, and the only slot it can
5
+ * submit) then its principal's.
6
+ */
7
+ export interface DecisionSelfIds {
8
+ agentId: string;
9
+ ownerUserId?: string | null;
10
+ }
2
11
  export type PendingDecisionKind = 'proposal' | 'link_request';
12
+ /**
13
+ * Who is actually able to answer a decision (ZIG-1087).
14
+ *
15
+ * `agent` — the pending slot is this delegate's own; ziggs_agreement_respond works.
16
+ * `human` — the slot belongs to the principal. Consent is withheld from
17
+ * delegates on purpose, and no tool on this surface can give it.
18
+ */
19
+ export type DecisionResponder = 'agent' | 'human';
3
20
  export interface PendingDecisionItem {
4
21
  kind: PendingDecisionKind;
5
22
  /** Agreement id for proposals; requestId for link requests. */
@@ -9,7 +26,12 @@ export interface PendingDecisionItem {
9
26
  proposedAt: string | null;
10
27
  proposedAtLabel: string | null;
11
28
  appUrl: string;
12
- /** The MCP tool call the agent runs on approval (both kinds are in-chat). */
29
+ respondableBy: DecisionResponder;
30
+ /**
31
+ * The MCP tool call the agent runs on approval — null when the decision is
32
+ * the human's, because offering a tool that always 403s is what sent agents
33
+ * into retry loops.
34
+ */
13
35
  respondApprove: string | null;
14
36
  respondReject: string | null;
15
37
  sayApprove: string | null;
@@ -59,7 +81,7 @@ export declare function agreementsListAppUrl(origin: string): string;
59
81
  export declare function connectionsSettingsAppUrl(origin: string): string;
60
82
  /** Where the human decides paused transfers (ZIG-896). */
61
83
  export declare function walletAppUrl(origin: string): string;
62
- export declare function buildPendingDecisionItems(inbox: InboxEnvelope, webOrigin: string): PendingDecisionItem[];
84
+ export declare function buildPendingDecisionItems(inbox: InboxEnvelope, webOrigin: string, self: DecisionSelfIds): PendingDecisionItem[];
63
85
  /** ZIG-896 — shape pending payment approvals for the session payload/card. */
64
86
  export declare function buildPaymentApprovalItems(approvals: Array<Record<string, unknown>>, webOrigin: string): PaymentApprovalItem[];
65
87
  /**
@@ -90,7 +112,7 @@ export declare function buildSessionChatCard(decisions: PendingDecisionItem[], w
90
112
  paymentApprovals?: PaymentApprovalItem[];
91
113
  }): string;
92
114
  /** Structured session payload for MCP tools (ZIG-625 + active work). */
93
- export declare function formatPendingDecisionsPayload(inbox: InboxEnvelope, webOrigin: string, opts?: {
115
+ export declare function formatPendingDecisionsPayload(inbox: InboxEnvelope, webOrigin: string, self: DecisionSelfIds, opts?: {
94
116
  activeTasks?: Task[];
95
117
  activeTasksError?: string;
96
118
  /**
@@ -1,3 +1,4 @@
1
+ import { resolvePendingApprovalPartyId, } from '@ziggs-ai/api-client';
1
2
  const TITLE_MAX = 72;
2
3
  const ACTIVE_TASK_LIMIT = 20;
3
4
  /**
@@ -57,8 +58,12 @@ function planProgress(plan) {
57
58
  const done = steps.filter((s) => s.status === 'completed' || s.status === 'skipped').length;
58
59
  return { done, total };
59
60
  }
60
- function proposalToItem(p, origin) {
61
+ function proposalToItem(p, origin, self) {
61
62
  const id = p.agreementId;
63
+ // The inbox lists proposals awaiting EITHER of our ids, so ask which one.
64
+ // Agent id first: when both owe a decision, ours is the one we can act on.
65
+ const slot = resolvePendingApprovalPartyId({ pendingPartyIds: p.pendingApprovalPartyIds ?? [], proposedTo: p.proposedTo }, [self.agentId, self.ownerUserId]);
66
+ const mine = slot != null && slot === self.agentId;
62
67
  return {
63
68
  kind: 'proposal',
64
69
  agreementId: id,
@@ -67,51 +72,56 @@ function proposalToItem(p, origin) {
67
72
  proposedAt: p.proposedAt,
68
73
  proposedAtLabel: formatWhen(p.proposedAt),
69
74
  appUrl: agreementAppUrl(origin, id),
70
- respondApprove: `ziggs_agreement_respond agreementId=${id} action=approve`,
71
- respondReject: `ziggs_agreement_respond agreementId=${id} action=reject`,
72
- sayApprove: `approve ${id}`,
73
- sayReject: `reject ${id}`,
75
+ respondableBy: mine ? 'agent' : 'human',
76
+ respondApprove: mine ? `ziggs_agreement_respond agreementId=${id} action=approve` : null,
77
+ respondReject: mine ? `ziggs_agreement_respond agreementId=${id} action=reject` : null,
78
+ sayApprove: mine ? `approve ${id}` : null,
79
+ sayReject: mine ? `reject ${id}` : null,
74
80
  };
75
81
  }
76
82
  function linkRequesterHeadline(c) {
77
- // ZIG-1039: consent cards lead with a readable name + org, id as fallback.
83
+ // ZIG-1039: consent cards lead with a readable name + org; ZIG-1137: ref is
84
+ // a non-addressable psn_* face, never an agent account id.
78
85
  const name = c.requesterDisplayName?.trim();
79
86
  const org = c.requesterOrgName?.trim();
80
87
  if (name && org)
81
88
  return `${name} (${org})`;
82
89
  if (name)
83
90
  return name;
84
- return c.requesterAgentId;
91
+ return c.requesterRef;
85
92
  }
86
- function linkToItem(c, origin) {
93
+ function linkToItem(c, origin, self) {
87
94
  const id = c.requestId;
88
95
  const note = c.message?.trim() || null;
89
96
  const headline = linkRequesterHeadline(c);
97
+ const slot = resolvePendingApprovalPartyId({ pendingPartyIds: c.pendingApprovalPartyIds ?? [], proposedTo: c.proposedTo }, [self.agentId, self.ownerUserId]);
98
+ const mine = slot != null && slot === self.agentId;
90
99
  return {
91
100
  kind: 'link_request',
92
101
  agreementId: id,
93
102
  title: truncateText(`Agent link · ${headline}`),
94
103
  subtitle: note
95
104
  ? truncateText(note, 96)
96
- : c.requesterAgentId && headline !== c.requesterAgentId
97
- ? truncateText(`id: ${c.requesterAgentId}`, 96)
105
+ : c.requesterRef && headline !== c.requesterRef
106
+ ? truncateText(`ref: ${c.requesterRef}`, 96)
98
107
  : null,
99
108
  proposedAt: c.requestedAt,
100
109
  proposedAtLabel: formatWhen(c.requestedAt),
101
110
  appUrl: agreementAppUrl(origin, id),
102
- respondApprove: `ziggs_agreement_respond agreementId=${id} action=approve`,
103
- respondReject: `ziggs_agreement_respond agreementId=${id} action=reject`,
104
- sayApprove: `approve link ${id}`,
105
- sayReject: `reject link ${id}`,
111
+ respondableBy: mine ? 'agent' : 'human',
112
+ respondApprove: mine ? `ziggs_agreement_respond agreementId=${id} action=approve` : null,
113
+ respondReject: mine ? `ziggs_agreement_respond agreementId=${id} action=reject` : null,
114
+ sayApprove: mine ? `approve link ${id}` : null,
115
+ sayReject: mine ? `reject link ${id}` : null,
106
116
  };
107
117
  }
108
- export function buildPendingDecisionItems(inbox, webOrigin) {
118
+ export function buildPendingDecisionItems(inbox, webOrigin, self) {
109
119
  const items = [];
110
120
  for (const p of inbox.proposalsAwaitingMe ?? []) {
111
- items.push(proposalToItem(p, webOrigin));
121
+ items.push(proposalToItem(p, webOrigin, self));
112
122
  }
113
123
  for (const c of inbox.connectionRequestsAwaitingMe ?? []) {
114
- items.push(linkToItem(c, webOrigin));
124
+ items.push(linkToItem(c, webOrigin, self));
115
125
  }
116
126
  return items;
117
127
  }
@@ -216,14 +226,19 @@ function buildDecisionSection(items, opts) {
216
226
  lines.push(`> ${item.subtitle}`);
217
227
  }
218
228
  lines.push('');
229
+ lines.push(`[Review in Ziggs →](${item.appUrl})`);
230
+ lines.push('');
219
231
  if (item.sayApprove && item.respondApprove && item.sayReject && item.respondReject) {
220
- lines.push(`[Review in Ziggs →](${item.appUrl})`);
221
- lines.push('');
222
232
  lines.push('| You say in chat | What the agent runs |');
223
233
  lines.push('|:----------------|:--------------------|');
224
234
  lines.push(`| \`${item.sayApprove}\` | \`${item.respondApprove}\` |`);
225
235
  lines.push(`| \`${item.sayReject}\` | \`${item.respondReject}\` |`);
226
236
  }
237
+ else {
238
+ // ZIG-1087 — this one is bound to the human's own party slot. Saying so
239
+ // beats offering a tool call that is refused every time.
240
+ lines.push('**Only you can answer this one** — it is waiting on your approval, not the agent\'s, so decide it on the link above. Nothing the agent runs can approve it for you.');
241
+ }
227
242
  lines.push('');
228
243
  }
229
244
  if (truncated > 0) {
@@ -356,9 +371,9 @@ export function buildSessionChatCard(decisions, work, opts) {
356
371
  return lines.join('\n').trim();
357
372
  }
358
373
  /** Structured session payload for MCP tools (ZIG-625 + active work). */
359
- export function formatPendingDecisionsPayload(inbox, webOrigin, opts) {
374
+ export function formatPendingDecisionsPayload(inbox, webOrigin, self, opts) {
360
375
  const withSessionCard = opts?.withSessionCard !== false;
361
- const decisions = buildPendingDecisionItems(inbox, webOrigin);
376
+ const decisions = buildPendingDecisionItems(inbox, webOrigin, self);
362
377
  const activeWork = buildActiveWorkItems(opts?.activeTasks ?? [], webOrigin);
363
378
  const paymentApprovals = buildPaymentApprovalItems(opts?.paymentApprovals ?? [], webOrigin);
364
379
  const truncatedProposals = inbox.truncatedProposals ?? 0;
@@ -376,14 +391,25 @@ export function formatPendingDecisionsPayload(inbox, webOrigin, opts) {
376
391
  agreementsListAppUrl: listUrl,
377
392
  paymentApprovals,
378
393
  };
379
- const sessionChatCard = buildSessionChatCard(decisions, activeWork, cardOpts);
394
+ // Only the card owner ships it (ZIG-659). ziggs_inbox and ziggs_auth_status
395
+ // pass withSessionCard: false, so building it there assembled every decision,
396
+ // payment and up-to-20 work sections just to drop them for a one-line hint.
397
+ const sessionChatCard = withSessionCard
398
+ ? buildSessionChatCard(decisions, activeWork, cardOpts)
399
+ : null;
380
400
  const proposalCount = decisions.filter((d) => d.kind === 'proposal').length;
381
401
  const linkCount = decisions.filter((d) => d.kind === 'link_request').length;
402
+ // ZIG-1087 — decisions bound to the principal's own slot cannot be answered
403
+ // from this surface at all, so the instruction has to stop saying they can.
404
+ const humanOnly = decisions.filter((d) => d.respondableBy === 'human');
405
+ const humanOnlyNote = humanOnly.length
406
+ ? ` ${humanOnly.length} of them ${humanOnly.length === 1 ? 'is' : 'are'} the human's own to answer (respondableBy "human") — for those, hand over the link and do not call ziggs_agreement_respond; it is refused for a delegate.`
407
+ : '';
382
408
  const instruction = actionCount === 0
383
409
  ? 'No pending decisions or active tasks — continue with ziggs_inbox for scope news.'
384
410
  : withSessionCard
385
- ? 'Paste sessionChatCard at the top of your reply. Decisions: wait for explicit approve/reject before ziggs_agreement_respond. Tasks: when the human says work on <taskId>, read context and implement.'
386
- : `Counts only here — ${SESSION_CARD_POINTER} Decisions: wait for explicit approve/reject before ziggs_agreement_respond.`;
411
+ ? `Paste sessionChatCard at the top of your reply. Decisions: wait for explicit approve/reject before ziggs_agreement_respond.${humanOnlyNote} Tasks: when the human says work on <taskId>, read context and implement.`
412
+ : `Counts only here — ${SESSION_CARD_POINTER} Decisions: wait for explicit approve/reject before ziggs_agreement_respond.${humanOnlyNote}`;
387
413
  return {
388
414
  pendingCount,
389
415
  hasPending: pendingCount > 0,
@@ -448,7 +474,7 @@ export function buildPendingNextActions(decisions, work = []) {
448
474
  actions.push(`${kindLabel(d.kind)} ${d.agreementId}: \`${d.sayApprove}\` or \`${d.sayReject}\``);
449
475
  }
450
476
  else {
451
- actions.push(`${kindLabel(d.kind)} ${d.agreementId}: connect or reject in the browser ${d.appUrl}`);
477
+ actions.push(`${kindLabel(d.kind)} ${d.agreementId}: waiting on the human's own approval they decide it at ${d.appUrl}; ziggs_agreement_respond cannot.`);
452
478
  }
453
479
  }
454
480
  for (const w of work.slice(0, 4)) {
@@ -14,9 +14,15 @@ export interface ToolErrorShape {
14
14
  hint?: string;
15
15
  }
16
16
  /** Classify a raw client error message into a stable shape. */
17
- export declare function classifyToolError(rawMessage: string): ToolErrorShape;
18
- /** MCP tool error result: machine-readable code + cleaned message (+ hint). */
19
- export declare function toolError(message: string): {
17
+ export declare function classifyToolError(rawMessage: string, knownStatus?: number | null): ToolErrorShape;
18
+ /**
19
+ * MCP tool error result: machine-readable code + cleaned message (+ hint).
20
+ *
21
+ * Pass the caught error itself, not `err.message` — an `ApiError` carries the
22
+ * HTTP status that decides the code and the hint (ZIG-1086). A plain string is
23
+ * still accepted for refusals this layer raises on its own.
24
+ */
25
+ export declare function toolError(input: unknown): {
20
26
  content: {
21
27
  type: "text";
22
28
  text: string;
package/dist/toolError.js CHANGED
@@ -14,6 +14,14 @@ const SCOPE_DENIED_HINT = 'You are not authorized for this scope. To get access:
14
14
  'to issue you a context grant (they run ziggs_context_issue_grant), or propose a ' +
15
15
  'bilateral link first (ziggs_agreement_propose with engagementKind "link"). Check what you can already ' +
16
16
  'reach with ziggs_grant_list / ziggs_context_snapshot.';
17
+ // ZIG-1088 — a human-authority denial is not "try again with more scope": no
18
+ // retry by this caller can ever pass it, because the guard refuses on being an
19
+ // agent at all, before it looks at what was asked. Without a next action the
20
+ // only strategy left is repetition, which is what agents did.
21
+ const HUMAN_AUTHORITY_HINT = 'This action is the account owner\'s to take, not yours — retrying will not ' +
22
+ 'change that. Your paths: ziggs_context_delegate to pass on a narrower slice ' +
23
+ 'of a grant you ALREADY hold, ziggs_artifact_share for an artifact you ' +
24
+ 'authored, or tell your human to do it in the Ziggs app.';
17
25
  function codeForStatus(status) {
18
26
  if (status === 401)
19
27
  return 'NOT_AUTHENTICATED';
@@ -45,11 +53,27 @@ function extractBodyReason(raw) {
45
53
  return null;
46
54
  }
47
55
  }
56
+ /**
57
+ * An `ApiError` from api-client, recognised by shape rather than `instanceof`:
58
+ * a bundled or duplicated copy of the class would fail an identity check while
59
+ * carrying exactly the fields we need.
60
+ */
61
+ function thrownStatus(err) {
62
+ if (typeof err !== 'object' || err === null)
63
+ return null;
64
+ const status = err.status;
65
+ return typeof status === 'number' && status >= 100 && status < 600 ? status : null;
66
+ }
48
67
  /** Classify a raw client error message into a stable shape. */
49
- export function classifyToolError(rawMessage) {
68
+ export function classifyToolError(rawMessage, knownStatus) {
50
69
  const cleaned = rawMessage.replace(CLIENT_PREFIX, '').trim();
70
+ // ZIG-1086: the status is scraped from the message only as a LAST resort.
71
+ // `ApiError` carries it as a real field, and every caller that dropped it
72
+ // (passing `err.message` instead of `err`) landed here with nothing to match:
73
+ // "missing scope: context:admin" has no digits, so it fell through to a bare
74
+ // TOOL_ERROR and the agent never saw the recovery hint it needed.
51
75
  const statusMatch = HTTP_STATUS.exec(cleaned);
52
- const status = statusMatch ? Number(statusMatch[1]) : null;
76
+ const status = knownStatus ?? (statusMatch ? Number(statusMatch[1]) : null);
53
77
  const bodyReason = extractBodyReason(cleaned);
54
78
  if (status === null) {
55
79
  return { code: 'TOOL_ERROR', message: cleaned || rawMessage };
@@ -57,6 +81,16 @@ export function classifyToolError(rawMessage) {
57
81
  const code = codeForStatus(status);
58
82
  // Prefer the backend's own reason over the transport framing.
59
83
  const message = bodyReason ?? cleaned;
84
+ // The human-authority guard refuses every impersonated agent, whatever it
85
+ // asked for. Checked before the scope branch below: its message also mentions
86
+ // scopes, and "ask for a grant on this scope" is the wrong advice here.
87
+ if (code === 'NOT_AUTHORIZED' && /impersonated agent cannot perform/i.test(message)) {
88
+ return {
89
+ code: 'AGENT_LACKS_HUMAN_AUTHORITY',
90
+ message,
91
+ hint: HUMAN_AUTHORITY_HINT,
92
+ };
93
+ }
60
94
  // Context-scope denials (the backend says "…for this scope") get the scope
61
95
  // code and a recovery path. Other 403s (connection grants, party checks)
62
96
  // keep the generic code — their fixes live in other domains.
@@ -69,9 +103,18 @@ export function classifyToolError(rawMessage) {
69
103
  }
70
104
  return { code, message };
71
105
  }
72
- /** MCP tool error result: machine-readable code + cleaned message (+ hint). */
73
- export function toolError(message) {
74
- const shape = classifyToolError(message);
106
+ /**
107
+ * MCP tool error result: machine-readable code + cleaned message (+ hint).
108
+ *
109
+ * Pass the caught error itself, not `err.message` — an `ApiError` carries the
110
+ * HTTP status that decides the code and the hint (ZIG-1086). A plain string is
111
+ * still accepted for refusals this layer raises on its own.
112
+ */
113
+ export function toolError(input) {
114
+ const message = typeof input === 'string'
115
+ ? input
116
+ : (input?.message ?? String(input));
117
+ const shape = classifyToolError(String(message), thrownStatus(input));
75
118
  return {
76
119
  content: [
77
120
  { type: 'text', text: JSON.stringify({ error: shape }, null, 2) },
package/dist/tools.js CHANGED
@@ -5,7 +5,7 @@ import { decodeOperatorKeyClaims } from './operatorKey.js';
5
5
  import { registerTrustTools } from './trustTools.js';
6
6
  import { registerPaymentTools } from './paymentTools.js';
7
7
  import { formatInboxToolResult, buildReadContextReadPlan, } from './inboxToolResult.js';
8
- import { filterTasksForDelegate, formatPendingDecisionsPayload, resolveWebAppOrigin, buildPendingNextActions, } from './pendingDecisions.js';
8
+ import { agreementAppUrl, filterTasksForDelegate, formatPendingDecisionsPayload, resolveWebAppOrigin, buildPendingNextActions, } from './pendingDecisions.js';
9
9
  import { PROTOCOL } from './protocol/delegateProtocol.js';
10
10
  const RELAY_COORDINATOR_AGENT_ID = 'relay-coordinator';
11
11
  function buildRelayCoordinatorTaskBody(opts) {
@@ -58,6 +58,12 @@ const ZIGGS_RECORD_ARTIFACT_DESCRIPTION = 'Write an artifact. Scope is optional
58
58
  // grouped connection lister (ConnectionsClient.listForHolder), the org lookups
59
59
  // (fetchMyOrgs / fetchDelegateAccess), and the whole SDK-twin tool definitions
60
60
  // all live in @ziggs-ai/api-client now (shared with the agent SDK).
61
+ /** The human this delegate acts for: operator-key ownerId, else the config id. */
62
+ function ownerPrincipalId(creds, cfg) {
63
+ return (decodeOperatorKeyClaims(creds.operatorKey)?.ownerId ??
64
+ cfg.ZIGGS_OWNER_USER_ID ??
65
+ null);
66
+ }
61
67
  /**
62
68
  * Ids this delegate answers for: its own agent id plus its principal's user
63
69
  * id (operator-key ownerId / ZIGGS_OWNER_USER_ID). Used to decide which
@@ -98,6 +104,9 @@ async function loadSessionActionsPayload(creds, cfg, opts) {
98
104
  paymentApprovalsError = e.message;
99
105
  }
100
106
  return formatPendingDecisionsPayload(inbox, webOrigin, {
107
+ agentId: creds.agentId,
108
+ ownerUserId: ownerPrincipalId(creds, cfg),
109
+ }, {
101
110
  activeTasks,
102
111
  activeTasksError,
103
112
  paymentApprovals,
@@ -163,7 +172,7 @@ function registerMarketplaceTools(server, creds) {
163
172
  });
164
173
  }
165
174
  catch (e) {
166
- return toolError(e.message);
175
+ return toolError(e);
167
176
  }
168
177
  });
169
178
  }
@@ -177,7 +186,7 @@ function registerConnectionTools(server, creds) {
177
186
  return textResult({ connections });
178
187
  }
179
188
  catch (e) {
180
- return toolError(e.message);
189
+ return toolError(e);
181
190
  }
182
191
  });
183
192
  registerCapability(server, requestConnectionCapability, creds);
@@ -260,7 +269,7 @@ export function registerZiggsTools(server, creds, cfg) {
260
269
  return textResult({ count: orgs.length, orgs });
261
270
  }
262
271
  catch (e) {
263
- return toolError(e.message);
272
+ return toolError(e);
264
273
  }
265
274
  });
266
275
  server.tool('ziggs_pending_decisions', ZIGGS_PENDING_DECISIONS_DESCRIPTION, {}, READ_ONLY, async () => {
@@ -277,7 +286,7 @@ export function registerZiggsTools(server, creds, cfg) {
277
286
  return textResult(payload);
278
287
  }
279
288
  catch (e) {
280
- return toolError(e.message);
289
+ return toolError(e);
281
290
  }
282
291
  });
283
292
  if (cfg.debugTools) {
@@ -299,7 +308,7 @@ export function registerZiggsTools(server, creds, cfg) {
299
308
  });
300
309
  }
301
310
  catch (e) {
302
- return toolError(e.message);
311
+ return toolError(e);
303
312
  }
304
313
  });
305
314
  }
@@ -320,7 +329,7 @@ export function registerZiggsTools(server, creds, cfg) {
320
329
  return textResult(result);
321
330
  }
322
331
  catch (e) {
323
- return toolError(e.message);
332
+ return toolError(e);
324
333
  }
325
334
  });
326
335
  server.tool('ziggs_agreement_list', 'List agreements you are a party to — your hires, proposals, and work (default scope "mine"). Pass scope "reachable" to list every agreement your grant can read in the org, including ones you are not a party to; the isYou flags on each row mark which party (if any) is you.', {
@@ -341,7 +350,7 @@ export function registerZiggsTools(server, creds, cfg) {
341
350
  return textResult({ count: agreements.length, scope: scope ?? 'mine', agreements });
342
351
  }
343
352
  catch (e) {
344
- return toolError(e.message);
353
+ return toolError(e);
345
354
  }
346
355
  });
347
356
  server.tool('ziggs_agreement_get', 'Fetch a single agreement by id.', { agreementId: z.string() }, READ_ONLY, async ({ agreementId }) => {
@@ -352,7 +361,7 @@ export function registerZiggsTools(server, creds, cfg) {
352
361
  return textResult({ agreement });
353
362
  }
354
363
  catch (e) {
355
- return toolError(e.message);
364
+ return toolError(e);
356
365
  }
357
366
  });
358
367
  server.tool('ziggs_chat_list', 'List chats the delegate agent is a member of (GET /chats/mine).', {}, READ_ONLY, async () => {
@@ -361,7 +370,7 @@ export function registerZiggsTools(server, creds, cfg) {
361
370
  return textResult({ count: chats.length, chats });
362
371
  }
363
372
  catch (e) {
364
- return toolError(e.message);
373
+ return toolError(e);
365
374
  }
366
375
  });
367
376
  registerCapability(server, openConversationCapability, creds);
@@ -370,7 +379,7 @@ export function registerZiggsTools(server, creds, cfg) {
370
379
  receiverId: z
371
380
  .string()
372
381
  .optional()
373
- .describe("User or agent id receiving the message. Optional: with exactly one other member the recipient is inferred server-side; in a room with several members an omitted receiver becomes a broadcast to the room's HUMAN members (agents are not woken by it). Pass 'human' to broadcast explicitly, or a specific agent id to address (and wake) that agent."),
382
+ .describe("Receiver id for the message. Prefer a real user/agent id when you have one. Cross-org masked counterparties arrive as opaque `rpb_*` refs — echo that same ref back here; the backend resolves it in-room (do not look it up, wake, or pay against it). Optional: with exactly one other member the recipient is inferred server-side; in a room with several members an omitted receiver becomes a broadcast to the room's HUMAN members (agents are not woken by it). Pass 'human' to broadcast explicitly."),
374
383
  text: z.string(),
375
384
  entryType: z
376
385
  .string()
@@ -398,7 +407,7 @@ export function registerZiggsTools(server, creds, cfg) {
398
407
  return textResult(result);
399
408
  }
400
409
  catch (e) {
401
- return toolError(e.message);
410
+ return toolError(e);
402
411
  }
403
412
  });
404
413
  server.tool('ziggs_agreement_propose', 'Propose an agreement — direct, broadcast, hand-off, or link; there are no separate publish tools. DIRECT: proposedTo = one counterparty id, chatId required. Omit providerId (or set it to proposedTo) to commission the recipient (they work, your side pays); set providerId to your own agent id to offer (you work, proposedTo pays); a third-party providerId brokers (they work, proposedTo pays) and requires that provider to have a matching active offer. BROADCAST: proposedTo "everyone" (fully public) or "org" (your active org only), chatId optional — with no providerId this publishes a QUEST (whoever claims does the work, your side pays); with providerId = your own id it publishes a STANDING OFFER (you work, the claimer pays). HAND-OFF (share an agent you hired): set parentAgreementId = that ACTIVE hire and providerId = its provider; proposedTo may be "everyone"/"org" (claimable) or a specific beneficiary id (they approve directly). The provider stays pinned — whoever claims/approves is the CUSTOMER the work is done for, never the worker, and on a priced hand-off they are also the payer (price omitted/0 = free: nobody is billed for their tasks). Handing off someone else\'s agent leaves that provider\'s approval pending — it must accept once before the hand-off can be claimed. Claiming is ziggs_agreement_claim; browsing is ziggs_marketplace_view. LINK: engagementKind "link" with proposedTo = an agent id proposes bilateral trust (no chat, no money). The server routes parties.proposedTo to that agent\'s owner human (a person decides who their delegate trusts) — the id you pass may differ from parties.proposedTo in the response; when it does, `note` explains the rewrite. Approve via ziggs_agreement_respond. ROLES: proposedTo is the CUSTOMER — the party the work is done for; the payer is only who pays, always derived server-side as the non-providing side — there is no payer input. engagementKind "service" (default) = one deliverable; "hire" = ongoing engagement. Agreements are STANDING by default (lifecycle "open": no expiry, unlimited tasks) — hire once, then keep spawning tasks under the same agreement; set expiresAt (time-bound) or maxExecutions (count-bound) only when the engagement should end on its own. price is recorded on the agreement but does not itself trigger a transfer.', {
@@ -417,8 +426,10 @@ export function registerZiggsTools(server, creds, cfg) {
417
426
  price: z
418
427
  .number()
419
428
  .optional()
420
- .describe('Amount in CENTS — 500 means $5.00. Optional; does not trigger a transfer by itself. ' +
421
- 'When you quote it to a human, convert: saying "$500" for 500 is off by 100x.'),
429
+ .describe('Amount in CENTS — 500 means $5.00, and ϟ5.00 in the UI. Optional; does not trigger a ' +
430
+ 'transfer by itself. Convert BOTH ways or you are off by 100x: a human who says ' +
431
+ '"ϟ2" or "$2 per task" means price 200, not 2; quoting 500 back as "$500" or "ϟ500" ' +
432
+ 'is the same mistake inverted. ϟ is the currency symbol the UI shows — it is not cents.'),
422
433
  engagementKind: z
423
434
  .enum(['hire', 'service', 'link'])
424
435
  .optional()
@@ -477,7 +488,7 @@ export function registerZiggsTools(server, creds, cfg) {
477
488
  });
478
489
  }
479
490
  catch (e) {
480
- return toolError(e.message);
491
+ return toolError(e);
481
492
  }
482
493
  });
483
494
  registerCapability(server, agreementClaimCapability, creds);
@@ -486,7 +497,6 @@ export function registerZiggsTools(server, creds, cfg) {
486
497
  executorId: z.string().describe('Agent doing the delegated work'),
487
498
  chatId: z.string().describe('Chat the delegation is coordinated in'),
488
499
  description: z.string().describe('What the sub-agreement covers'),
489
- parentTaskId: z.string().optional(),
490
500
  price: z
491
501
  .number()
492
502
  .optional()
@@ -498,14 +508,13 @@ export function registerZiggsTools(server, creds, cfg) {
498
508
  .optional()
499
509
  .describe("Usually inferred: expiresAt → 'time-bound', maxExecutions → 'count-bound', neither → 'open' (standing)."),
500
510
  agreementDescription: z.string().optional(),
501
- }, WRITE, async ({ parentAgreementId, executorId, chatId, description, parentTaskId, price, expiresAt, maxExecutions, lifecycle, agreementDescription, }) => {
511
+ }, WRITE, async ({ parentAgreementId, executorId, chatId, description, price, expiresAt, maxExecutions, lifecycle, agreementDescription, }) => {
502
512
  try {
503
513
  const agreement = await delegateAgreement({
504
514
  parentAgreementId,
505
515
  executorId,
506
516
  chatId,
507
517
  description,
508
- parentTaskId,
509
518
  price,
510
519
  expiresAt,
511
520
  maxExecutions,
@@ -515,13 +524,13 @@ export function registerZiggsTools(server, creds, cfg) {
515
524
  return textResult({ agreement });
516
525
  }
517
526
  catch (e) {
518
- return toolError(e.message);
527
+ return toolError(e);
519
528
  }
520
529
  });
521
530
  if (!cfg.coreOnly) {
522
531
  registerMarketplaceTools(server, creds);
523
532
  }
524
- server.tool('ziggs_agreement_respond', 'Approve or reject a pending DIRECT agreement proposal addressed to you (PUT /approvals/:partyId). Open broadcasts (quests, standing offers, link invites) have no personal approval slot — claim those with ziggs_agreement_claim instead, or ignore them to pass. ONE exception: a hand-off that pins YOU (or your agent) as provider carries your pending approval even as an open broadcast — approving it consents to serving whoever claims it (the row stays open for claims); rejecting cancels the hand-off.', {
533
+ server.tool('ziggs_agreement_respond', 'Approve or reject a pending DIRECT agreement proposal addressed to YOU — your own party slot (PUT /approvals/:partyId, which takes a decision only from the party itself). A proposal bound to your PRINCIPAL instead is not yours to answer and this tool refuses it: consent is deliberately withheld from delegates, so no retry and no other tool changes it — the human decides it in the Ziggs app, under Agreements. ziggs_pending_decisions marks which is which with respondableBy (agent | human), so check there before calling. Open broadcasts (quests, standing offers, link invites) have no personal approval slot — claim those with ziggs_agreement_claim instead, or ignore them to pass. ONE exception: a hand-off that pins YOU (or your agent) as provider carries your pending approval even as an open broadcast — approving it consents to serving whoever claims it (the row stays open for claims); rejecting cancels the hand-off.', {
525
534
  agreementId: z.string(),
526
535
  action: z.enum(['approve', 'reject']),
527
536
  }, WRITE, async ({ agreementId, action }) => {
@@ -530,13 +539,15 @@ export function registerZiggsTools(server, creds, cfg) {
530
539
  const ownerId = claims?.ownerId ?? cfg.ZIGGS_OWNER_USER_ID;
531
540
  const updated = await respondToAgreement(agreementId, action, creds, {
532
541
  ownerUserId: ownerId,
542
+ // So a refusal can name where the human goes, not just why.
543
+ appUrl: agreementAppUrl(resolveWebAppOrigin(cfg.ZIGGS_WEB_URL), agreementId),
533
544
  });
534
545
  // ZIG-957: link approvals/rejects share the same summary shape as claim/revoke.
535
546
  const agreement = updated?.engagementKind === 'link' ? linkSummary(updated) : updated;
536
547
  return textResult({ agreement });
537
548
  }
538
549
  catch (e) {
539
- return toolError(e.message);
550
+ return toolError(e);
540
551
  }
541
552
  });
542
553
  server.tool('ziggs_agreement_revoke', 'Revoke any agreement you are a party to — hire, service, quest, standing offer, or link (DELETE /agreements/:id). Either party may revoke; this ends the engagement immediately. Revoking a link ends cross-org reach to that peer; revoking an open broadcast takes it off the marketplace.', {
@@ -559,12 +570,15 @@ export function registerZiggsTools(server, creds, cfg) {
559
570
  });
560
571
  }
561
572
  catch (e) {
562
- return toolError(e.message);
573
+ return toolError(e);
563
574
  }
564
575
  });
565
- server.tool('ziggs_agreement_counter', 'Counter a pending proposal with revised terms instead of approving or rejecting (POST /agreements/:id/counter). Provide only the terms you want to change — price, description, expiry, lifecycle, or plan; omitted fields keep the original proposal\'s value. The counter goes back to the counterparty as a fresh pending proposal for them to approve/reject/counter. Read the current terms first with ziggs_agreement_get.', {
576
+ server.tool('ziggs_agreement_counter', 'Counter a pending proposal with revised terms instead of approving or rejecting (POST /agreements/:id/counter). Provide only the terms you want to change — price, description, expiry, or lifecycle; omitted fields keep the original proposal\'s value. The counter goes back to the counterparty as a fresh pending proposal for them to approve/reject/counter. Read the current terms first with ziggs_agreement_get.', {
566
577
  agreementId: z.string().describe('The pending agreement to counter'),
567
- price: z.number().optional().describe('Revised price'),
578
+ price: z
579
+ .number()
580
+ .optional()
581
+ .describe('Revised price, in CENTS — 500 means $5.00 / ϟ5.00 (see ziggs_agreement_propose).'),
568
582
  agreementDescription: z
569
583
  .string()
570
584
  .optional()
@@ -579,22 +593,13 @@ export function registerZiggsTools(server, creds, cfg) {
579
593
  .string()
580
594
  .optional()
581
595
  .describe('Revised task description for the spawned work'),
582
- plan: z
583
- .record(z.unknown())
584
- .optional()
585
- .describe('Override plan { steps: [...] }; omit to keep the original'),
586
- planReviewTiming: z
587
- .enum(['with_proposal', 'before_execution'])
588
- .optional()
589
- .describe('When the buyer reviews the plan: with_proposal | before_execution'),
590
- requireMidWorkPlanAck: z.boolean().optional(),
591
596
  }, WRITE, async ({ agreementId, ...counter }) => {
592
597
  try {
593
598
  const agreement = await counterAgreement(agreementId, counter, creds);
594
599
  return textResult({ status: 'countered', agreementId, agreement });
595
600
  }
596
601
  catch (e) {
597
- return toolError(e.message);
602
+ return toolError(e);
598
603
  }
599
604
  });
600
605
  server.tool('ziggs_agreement_fulfill', 'END an agreement you PROVIDE — permanently (POST /agreements/:id/fulfill). Fulfilling terminates the whole relationship, not one deliverable: every grant the agreement conferred (context, connection, payment) is revoked, its shared space is torn down, and it cannot be reopened — the counterparty would have to re-hire you from scratch. Finished WORK is reported with ziggs_task_set_result, which closes the task and leaves the agreement standing for the next one. Only fulfill a count/time-bound engagement whose full scope is delivered and where nothing more is expected — never a standing hire that just finished a task. Party-gated server-side: only the providing side can fulfill.', {
@@ -605,7 +610,7 @@ export function registerZiggsTools(server, creds, cfg) {
605
610
  return textResult({ status: 'fulfilled', agreementId, agreement: result.agreement });
606
611
  }
607
612
  catch (e) {
608
- return toolError(e.message);
613
+ return toolError(e);
609
614
  }
610
615
  });
611
616
  server.tool('ziggs_inbox', ZIGGS_INBOX_DESCRIPTION, {
@@ -648,10 +653,10 @@ export function registerZiggsTools(server, creds, cfg) {
648
653
  catch {
649
654
  // omit grant tags when the grants read fails
650
655
  }
651
- return textResult(formatInboxToolResult(inbox, acked, cfg.ZIGGS_WEB_URL, activeTasks, reach, activeTasksError));
656
+ return textResult(formatInboxToolResult(inbox, acked, cfg.ZIGGS_WEB_URL, activeTasks, reach, activeTasksError, { agentId: creds.agentId, ownerUserId: ownerPrincipalId(creds, cfg) }));
652
657
  }
653
658
  catch (e) {
654
- return toolError(e.message);
659
+ return toolError(e);
655
660
  }
656
661
  });
657
662
  registerCapabilities(server, GRANTS_CAPABILITIES, creds);
@@ -681,7 +686,7 @@ export function registerZiggsTools(server, creds, cfg) {
681
686
  // ---------------------------------------------------------------------------
682
687
  // Task mutation tools (ZIG-555)
683
688
  // ---------------------------------------------------------------------------
684
- server.tool('ziggs_task_create', 'Create a task under an agreement. Every task belongs to exactly one agreement (agreementId required).', {
689
+ server.tool('ziggs_task_create', 'Create a task under an agreement. Every task belongs to exactly one agreement (agreementId required). Pass plan to give the task its checklist in the same call — every step needs a non-blank description, since that is the label whoever is watching reads before anything closes. Leave plan off to start without one and post it later with ziggs_task_replace_plan.', {
685
690
  agreementId: z.string().describe('Agreement this task belongs to'),
686
691
  description: z.string().describe('What the task entails'),
687
692
  parentTaskId: z.string().optional().describe('Parent task id for sub-tasks'),
@@ -693,13 +698,47 @@ export function registerZiggsTools(server, creds, cfg) {
693
698
  .array(z.string())
694
699
  .optional()
695
700
  .describe('Artifact ids this task consumes as structured inputs — pass prior-step output handles without embedding them in description'),
696
- }, WRITE, async ({ agreementId, description, parentTaskId, assigneeId, inputArtifactIds }) => {
701
+ // ZIG-1096: POST /tasks and the SDK's task_create have always taken
702
+ // these three; only this surface hid them, so an agent on MCP had to
703
+ // create-then-replace_plan even when it already knew the steps.
704
+ plan: z
705
+ .object({
706
+ steps: z
707
+ .array(z.object({
708
+ stepId: z.string().describe('Stable id for this step (e.g. uuid)'),
709
+ description: z
710
+ .string()
711
+ .describe('What this step does, in one line — required and non-blank'),
712
+ order: z.number().int(),
713
+ }))
714
+ .describe('Ordered steps the task starts with'),
715
+ })
716
+ .optional()
717
+ .describe('The plan the task is born with. Omit to start without one.'),
718
+ planReviewTiming: z
719
+ .enum(['with_proposal', 'before_execution'])
720
+ .optional()
721
+ .describe('When the buyer reviews the plan. with_proposal (default) — approving the agreement covers it. before_execution — the task cannot start until the plan is acknowledged.'),
722
+ requireMidWorkPlanAck: z
723
+ .boolean()
724
+ .optional()
725
+ .describe('When true, restructuring the plan mid-task parks it for a fresh acknowledgement instead of applying silently.'),
726
+ }, WRITE, async ({ agreementId, description, parentTaskId, assigneeId, inputArtifactIds, plan, planReviewTiming, requireMidWorkPlanAck, }) => {
697
727
  try {
698
- const task = await createTask({ agreementId, description, parentTaskId, assigneeId, inputArtifactIds }, creds);
728
+ const task = await createTask({
729
+ agreementId,
730
+ description,
731
+ parentTaskId,
732
+ assigneeId,
733
+ inputArtifactIds,
734
+ plan,
735
+ planReviewTiming,
736
+ requireMidWorkPlanAck,
737
+ }, creds);
699
738
  return textResult({ ok: true, task });
700
739
  }
701
740
  catch (e) {
702
- return toolError(e.message);
741
+ return toolError(e);
703
742
  }
704
743
  });
705
744
  server.tool('ziggs_task_set_result', 'Transition a task to a terminal state (completed / failed / cancelled) and record the result. Enforces the state machine — only active tasks can be transitioned.', {
@@ -725,7 +764,7 @@ export function registerZiggsTools(server, creds, cfg) {
725
764
  return textResult({ ok: true, task });
726
765
  }
727
766
  catch (e) {
728
- return toolError(e.message);
767
+ return toolError(e);
729
768
  }
730
769
  });
731
770
  server.tool('ziggs_task_replace_plan', 'Replace the plan for a task with the full ordered step list you provide — existing steps are replaced wholesale, not appended to. Use this to post progress: resend the whole plan with completed steps marked in their descriptions.', {
@@ -743,7 +782,7 @@ export function registerZiggsTools(server, creds, cfg) {
743
782
  return textResult({ ok: true, task });
744
783
  }
745
784
  catch (e) {
746
- return toolError(e.message);
785
+ return toolError(e);
747
786
  }
748
787
  });
749
788
  server.tool('ziggs_task_list', 'List tasks reachable by this delegate agent (GET /tasks). Scope is determined by the operator key — same reach as chats and agreements. Supports optional state filter, cursor pagination, and assignee filtering.', {
@@ -768,7 +807,7 @@ export function registerZiggsTools(server, creds, cfg) {
768
807
  return textResult(result);
769
808
  }
770
809
  catch (e) {
771
- return toolError(e.message);
810
+ return toolError(e);
772
811
  }
773
812
  });
774
813
  server.tool('ziggs_task_get', 'Fetch a single task by id (GET /tasks/:id). Use this when a human hands you a taskId directly (e.g. "work on task_…") so you can read the work-order — its description, plan, assignee, state, and result — before acting. Same operator-key scope as ziggs_task_list; pairs with ziggs_task_set_result to close the task.', { taskId: z.string() }, READ_ONLY, async ({ taskId }) => {
@@ -779,7 +818,7 @@ export function registerZiggsTools(server, creds, cfg) {
779
818
  return textResult({ task });
780
819
  }
781
820
  catch (e) {
782
- return toolError(e.message);
821
+ return toolError(e);
783
822
  }
784
823
  });
785
824
  // ---------------------------------------------------------------------------
@@ -15,7 +15,7 @@ const DEFAULT_WEB_URL = 'https://ziggsai.com';
15
15
  export function registerTrustTools(server, creds, cfg) {
16
16
  const webUrl = cfg?.ZIGGS_WEB_URL?.replace(/\/$/, '') ?? DEFAULT_WEB_URL;
17
17
  registerCapabilities(server, DISCOVERY_CAPABILITIES, creds);
18
- server.tool('ziggs_context_issue_grant', 'Issue bounded context access. Chat scope: admits agent via POST /chats/:id/members (agent-invite → pending_approval until humans consent) this works for you as a delegate. Agreement/org scope: issuing a NEW root grant is a human-authority action; if you are acting for a principal you are denied (AGENT_LACKS_HUMAN_AUTHORITY) instead use ziggs_context_delegate to hand a peer a narrower slice of a grant you already hold, or ask your human to issue it. Artifact scope: hands over one specific artifact and nothing else; it is always from-start (the artifact already exists), and if YOU authored it use ziggs_artifact_share instead — that needs no human authority. Defaults: from-now, narrow scope.', {
18
+ server.tool('ziggs_context_issue_grant', 'Issue bounded context access. ONE scope works for you as a delegate: chat, which admits the agent via POST /chats/:id/members (agent-invite → pending_approval until humans consent). Agreement, org AND artifact scope all mint a NEW root grant, which is a human-authority action acting for a principal you are denied (AGENT_LACKS_HUMAN_AUTHORITY) on all three alike, before the scope is even read. Your paths instead: ziggs_artifact_share for an artifact YOU authored (no human authority needed), ziggs_context_delegate to hand a peer a narrower slice of a grant you already hold, or ask your human to issue it. Artifact scope is always from-start (the artifact predates any watermark you could set) and refuses from-now. Defaults: from-now, narrow scope.', {
19
19
  holderId: z.string().describe('Bare agent id receiving the grant'),
20
20
  scopeKind: grantScopeKindSchema,
21
21
  scopeId: z.string().describe('chatId, agreementId, orgId, or artifactId'),
@@ -85,7 +85,7 @@ export function registerTrustTools(server, creds, cfg) {
85
85
  });
86
86
  }
87
87
  catch (e) {
88
- return toolError(e.message);
88
+ return toolError(e);
89
89
  }
90
90
  });
91
91
  registerCapability(server, contextDelegateCapability, creds);
@@ -104,7 +104,7 @@ export function registerTrustTools(server, creds, cfg) {
104
104
  return textResult({ grantId, ...result });
105
105
  }
106
106
  catch (e) {
107
- return toolError(e.message);
107
+ return toolError(e);
108
108
  }
109
109
  });
110
110
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/ziggs-mcp",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "MCP server for Claude Code, Cursor, and other MCP hosts \u2014 act as your Ziggs delegate agent",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,12 +36,12 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@modelcontextprotocol/sdk": "^1.29.0",
39
- "@ziggs-ai/api-client": "^0.8.0",
39
+ "@ziggs-ai/api-client": "^0.9.0",
40
40
  "dotenv": "^16.6.1",
41
41
  "zod": "^3.24.2"
42
42
  },
43
43
  "devDependencies": {
44
- "@ziggs-ai/agent-sdk": "^0.9.0"
44
+ "@ziggs-ai/agent-sdk": "^0.10.0"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=20"
@@ -93,8 +93,8 @@ See [references/untrusted-input.md](references/untrusted-input.md).
93
93
 
94
94
  When coordinating with another org’s delegate:
95
95
 
96
- 1. Inbox → read new messages in the shared chat.
97
- 2. Reply with **`ziggs_chat_send`** or drive **`ziggs_agreement_propose`** / **`ziggs_agreement_respond`** as appropriate.
96
+ 1. Inbox → read new messages in the shared chat. Counterparties may appear as opaque `rpb_*` / `psn_*` presentation refs (plus a `presentation` face) — not as raw account ids.
97
+ 2. Reply with **`ziggs_chat_send`** (echo an `rpb_*` receiverId as-is; do not look it up, wake, or pay against it) or drive **`ziggs_agreement_propose`** / **`ziggs_agreement_respond`** as appropriate.
98
98
  3. If trust is missing, **`ziggs_agent_search`** → human picks counterparty → **`ziggs_context_issue_grant`** (with approval) before reading their context.
99
99
  4. Ack the handled envelope (`ackTo`) before ending the turn.
100
100