@ziggs-ai/api-client 0.6.3 → 0.8.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.
@@ -10,8 +10,8 @@ export const agreementClaimCapability = {
10
10
  key: 'agreement_claim',
11
11
  names: { sdk: 'agreement_claim', mcp: 'ziggs_agreement_claim' },
12
12
  descriptions: {
13
- sdk: 'Claim an open broadcast agreement by id — a quest (you do the work, the publisher pays), a standing offer (you buy, the publisher works), or a link invite (bilateral trust forms immediately). You become the open party and the agreement activates. Find quests/offers with marketplace_view; direct proposals are approved with agreement_respond instead, not claimed.',
14
- mcp: 'Claim an open broadcast agreement by id — a quest (you do the work, the publisher pays), a standing offer (you buy, the publisher works), or a link invite (bilateral trust forms immediately). You become the open party and the agreement activates. Find quests/offers with ziggs_marketplace_view; direct proposals are approved with ziggs_agreement_respond instead, not claimed. You cannot claim your own broadcast.',
13
+ sdk: 'Claim an open broadcast agreement by id — a quest (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 and the agreement activates. A hand-off is claimable only after its provider has accepted (409 until then). Find quests/offers with marketplace_view; direct proposals are approved with agreement_respond instead, not claimed.',
14
+ mcp: 'Claim an open broadcast agreement by id — a quest (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 and the agreement activates. A hand-off is claimable only after its provider has accepted (409 until then). Find quests/offers with ziggs_marketplace_view; direct proposals are approved with ziggs_agreement_respond instead, not claimed. You cannot claim your own broadcast.',
15
15
  },
16
16
  annotation: 'write',
17
17
  params: {
@@ -37,7 +37,9 @@ export const agreementClaimCapability = {
37
37
  kind,
38
38
  message: kind === 'offer'
39
39
  ? 'Standing offer claimed — the publisher provides, your side pays. Spawn work under it with the task-create tool.'
40
- : 'Quest claimed — you provide the work. Read the terms, then post progress and set the task result under this agreement.',
40
+ : kind === 'hand-off'
41
+ ? 'Hand-off claimed — the pinned agent works FOR you: you are the customer (and the payer when priced), never the worker. Spawn work under it with the task-create tool.'
42
+ : 'Quest claimed — you provide the work. Read the terms, then post progress and set the task result under this agreement.',
41
43
  agreement,
42
44
  };
43
45
  },
@@ -1,3 +1,31 @@
1
1
  import { type CapabilityDefinition } from './types.js';
2
2
  export declare const recordArtifactCapability: CapabilityDefinition;
3
+ /**
4
+ * ZIG-1037 — the artifacts you wrote, across every scope and none.
5
+ *
6
+ * The scope listings (`context_read` via chat / agreement / task) read attach
7
+ * tables, so a free-standing artifact appears in none of them. This is how an
8
+ * agent finds what it recorded without naming a container.
9
+ */
10
+ export declare const listArtifactsCapability: CapabilityDefinition;
11
+ /**
12
+ * ZIG-1037 — hand one artifact you authored to one other agent.
13
+ *
14
+ * Deliberately not part of `context_delegate`: there is no parent grant to
15
+ * attenuate here. An agent holds no grant over its own output — authorship is
16
+ * the authority, and it belongs to the agent's owner, which is why a receiver
17
+ * outside that owner's trust boundary needs their approval. What counts as
18
+ * "inside" is the shared new-party policy, not a local rule: same owner, same
19
+ * org, or an active agent link.
20
+ */
21
+ export declare const shareArtifactCapability: CapabilityDefinition;
22
+ /**
23
+ * ZIG-1037 — attach an artifact you already have to a chat or a task.
24
+ *
25
+ * The other half of "record now, decide where later". Attaching confers nothing
26
+ * by itself: it puts the artifact inside the container, and that container's
27
+ * audience can read it from then on. Use this to publish to a room or bind a
28
+ * deliverable to a task; use artifact_share to hand it to ONE agent instead.
29
+ */
30
+ export declare const attachArtifactCapability: CapabilityDefinition;
3
31
  export declare const ARTIFACT_CAPABILITIES: CapabilityDefinition[];
@@ -1,4 +1,5 @@
1
1
  import { ArtifactsClient } from '../http/ArtifactsClient.js';
2
+ import { ContextGrantsClient } from '../http/ContextGrantsClient.js';
2
3
  import { fullCreds } from './types.js';
3
4
  /**
4
5
  * ZIG-560 teaching: name the result slot on the success path so an agent finds
@@ -20,8 +21,14 @@ export const recordArtifactCapability = {
20
21
  key: 'artifact_record',
21
22
  names: { sdk: 'artifact_record', mcp: 'ziggs_artifact_record' },
22
23
  descriptions: {
23
- sdk: 'Write an artifact to a chat or agreement scope. Set visibility explicitly. For a finished deliverable, set content_type=result and pass taskId to bind it to the task — heavy results belong in artifacts, not chat messages.',
24
- mcp: 'Write an artifact to a chat or agreement scope. Set visibility explicitly. ' +
24
+ sdk: 'Write an artifact. Scope is optional: pass agreementId or chatId to record it there, ' +
25
+ 'or pass no scope at all to record it as yours alone and attach it somewhere later. ' +
26
+ 'Set visibility explicitly. For a finished deliverable, set content_type=result and pass ' +
27
+ 'taskId to bind it to the task — heavy results belong in artifacts, not chat messages.',
28
+ mcp: 'Write an artifact. Scope is optional — pass agreementId or chatId to record it into that ' +
29
+ 'scope, pass taskId alone to bind a deliverable to its task, or pass no scope at all for a ' +
30
+ 'free-standing artifact that is yours until you attach or share it. Never guess a scope: ' +
31
+ 'recording with none always succeeds. Set visibility explicitly. ' +
25
32
  'For a finished deliverable, set content_type=result and pass taskId to bind it to the task. ' +
26
33
  'Finished work is the task result — set it with ziggs_task_set_result; never report finished work as a chat message (chat is conversation only).',
27
34
  },
@@ -32,16 +39,17 @@ export const recordArtifactCapability = {
32
39
  type: 'string',
33
40
  required: true,
34
41
  enum: ['chat', 'agent-private'],
35
- description: 'chat = visible to scope parties; agent-private = your eyes only',
42
+ description: 'chat = the scope’s parties can read it; agent-private = not shared with them. ' +
43
+ 'Neither is permanent: you can share any artifact later with a specific agent.',
36
44
  },
37
- chatId: { type: 'string', description: 'Target chat scope' },
45
+ chatId: { type: 'string', description: 'Optional chat scope' },
38
46
  agreementId: {
39
47
  type: 'string',
40
- description: 'Target agreement scope — for a hire deliverable, prefer this. When both chatId and agreementId are passed, agreementId wins.',
48
+ description: 'Optional agreement scope — for a hire deliverable, prefer this. When both chatId and agreementId are passed, agreementId wins.',
41
49
  },
42
50
  taskId: {
43
51
  type: 'string',
44
- description: 'Optional task — creates a TaskArtifactLink alongside the primary scope link',
52
+ description: 'Optional task binding. Valid on its own — a task-bound deliverable needs no chat or agreement.',
45
53
  },
46
54
  content_type: {
47
55
  type: 'string',
@@ -58,12 +66,13 @@ export const recordArtifactCapability = {
58
66
  // Agreement wins when both scopes arrive. Models naturally pass the chat
59
67
  // they are standing in alongside the agreement they deliver under; a hard
60
68
  // xor here failed the deliverable at the last step of a finished task
61
- // (ZIG-924 dogfood). The backend still enforces exactly-one — we resolve
62
- // the ambiguity at the exposed surface instead of erroring.
69
+ // (ZIG-924 dogfood). The backend accepts at most one — we resolve the
70
+ // ambiguity at the exposed surface instead of erroring.
63
71
  const chatId = agreementId ? undefined : args['chatId'];
64
- if (!chatId && !agreementId) {
65
- throw new Error('Pass chatId or agreementId');
66
- }
72
+ // ZIG-1037: no scope is legal. The throw that used to live here ("Pass
73
+ // chatId or agreementId") is the exact failure this ticket removed — it cost
74
+ // a live agent a turn mid-delivery for naming no container, when the record
75
+ // itself never needed one.
67
76
  const visibility = args['visibility'];
68
77
  if (visibility !== 'chat' && visibility !== 'agent-private') {
69
78
  throw new Error('visibility must be chat or agent-private');
@@ -91,4 +100,177 @@ export const recordArtifactCapability = {
91
100
  };
92
101
  },
93
102
  };
94
- export const ARTIFACT_CAPABILITIES = [recordArtifactCapability];
103
+ /**
104
+ * ZIG-1037 — the artifacts you wrote, across every scope and none.
105
+ *
106
+ * The scope listings (`context_read` via chat / agreement / task) read attach
107
+ * tables, so a free-standing artifact appears in none of them. This is how an
108
+ * agent finds what it recorded without naming a container.
109
+ */
110
+ export const listArtifactsCapability = {
111
+ key: 'artifact_list',
112
+ names: { sdk: 'artifact_list', mcp: 'ziggs_artifact_list' },
113
+ descriptions: {
114
+ sdk: 'List artifacts you authored, in any scope or none — including free-standing ones no scope read can reach. Forward-delta with `after`.',
115
+ mcp: 'List artifacts YOU authored, across every scope and none. Use this to find something you ' +
116
+ 'recorded free-standing (no chat/agreement/task), which the scope reads cannot return. ' +
117
+ 'To read an artifact someone shared WITH you, use ziggs_context_read with via=artifact:<id>; ' +
118
+ 'to see what has been shared with you, use ziggs_grant_list with scopeKind=artifact.',
119
+ },
120
+ annotation: 'read-only',
121
+ params: {
122
+ after: {
123
+ type: 'string',
124
+ description: 'ISO timestamp — return only artifacts written strictly after this',
125
+ },
126
+ limit: { type: 'number', description: 'Page size (server default when omitted)' },
127
+ },
128
+ needsAgentId: true,
129
+ handler: async (args, env) => {
130
+ const creds = fullCreds(env);
131
+ return new ArtifactsClient(creds.operatorKey, creds.agentId).list({ authoredBy: 'me' }, {
132
+ after: args['after'],
133
+ limit: typeof args['limit'] === 'number' ? args['limit'] : undefined,
134
+ });
135
+ },
136
+ };
137
+ /**
138
+ * ZIG-1037 — hand one artifact you authored to one other agent.
139
+ *
140
+ * Deliberately not part of `context_delegate`: there is no parent grant to
141
+ * attenuate here. An agent holds no grant over its own output — authorship is
142
+ * the authority, and it belongs to the agent's owner, which is why a receiver
143
+ * outside that owner's trust boundary needs their approval. What counts as
144
+ * "inside" is the shared new-party policy, not a local rule: same owner, same
145
+ * org, or an active agent link.
146
+ */
147
+ export const shareArtifactCapability = {
148
+ key: 'artifact_share',
149
+ names: { sdk: 'artifact_share', mcp: 'ziggs_artifact_share' },
150
+ descriptions: {
151
+ sdk: 'Share an artifact you authored with another agent — just that artifact, no chat or agreement. An agent already connected to you (same owner, same org, or an active link) gets it immediately; anyone else is offered to your owner to approve.',
152
+ mcp: 'Share ONE artifact you authored with another agent, without sharing any chat or agreement ' +
153
+ 'it sits in. Only the authoring agent can call this. A receiver already connected to you — ' +
154
+ 'same owner, same org, or an active link — is granted immediately. Any other agent is ' +
155
+ 'offered to your owner to approve, and the reply is { status: "pending_approval", ' +
156
+ 'agreementId } — nothing is shared until they do. Pass chatId to have that approval appear ' +
157
+ 'in the chat you are working in. The receiver then reads it with ziggs_context_read ' +
158
+ 'via=artifact:<id>.',
159
+ },
160
+ annotation: 'write',
161
+ params: {
162
+ artifactId: {
163
+ type: 'string',
164
+ required: true,
165
+ description: 'The artifact to share — you must be its author',
166
+ },
167
+ holderId: { type: 'string', required: true, description: 'Agent id that receives it' },
168
+ expiresAt: {
169
+ type: 'string',
170
+ description: 'ISO-8601 expiry; omit for the default grant lifetime',
171
+ },
172
+ chatId: {
173
+ type: 'string',
174
+ description: 'Chat to surface the approval request in, when the receiver’s owner has to approve',
175
+ },
176
+ },
177
+ needsAgentId: true,
178
+ handler: async (args, env) => {
179
+ const creds = fullCreds(env);
180
+ const artifactId = args['artifactId'];
181
+ if (!artifactId)
182
+ throw new Error('artifactId is required');
183
+ const holderId = args['holderId'];
184
+ if (!holderId)
185
+ throw new Error('holderId is required');
186
+ const result = await new ContextGrantsClient(creds.operatorKey, creds.agentId, env.baseUrl).shareArtifact(artifactId, {
187
+ holderId,
188
+ expiresAt: args['expiresAt'],
189
+ chatId: args['chatId'],
190
+ });
191
+ if (result.status === 'pending_approval') {
192
+ return {
193
+ ok: true,
194
+ ...result,
195
+ note: `Not shared yet — ${result.ownerId ?? 'the owner'} must approve agreement ${result.agreementId}.`,
196
+ };
197
+ }
198
+ return {
199
+ ok: true,
200
+ ...result,
201
+ note: `${holderId} can now read artifact ${artifactId} with via=artifact:${artifactId}.`,
202
+ };
203
+ },
204
+ };
205
+ /**
206
+ * ZIG-1037 — attach an artifact you already have to a chat or a task.
207
+ *
208
+ * The other half of "record now, decide where later". Attaching confers nothing
209
+ * by itself: it puts the artifact inside the container, and that container's
210
+ * audience can read it from then on. Use this to publish to a room or bind a
211
+ * deliverable to a task; use artifact_share to hand it to ONE agent instead.
212
+ */
213
+ export const attachArtifactCapability = {
214
+ key: 'artifact_attach',
215
+ names: { sdk: 'artifact_attach', mcp: 'ziggs_artifact_attach' },
216
+ descriptions: {
217
+ sdk: 'Attach an artifact you can read to a chat or a task. Everyone in that chat / party to that task can read it from then on. Use artifact_share to give it to one specific agent instead.',
218
+ mcp: 'Attach an existing artifact to a chat or a task — the way a free-standing artifact (one you ' +
219
+ 'recorded with no scope) reaches an audience. Pass exactly one of chatId or taskId. Everyone ' +
220
+ 'in that chat, or party to that task, can read it from then on; attaching gives you nothing ' +
221
+ 'new yourself. You must already be able to read the artifact AND belong to the container. ' +
222
+ 'To hand it to ONE specific agent without opening a chat, use ziggs_artifact_share instead.',
223
+ },
224
+ annotation: 'write',
225
+ params: {
226
+ artifactId: { type: 'string', required: true, description: 'Artifact to attach' },
227
+ chatId: { type: 'string', description: 'Chat to attach it to' },
228
+ taskId: { type: 'string', description: 'Task to attach it to' },
229
+ role: {
230
+ type: 'string',
231
+ enum: ['input', 'output'],
232
+ description: 'Task attach only: output (a deliverable, default) or input (something the task consumes). ' +
233
+ 'A link’s role is set once — re-attaching with a different role is refused, not changed.',
234
+ },
235
+ },
236
+ needsAgentId: true,
237
+ handler: async (args, env) => {
238
+ const artifactId = args['artifactId'];
239
+ if (!artifactId)
240
+ throw new Error('artifactId is required');
241
+ const chatId = args['chatId'];
242
+ const taskId = args['taskId'];
243
+ if (!!chatId === !!taskId) {
244
+ throw new Error('Pass exactly one of chatId or taskId');
245
+ }
246
+ const role = args['role'] ?? 'output';
247
+ if (role !== 'input' && role !== 'output') {
248
+ throw new Error('role must be input or output');
249
+ }
250
+ const creds = fullCreds(env);
251
+ const client = new ArtifactsClient(creds.operatorKey, creds.agentId);
252
+ if (chatId) {
253
+ await client.attachToChat(artifactId, chatId);
254
+ return {
255
+ ok: true,
256
+ artifactId,
257
+ chatId,
258
+ note: `Everyone in chat ${chatId} can now read artifact ${artifactId}.`,
259
+ };
260
+ }
261
+ await client.attachToTask(artifactId, taskId, role);
262
+ return {
263
+ ok: true,
264
+ artifactId,
265
+ taskId,
266
+ role,
267
+ note: `Artifact ${artifactId} is attached to task ${taskId} as ${role}; the task's parties can read it.`,
268
+ };
269
+ },
270
+ };
271
+ export const ARTIFACT_CAPABILITIES = [
272
+ recordArtifactCapability,
273
+ listArtifactsCapability,
274
+ shareArtifactCapability,
275
+ attachArtifactCapability,
276
+ ];
@@ -1,7 +1,7 @@
1
1
  import { ContextReadClient } from '../http/ContextReadClient.js';
2
2
  import { ContextGrantsClient, } from '../http/ContextGrantsClient.js';
3
3
  import { ContextDiscoveryClient } from '../http/ContextDiscoveryClient.js';
4
- import { grantCaveat } from '../http/grants.js';
4
+ import { grantCaveat, CONTEXT_GRANT_SCOPE_KINDS, } from '../http/grants.js';
5
5
  import { fetchMyOrgs, resolveOrgSelector } from '../http/OrgsClient.js';
6
6
  import { fullCreds } from './types.js';
7
7
  const CONTEXT_READ_TYPES = [
@@ -10,7 +10,10 @@ const CONTEXT_READ_TYPES = [
10
10
  'agreements',
11
11
  'tasks',
12
12
  ];
13
- const GRANT_SCOPE_KINDS = ['chat', 'agreement', 'org'];
13
+ // ZIG-1037: `artifact` joined the context rail. Delegating an artifact grant
14
+ // onward works (same kind, same id — an exact re-grant); narrowing a container
15
+ // grant DOWN to an artifact inside it is deliberately not supported yet.
16
+ const GRANT_SCOPE_KINDS = CONTEXT_GRANT_SCOPE_KINDS;
14
17
  const CONTEXT_TEMPORALS = ['from-now', 'from-start'];
15
18
  /**
16
19
  * Human/LLM-readable bounds for a context grant. ZIG-646 folded a context
@@ -50,8 +53,8 @@ export const contextReadCapability = {
50
53
  key: 'context_read',
51
54
  names: { sdk: 'context_read', mcp: 'ziggs_context_read' },
52
55
  descriptions: {
53
- sdk: 'Read the contents of a scope you already hold a grant for: messages | artifacts | agreements | tasks. Use grant_list first to see which scopes your grants cover, then read through any of them. Cursored; all access is grant-fenced server-side.',
54
- mcp: 'Read the contents of a scope you already hold: messages | artifacts | agreements | tasks (the type param), under via=chat:<id>, agreement:<id>, or task:<id>. Forward-delta with after+direction=forward; cursor pagination; contextGrantId pins a grant. The response carries a `readPlan` with the next page and/or forward-delta call pre-filled (after=this page\'s latestSequence), so you can keep reading without rebuilding args. This is the single read path for all four types — to discover which scopes exist, use the listers: ziggs_chat_list, ziggs_task_list, ziggs_agreement_list, ziggs_grant_list, ziggs_link_list.',
56
+ sdk: 'Read the contents of a scope you already hold a grant for: messages | artifacts | agreements | tasks. Use grant_list first to see which scopes your grants cover, then read through any of them. For artifacts you can also name one directly with via=artifact:<id> — the only way to read an artifact attached to no chat, agreement or task. Cursored; all access is grant-fenced server-side.',
57
+ mcp: 'Read the contents of a scope you already hold: messages | artifacts | agreements | tasks (the type param), under via=chat:<id>, agreement:<id>, or task:<id> — plus via=artifact:<id> for artifacts, a point read of one named artifact and the ONLY way to read one that is attached to no container (e.g. an artifact someone shared with you; ziggs_grant_list scopeKind=artifact shows those). Forward-delta with after+direction=forward; cursor pagination; contextGrantId pins a grant. The response carries a `readPlan` with the next page and/or forward-delta call pre-filled (after=this page\'s latestSequence), so you can keep reading without rebuilding args. This is the single read path for all four types — to discover which scopes exist, use the listers: ziggs_chat_list, ziggs_task_list, ziggs_agreement_list, ziggs_grant_list, ziggs_link_list.',
55
58
  },
56
59
  annotation: 'read-only',
57
60
  params: {
@@ -64,7 +67,7 @@ export const contextReadCapability = {
64
67
  via: {
65
68
  type: 'string',
66
69
  required: true,
67
- description: 'Scope entry you hold, e.g. chat:<id>, agreement:<id>, task:<id>',
70
+ description: 'Scope entry you hold, e.g. chat:<id>, agreement:<id>, task:<id>. For type=artifacts also artifact:<id> to read that one artifact.',
68
71
  },
69
72
  cursor: { type: 'string', description: 'Opaque cursor from a prior nextCursor to page' },
70
73
  after: { type: 'string', description: 'ISO timestamp for forward-delta (messages/artifacts)' },
@@ -109,8 +112,8 @@ export const contextExpandReachCapability = {
109
112
  key: 'context_expand_reach',
110
113
  names: { sdk: 'context_expand_reach', mcp: 'ziggs_context_expand_reach' },
111
114
  descriptions: {
112
- sdk: 'Expand a grant you hold into the chat/agreement ids inside its scope, so you can read through it. grant_list tells you that you hold e.g. org:acme or agreement:x; this returns the actual { chats, agreements } (ids + labels only, no content) that scope covers — feed an id to context_read (via=chat:<id> / agreement:<id>). Org scope is capped: truncatedChats/truncatedAgreements say how many were left off. Holder-only, grant-fenced.',
113
- mcp: 'Expand a grant you hold into the chat/agreement ids inside its scope, so you can actually read through it. ziggs_grant_list tells you that you hold e.g. org:acme or agreement:x; this returns the { chats, agreements } (ids + labels only, never content) that scope covers — feed an id to ziggs_context_read (via=chat:<id> / agreement:<id>). Org scope is capped: truncatedChats/truncatedAgreements say how many were left off. Holder-only, grant-fenced.',
115
+ sdk: 'Expand a grant you hold into the chat/agreement ids inside its scope, so you can read through it. grant_list tells you that you hold e.g. org:acme or agreement:x; this returns the actual { chats, agreements } (ids + labels only, no content) that scope covers — feed an id to context_read (via=chat:<id> / agreement:<id>). Org scope is capped: truncatedChats/truncatedAgreements say how many were left off. An artifact-scope grant returns empty by design — the grant IS one artifact, so read it directly with context_read via=artifact:<the scope id>. Holder-only, grant-fenced.',
116
+ mcp: 'Expand a grant you hold into the chat/agreement ids inside its scope, so you can actually read through it. ziggs_grant_list tells you that you hold e.g. org:acme or agreement:x; this returns the { chats, agreements } (ids + labels only, never content) that scope covers — feed an id to ziggs_context_read (via=chat:<id> / agreement:<id>). Org scope is capped: truncatedChats/truncatedAgreements say how many were left off. An artifact-scope grant returns empty by design — that grant IS a single artifact, so do not read the empty map as a dead grant: read the artifact with ziggs_context_read via=artifact:<the scope id>. Holder-only, grant-fenced.',
114
117
  },
115
118
  annotation: 'read-only',
116
119
  params: {
@@ -179,7 +182,7 @@ export const contextDelegateCapability = {
179
182
  }
180
183
  const scopeKind = args['scopeKind'];
181
184
  if (!GRANT_SCOPE_KINDS.includes(scopeKind)) {
182
- throw new Error('scopeKind must be chat, agreement, or org');
185
+ throw new Error(`scopeKind must be one of ${GRANT_SCOPE_KINDS.join(', ')}`);
183
186
  }
184
187
  // ZIG-941 #6 — an org scope may be named rather than pasted as org_… id.
185
188
  let scopeId = args['scopeId'];
@@ -1,7 +1,7 @@
1
1
  import { type CapabilityDefinition } from './types.js';
2
2
  /**
3
3
  * ZIG-893 — the single "what authority do I hold?" tool. One name, every rail
4
- * (context chat/agreement/org, connection, wallet), holder-scoped,
4
+ * (context chat/agreement/org/artifact, connection, wallet), holder-scoped,
5
5
  * cross-session. `unreadableRails` comes from the backend (ZIG-956) so a short
6
6
  * list is never presented as complete when the key can't read a rail.
7
7
  */
@@ -1,12 +1,11 @@
1
1
  import { GrantsClient } from '../http/GrantsClient.js';
2
+ import { GRANT_SCOPE_KINDS, } from '../http/grants.js';
2
3
  import { fullCreds } from './types.js';
3
- const SCOPE_KINDS = [
4
- 'chat',
5
- 'agreement',
6
- 'org',
7
- 'connection',
8
- 'wallet',
9
- ];
4
+ // BOTH the tool's param enum and its validator, so a missing kind makes the
5
+ // filter the description advertises fail validation. Derived from the canonical
6
+ // list next to the type rather than restated, because that is exactly how
7
+ // `artifact` came to be advertised but not accepted.
8
+ const SCOPE_KINDS = GRANT_SCOPE_KINDS;
10
9
  const HEALTHS = ['active', 'expired', 'revoked'];
11
10
  function parseScopeKinds(raw) {
12
11
  if (raw == null)
@@ -24,7 +23,7 @@ function parseScopeKinds(raw) {
24
23
  }
25
24
  /**
26
25
  * ZIG-893 — the single "what authority do I hold?" tool. One name, every rail
27
- * (context chat/agreement/org, connection, wallet), holder-scoped,
26
+ * (context chat/agreement/org/artifact, connection, wallet), holder-scoped,
28
27
  * cross-session. `unreadableRails` comes from the backend (ZIG-956) so a short
29
28
  * list is never presented as complete when the key can't read a rail.
30
29
  */
@@ -32,15 +31,15 @@ export const listGrantsCapability = {
32
31
  key: 'grant_list',
33
32
  names: { sdk: 'grant_list', mcp: 'ziggs_grant_list' },
34
33
  descriptions: {
35
- sdk: 'List every grant this agent holds across all rails in one call — context (chat/agreement/org), connection, and wallet — as canonical grants (grantId, scope, caveats, expiresAt, health; no message/artifact content or credentials). The single answer to "what grants of mine do you hold?", holder-scoped and cross-session. Filter by scopeKind (rail) and health (defaults to active). Rails you lack the operator-key read scope for are named in unreadableRails, not silently dropped. Cursor-paginated: pass cursor from a prior nextCursor. Pair with context_read to read through a context grant, or context_expand_reach to enumerate a scope.',
36
- mcp: 'List every grant this delegate holds across all rails in one call — context (chat/agreement/org), connection, and wallet — as canonical grants (grantId, scope, caveats, expiresAt, health; no content or credentials). The single answer to "what grants of mine do you hold?", holder-scoped and cross-session. Filter by scopeKind (rail) and health (defaults to active). Rails you lack the operator-key read scope for are named in unreadableRails, not silently dropped. Cursor-paginated: pass cursor from a prior nextCursor to page. Pass a grantId to ziggs_context_read to pin a specific grant, or ziggs_context_expand_reach to enumerate a scope.',
34
+ sdk: 'List every grant this agent holds across all rails in one call — context (chat/agreement/org/artifact), connection, and wallet — as canonical grants (grantId, scope, caveats, expiresAt, health; no message/artifact content or credentials). The single answer to "what grants of mine do you hold?", holder-scoped and cross-session. Filter by scopeKind (rail) and health (defaults to active). Rails you lack the operator-key read scope for are named in unreadableRails, not silently dropped. Cursor-paginated: pass cursor from a prior nextCursor. Pair with context_read to read through a context grant, or context_expand_reach to enumerate a scope.',
35
+ mcp: 'List every grant this delegate holds across all rails in one call — context (chat/agreement/org/artifact), connection, and wallet — as canonical grants (grantId, scope, caveats, expiresAt, health; no content or credentials). The single answer to "what grants of mine do you hold?", holder-scoped and cross-session. Filter by scopeKind (rail) and health (defaults to active). Rails you lack the operator-key read scope for are named in unreadableRails, not silently dropped. Cursor-paginated: pass cursor from a prior nextCursor to page. Pass a grantId to ziggs_context_read to pin a specific grant, or ziggs_context_expand_reach to enumerate a scope.',
37
36
  },
38
37
  annotation: 'read-only',
39
38
  params: {
40
39
  scopeKind: {
41
40
  type: 'array',
42
41
  items: { type: 'string', enum: SCOPE_KINDS },
43
- description: 'Rail filter (repeatable): chat | agreement | org (context) | connection | wallet. Omit for every rail you can read.',
42
+ description: 'Rail filter (repeatable): chat | agreement | org | artifact (context) | connection | wallet. Omit for every rail you can read.',
44
43
  },
45
44
  health: {
46
45
  type: 'string',
@@ -7,5 +7,5 @@ export { GRANTS_CAPABILITIES, listGrantsCapability } from './grants.js';
7
7
  export { CONTEXT_CAPABILITIES, contextReadCapability, contextDelegateCapability, contextExpandReachCapability, contextDiscoverGrantableCapability, contextBounds, resolveOrgScopeId, } from './context.js';
8
8
  export { CONNECTION_CAPABILITIES, connectionProxyCapability, requestConnectionCapability, } from './connections.js';
9
9
  export { DISCOVERY_CAPABILITIES, agentSearchCapability, agentGetCapability, } from './discovery.js';
10
- export { ARTIFACT_CAPABILITIES, recordArtifactCapability } from './artifacts.js';
10
+ export { ARTIFACT_CAPABILITIES, recordArtifactCapability, listArtifactsCapability, shareArtifactCapability, attachArtifactCapability, } from './artifacts.js';
11
11
  export { CHAT_CAPABILITIES, openConversationCapability } from './chat.js';
@@ -7,5 +7,5 @@ export { GRANTS_CAPABILITIES, listGrantsCapability } from './grants.js';
7
7
  export { CONTEXT_CAPABILITIES, contextReadCapability, contextDelegateCapability, contextExpandReachCapability, contextDiscoverGrantableCapability, contextBounds, resolveOrgScopeId, } from './context.js';
8
8
  export { CONNECTION_CAPABILITIES, connectionProxyCapability, requestConnectionCapability, } from './connections.js';
9
9
  export { DISCOVERY_CAPABILITIES, agentSearchCapability, agentGetCapability, } from './discovery.js';
10
- export { ARTIFACT_CAPABILITIES, recordArtifactCapability } from './artifacts.js';
10
+ export { ARTIFACT_CAPABILITIES, recordArtifactCapability, listArtifactsCapability, shareArtifactCapability, attachArtifactCapability, } from './artifacts.js';
11
11
  export { CHAT_CAPABILITIES, openConversationCapability } from './chat.js';
@@ -1,53 +1,22 @@
1
1
  import { createAgreement, listAgreements } from '../http/AgreementClient.js';
2
2
  import { fullCreds } from './types.js';
3
3
  const DEFAULT_WEB_URL = 'https://ziggsai.com';
4
- /** Public Streamable-HTTP MCP endpoint; OAuth is discovered from it (RFC 9728). */
5
- const ZIGGS_MCP_URL = 'https://mcp.ziggsai.com/mcp';
6
4
  function webAppOrigin(env) {
7
5
  return (env.webUrl?.trim() || DEFAULT_WEB_URL).replace(/\/$/, '');
8
6
  }
9
7
  /**
10
- * The second form an invite travels in: text the recipient pastes into their
11
- * own assistant, which then connects itself and claims the invite. The claim
12
- * URL alone only helps someone who already has Ziggs and an assistant wired
13
- * up, so hand the caller both and let it pick per recipient.
8
+ * The one thing an invite travels as: `/connect/<inviteId>`.
14
9
  *
15
- * The account question comes FIRST, and the answer is always the claim URL —
16
- * never 'Create account' on the MCP consent screen. Signup is invite-gated and
17
- * only the claim URL carries the invite that waives it; the consent screen's
18
- * signup button carries nothing, and nothing in an MCP OAuth handshake can
19
- * carry it for them (the client builds its authorize request from RFC 8414/9728
20
- * discovery, so a query param on the pasted server URL never reaches us). So a
21
- * recipient new to Ziggs who lets their assistant lead used to hit a hard 403
22
- * with no way back. Ordering is the whole fix: the URL both signs them up AND
23
- * claims, leaving the assistant less to do, not more.
24
- *
25
- * Keep in step with the web app's copy of this text (frontend
26
- * src/lib/links/inviteShare.ts) — same instructions, two surfaces.
10
+ * There used to be two forms — this URL plus a block of instructions to paste
11
+ * into the recipient's assistant — and the caller had to explain which was
12
+ * which and pick per recipient. That block now lives on the page itself, which
13
+ * is public and server-rendered: a person reads it, and an assistant handed the
14
+ * same URL fetches it and finds the MCP server address, the claim tool and the
15
+ * invite id in the markup. One link, both readers, nothing to keep in step
16
+ * across two repos.
27
17
  */
28
- function invitePasteText(agreementId, claimUrl) {
29
- return [
30
- 'Connect me to Ziggs and accept this agent link invite.',
31
- '',
32
- 'First ask me: do I already have a Ziggs account?',
33
- '',
34
- ` If no, or I am not sure — open this in a browser: ${claimUrl}`,
35
- ' That creates my account and accepts the link in one step, and needs no',
36
- " invite code. Do NOT send me to 'Create account' on the Ziggs consent",
37
- ' screen instead — signup there is gated and will reject me. Once I am',
38
- ' back, continue from step 1 to connect yourself; step 2 is already done.',
39
- '',
40
- ' If yes — start at step 1.',
41
- '',
42
- `1. Add this MCP server: ${ZIGGS_MCP_URL}`,
43
- ' It speaks Streamable HTTP and uses OAuth — pasting the URL is enough,',
44
- ' but I may need to approve a consent screen in my browser.',
45
- `2. Once connected, call the tool ziggs_agreement_claim with agreementId "${agreementId}".`,
46
- '3. Then tell me who I am linked with, and what they can and cannot see.',
47
- '',
48
- 'If you cannot add MCP servers yourself, tell me exactly where to paste that',
49
- "URL in my assistant's settings, then continue from step 2.",
50
- ].join('\n');
18
+ function inviteShareUrl(env, agreementId) {
19
+ return `${webAppOrigin(env)}/connect/${agreementId}`;
51
20
  }
52
21
  /**
53
22
  * ZIG-670 — link-shaped summaries, not raw agreement documents: the money
@@ -101,8 +70,8 @@ export const createLinkInviteCapability = {
101
70
  key: 'link_create_invite',
102
71
  names: { sdk: 'link_create_invite', mcp: 'ziggs_link_create_invite' },
103
72
  descriptions: {
104
- sdk: "Create a shareable OPEN link invite (bilateral agent-to-agent trust) when you do NOT have the counterparty's agent id (e.g. connecting across orgs). Creates an open link agreement proposed to everyone; the recipient forms the link by claiming the returned inviteId with agreement_claim. Set maxClaims to let several people claim the same link — each gets their own separate connection. When you DO have the agent id, propose the link directly instead: agreement_propose with engagementKind \"link\" and proposedTo = that id.",
105
- mcp: 'Create a shareable OPEN link invite (bilateral agent-to-agent trust, NOT a third-party service connection — see ziggs_connection_list for that) when you do NOT have the counterparty\'s agent id (e.g. connecting across orgs). Creates an open link agreement (POST /agreements {engagementKind:"link"}, proposedTo:"everyone"). Share the returned inviteId (agreementId) out-of-band; the recipient forms the link by calling ziggs_agreement_claim — neither side pastes an agent id. Set maxClaims to share ONE link with several people; each claimer gets their own separate connection, not a group. Revoke via ziggs_agreement_revoke to disable. When you DO have the agent id, propose the link directly instead: ziggs_agreement_propose with engagementKind "link" and proposedTo = that id.',
73
+ sdk: "Create a shareable OPEN link invite (bilateral agent-to-agent trust) when you do NOT have the counterparty's agent id (e.g. connecting across orgs). Creates an open link agreement proposed to everyone and returns shareUrl — one public page that is the entire invite, for a person or for their assistant. Set maxClaims to let several people claim the same link — each gets their own separate connection. When you DO have the agent id, propose the link directly instead: agreement_propose with engagementKind \"link\" and proposedTo = that id.",
74
+ mcp: 'Create a shareable OPEN link invite (bilateral agent-to-agent trust, NOT a third-party service connection — see ziggs_connection_list for that) when you do NOT have the counterparty\'s agent id (e.g. connecting across orgs). Creates an open link agreement (POST /agreements {engagementKind:"link"}, proposedTo:"everyone") and returns shareUrl: one public page that is the entire invite — the recipient accepts from it with no account, and their assistant can read the connect instructions off the same URL. Give the human that link and nothing else. Set maxClaims to share ONE link with several people; each claimer gets their own separate connection, not a group. Revoke via ziggs_agreement_revoke to disable. When you DO have the agent id, propose the link directly instead: ziggs_agreement_propose with engagementKind "link" and proposedTo = that id.',
106
75
  },
107
76
  annotation: 'write',
108
77
  params: {
@@ -123,7 +92,7 @@ export const createLinkInviteCapability = {
123
92
  description: args['message'],
124
93
  ...(maxClaims == null ? {} : { maxClaims }),
125
94
  }, fullCreds(env));
126
- const claimUrl = `${webAppOrigin(env)}/app/link-invites/${agreement.agreementId}`;
95
+ const shareUrl = inviteShareUrl(env, agreement.agreementId);
127
96
  const seats = agreement.linkInvite?.maxClaims ?? 1;
128
97
  const seatNote = seats > 1
129
98
  ? `valid 7 days and claimable by up to ${seats} people (each gets their own separate connection — not a group)`
@@ -131,13 +100,12 @@ export const createLinkInviteCapability = {
131
100
  return {
132
101
  status: 'open',
133
102
  inviteId: agreement.agreementId,
134
- claimUrl,
103
+ shareUrl,
135
104
  maxClaims: seats,
136
105
  seatsRemaining: seats - (agreement.linkInvite?.claimsUsed ?? 0),
137
- pasteText: invitePasteText(agreement.agreementId, claimUrl),
138
106
  message: env.surface === 'mcp'
139
- ? `Open link invite created, ${seatNote}. Give the human BOTH forms and say which is which: claimUrl is the one that always works — a recipient with no Ziggs account signs up straight from it, no beta code needed, and it accepts the link in the same step. pasteText is for a recipient who would rather their own assistant do the wiring; it starts by sending them to claimUrl if they have no account yet, since signup on the MCP consent screen is gated and would reject them. Either way the recipient needs their own assistant connected before the link carries anything. No agent id needed on either side.`
140
- : `Open link invite created, ${seatNote}. claimUrl is the one that always works — a recipient with no Ziggs account signs up straight from it, no beta code needed, and it accepts the link in the same step. pasteText is for a recipient whose own assistant does the wiring; it sends them to claimUrl first if they have no account, since signup on the consent screen is gated. Either way they need an assistant connected before the link carries anything. No agent id needed on either side. Revoke with agreement_revoke to disable.`,
107
+ ? `Open link invite created, ${seatNote}. Give the human shareUrl and nothing else — it is the whole invite. A recipient with no Ziggs account signs up straight from that page, no beta code needed, and accepting the link is part of the same step; a recipient who would rather their own assistant do the wiring can hand it the same URL, because the page carries the MCP server address and the claim instructions in its markup. Either way the recipient needs their own assistant connected before the link carries anything.`
108
+ : `Open link invite created, ${seatNote}. shareUrl is the whole invite: a recipient with no Ziggs account signs up straight from that page and accepts the link in the same step, and an assistant handed the same URL reads the connect instructions off it. They still need an assistant connected before the link carries anything. No agent id needed on either side. Revoke with agreement_revoke to disable.`,
141
109
  agreement: linkSummary(agreement),
142
110
  };
143
111
  },
@@ -1,7 +1,8 @@
1
1
  import 'dotenv/config';
2
2
  import { runtimeLog } from '../shared/runtimeLog.js';
3
3
  import { getBackendUrl } from '../utils/urlUtils.js';
4
- import { OPEN_AGREEMENT_TARGET, ORG_AGREEMENT_TARGET, isBroadcastTarget, ApiError } from '../types.js';
4
+ import { OPEN_AGREEMENT_TARGET, ORG_AGREEMENT_TARGET, isBroadcastTarget } from '../types.js';
5
+ import { throwApiError } from '../shared/apiError.js';
5
6
  // Lazy: read at call time so dotenv loaded after this module is imported
6
7
  // still takes effect. Baking it at module-load time would freeze the URL
7
8
  // before the caller's dotenv.config() runs.
@@ -21,20 +22,6 @@ function assertCreds(creds, op) {
21
22
  if (!creds?.agentId)
22
23
  throw new Error(`agentId is required for ${op}`);
23
24
  }
24
- function parseErrorMessage(responseBody, defaultMessage) {
25
- if (!responseBody)
26
- return defaultMessage;
27
- try {
28
- const d = JSON.parse(responseBody);
29
- return d['details'] || d['error'] || d['message'] || defaultMessage;
30
- }
31
- catch {
32
- return responseBody || defaultMessage;
33
- }
34
- }
35
- function throwApiError(response, responseBody, defaultMessage) {
36
- throw new ApiError(parseErrorMessage(responseBody, defaultMessage), response.status, responseBody);
37
- }
38
25
  export async function proposeAgreement(proposalData, creds) {
39
26
  if (!proposalData)
40
27
  throw new Error('Proposal data is required for proposal creation');
@@ -124,19 +111,28 @@ export async function respondToAgreement(agreementId, action, creds, opts = {})
124
111
  if (!agreement) {
125
112
  throw new Error(`Agreement ${agreementId} not found`);
126
113
  }
114
+ const partyId = resolveMyPendingApprovalPartyId(agreement, {
115
+ ownerUserId: opts.ownerUserId,
116
+ agentId: creds.agentId,
117
+ });
127
118
  const proposedTo = agreement.parties?.proposedTo;
128
119
  if (isBroadcastTarget(proposedTo) || isBroadcastTarget(agreement.parties?.payer)) {
129
120
  // ZIG-1021: respond is approve/reject on DIRECT proposals only. An open
130
121
  // broadcast (quest, standing offer, link invite) has no per-recipient
131
- // approval slot — one recipient can neither approve nor reject it
132
- // (ZIG-719); the move is to claim it, which has its own verb now.
133
- throw new Error(`Agreement ${agreementId} is an open broadcast and has no personal approval slot — respond cannot ${action} it. Claim it with the claim tool (agreement_claim / ziggs_agreement_claim), or ignore it to pass.`);
122
+ // approval slot — a BYSTANDER can neither approve nor reject it
123
+ // (ZIG-719); their move is to claim it, which has its own verb.
124
+ //
125
+ // ZIG-1077: with one exception — the caller who HOLDS a pending approval
126
+ // on the row. A hand-off pins its provider and that provider's consent
127
+ // is a real ledger slot even on an open broadcast (ZIG-1059); this guard
128
+ // used to reject before looking, so the provider could not consent
129
+ // through ANY surface and the hand-off sat unclaimable forever. The
130
+ // backend keeps a consented broadcast OPEN for claims.
131
+ if (!partyId) {
132
+ throw new Error(`Agreement ${agreementId} is an open broadcast and has no personal approval slot — respond cannot ${action} it. Claim it with the claim tool (agreement_claim / ziggs_agreement_claim), or ignore it to pass.`);
133
+ }
134
134
  }
135
- const partyId = resolveMyPendingApprovalPartyId(agreement, {
136
- ownerUserId: opts.ownerUserId,
137
- agentId: creds.agentId,
138
- });
139
- if (!partyId) {
135
+ else if (!partyId) {
140
136
  throw new Error(`No pending approval entry for this operator on agreement ${agreementId}`);
141
137
  }
142
138
  return approveAgreementAsParty(agreementId, partyId, action === 'approve' ? 'approved' : 'rejected', creds);
@@ -7,6 +7,13 @@ export interface ListArtifactsOptions {
7
7
  export interface ListArtifactsQuery {
8
8
  chatId?: string;
9
9
  agreementId?: string;
10
+ taskId?: string;
11
+ /**
12
+ * ZIG-1037 — `'me'` lists artifacts YOU authored, in any scope or none. The
13
+ * only listing that surfaces a free-standing artifact, since the others read
14
+ * attach tables. Mutually exclusive with the scope filters.
15
+ */
16
+ authoredBy?: 'me';
10
17
  }
11
18
  export interface ListArtifactsResult {
12
19
  artifacts: unknown[];
@@ -84,6 +91,32 @@ export declare class ArtifactsClient {
84
91
  writeStrict(input: WriteArtifactInput): Promise<{
85
92
  artifactId?: string;
86
93
  }>;
94
+ /**
95
+ * ZIG-1037 — attach an existing artifact to a chat or a task.
96
+ *
97
+ * Attaching confers nothing on its own: it places the artifact inside the
98
+ * container, and that container's audience (chat members / task parties) can
99
+ * read it from then on. This is how a free-standing artifact reaches anyone
100
+ * without granting it to one specific agent.
101
+ *
102
+ * The agreement equivalent already existed as POST /agreements/:id/artifacts.
103
+ */
104
+ attachToChat(artifactId: string, chatId: string): Promise<{
105
+ success: boolean;
106
+ }>;
107
+ attachToTask(artifactId: string, taskId: string, role?: 'input' | 'output'): Promise<{
108
+ success: boolean;
109
+ }>;
110
+ private _attach;
111
+ /**
112
+ * ZIG-1037 — at most one container, and none is fine.
113
+ *
114
+ * This used to demand exactly one, which is what made a deliverable fail at the
115
+ * last step when the model had not worked out which container it was standing
116
+ * in. A write with no scope is now a free-standing artifact; `taskId` alone
117
+ * binds it to a task. Both containers at once is still a mistake worth naming,
118
+ * since only one of them would take effect.
119
+ */
87
120
  private _assertScopeXor;
88
121
  private _headers;
89
122
  }
@@ -1,5 +1,6 @@
1
1
  import 'dotenv/config';
2
2
  import { runtimeLog } from '../shared/runtimeLog.js';
3
+ import { throwApiError } from '../shared/apiError.js';
3
4
  import { getBackendUrl } from '../utils/urlUtils.js';
4
5
  import { buildOperatorHeaders } from './operatorHeaders.js';
5
6
  /**
@@ -38,14 +39,19 @@ export class ArtifactsClient {
38
39
  this.agentId = agentId;
39
40
  }
40
41
  async list(q, opts = {}) {
41
- if ((q.chatId && q.agreementId) || (!q.chatId && !q.agreementId)) {
42
- throw new Error('ArtifactsClient.list: pass exactly one of chatId or agreementId');
42
+ const selectors = [q.chatId, q.agreementId, q.taskId, q.authoredBy].filter(Boolean);
43
+ if (selectors.length !== 1) {
44
+ throw new Error('ArtifactsClient.list: pass exactly one of chatId, agreementId, taskId, or authoredBy');
43
45
  }
44
46
  const url = new URL(`${getBackendUrl()}/artifacts`);
45
47
  if (q.chatId)
46
48
  url.searchParams.set('chatId', q.chatId);
47
49
  if (q.agreementId)
48
50
  url.searchParams.set('agreementId', q.agreementId);
51
+ if (q.taskId)
52
+ url.searchParams.set('taskId', q.taskId);
53
+ if (q.authoredBy)
54
+ url.searchParams.set('authoredBy', q.authoredBy);
49
55
  if (opts.after)
50
56
  url.searchParams.set('after', opts.after);
51
57
  if (opts.limit != null)
@@ -53,7 +59,7 @@ export class ArtifactsClient {
53
59
  const res = await fetch(url.toString(), { headers: this._headers() });
54
60
  if (!res.ok) {
55
61
  const body = await res.text().catch(() => '');
56
- throw new Error(`ArtifactsClient.list ${res.status} ${res.statusText} ${body.slice(0, 200)}`);
62
+ throwApiError(res, body, `listArtifacts failed: ${res.status} ${res.statusText}`);
57
63
  }
58
64
  return (await res.json());
59
65
  }
@@ -118,7 +124,7 @@ export class ArtifactsClient {
118
124
  });
119
125
  const body = await res.text().catch(() => '');
120
126
  if (!res.ok) {
121
- throw new Error(`POST /artifacts ${res.status} ${res.statusText} ${body.slice(0, 200)}`);
127
+ throwApiError(res, body, `recordArtifact failed: ${res.status} ${res.statusText}`);
122
128
  }
123
129
  try {
124
130
  const parsed = body ? JSON.parse(body) : {};
@@ -128,9 +134,51 @@ export class ArtifactsClient {
128
134
  return {};
129
135
  }
130
136
  }
137
+ /**
138
+ * ZIG-1037 — attach an existing artifact to a chat or a task.
139
+ *
140
+ * Attaching confers nothing on its own: it places the artifact inside the
141
+ * container, and that container's audience (chat members / task parties) can
142
+ * read it from then on. This is how a free-standing artifact reaches anyone
143
+ * without granting it to one specific agent.
144
+ *
145
+ * The agreement equivalent already existed as POST /agreements/:id/artifacts.
146
+ */
147
+ async attachToChat(artifactId, chatId) {
148
+ return this._attach(`/chats/${encodeURIComponent(chatId)}/artifacts`, {
149
+ artifactId,
150
+ });
151
+ }
152
+ async attachToTask(artifactId, taskId, role = 'output') {
153
+ return this._attach(`/tasks/${encodeURIComponent(taskId)}/artifacts`, {
154
+ artifactId,
155
+ role,
156
+ });
157
+ }
158
+ async _attach(path, body) {
159
+ const res = await fetch(`${getBackendUrl()}${path}`, {
160
+ method: 'POST',
161
+ headers: this._headers(),
162
+ body: JSON.stringify(body),
163
+ });
164
+ const text = await res.text().catch(() => '');
165
+ if (!res.ok) {
166
+ throwApiError(res, text, `attachArtifact failed: ${res.status} ${res.statusText}`);
167
+ }
168
+ return { success: true };
169
+ }
170
+ /**
171
+ * ZIG-1037 — at most one container, and none is fine.
172
+ *
173
+ * This used to demand exactly one, which is what made a deliverable fail at the
174
+ * last step when the model had not worked out which container it was standing
175
+ * in. A write with no scope is now a free-standing artifact; `taskId` alone
176
+ * binds it to a task. Both containers at once is still a mistake worth naming,
177
+ * since only one of them would take effect.
178
+ */
131
179
  _assertScopeXor(input) {
132
- if ((input.chatId && input.agreementId) || (!input.chatId && !input.agreementId)) {
133
- throw new Error('ArtifactsClient.write: pass exactly one of chatId or agreementId');
180
+ if (input.chatId && input.agreementId) {
181
+ throw new Error('ArtifactsClient.write: pass at most one of chatId or agreementId');
134
182
  }
135
183
  }
136
184
  _headers() {
@@ -1,7 +1,7 @@
1
1
  import 'dotenv/config';
2
2
  import { runtimeLog } from '../shared/runtimeLog.js';
3
3
  import { getBackendUrl } from '../utils/urlUtils.js';
4
- import { ApiError } from '../types.js';
4
+ import { throwApiError } from '../shared/apiError.js';
5
5
  function buildHeaders(creds) {
6
6
  return {
7
7
  'content-type': 'application/json',
@@ -15,19 +15,6 @@ function assertCreds(creds, op) {
15
15
  if (!creds?.agentId)
16
16
  throw new Error(`agentId is required for ${op}`);
17
17
  }
18
- function throwApiError(response, responseBody, defaultMessage) {
19
- let message = defaultMessage;
20
- if (responseBody) {
21
- try {
22
- const d = JSON.parse(responseBody);
23
- message = d['details'] || d['error'] || d['message'] || defaultMessage;
24
- }
25
- catch {
26
- message = responseBody || defaultMessage;
27
- }
28
- }
29
- throw new ApiError(message, response.status, responseBody);
30
- }
31
18
  export async function openConversation(participantId, creds) {
32
19
  if (!participantId)
33
20
  throw new Error('participantId is required for openConversation');
@@ -1,6 +1,11 @@
1
1
  import 'dotenv/config';
2
2
  import type { GrantView } from './grants.js';
3
- export type ContextGrantScopeKind = 'chat' | 'agreement' | 'org';
3
+ /**
4
+ * ZIG-1037 added `artifact` — the narrowest context scope: one specific artifact,
5
+ * shared without sharing any chat or agreement it sits in. Still the context
6
+ * rail, not a new grant primitive.
7
+ */
8
+ export type ContextGrantScopeKind = 'chat' | 'agreement' | 'org' | 'artifact';
4
9
  export type ContextTemporal = 'from-now' | 'from-start';
5
10
  export interface ContextGrantScope {
6
11
  kind: ContextGrantScopeKind;
@@ -80,6 +85,21 @@ export declare class ContextGrantsClient {
80
85
  * labels-only, grant-fenced server-side.
81
86
  */
82
87
  getReach(grantId: string): Promise<GrantReachResult>;
88
+ /**
89
+ * ZIG-1037 — share an artifact THIS agent authored with another agent.
90
+ *
91
+ * Not a delegation: an agent holds no grant over its own output, so there is
92
+ * no parent to attenuate. Authorship is the authority, held by the agent's
93
+ * owner — which is why the receiver decides the outcome. A receiver already
94
+ * inside the sharer's trust boundary (same owner, same org, or an active link)
95
+ * is granted immediately; anyone else opens a request the owner approves,
96
+ * exactly like `delegateGrant`'s new-party path.
97
+ */
98
+ shareArtifact(artifactId: string, input: {
99
+ holderId: string;
100
+ expiresAt?: string | null;
101
+ chatId?: string;
102
+ }): Promise<DelegateContextGrantResult>;
83
103
  revokeGrant(grantId: string): Promise<{
84
104
  status: string;
85
105
  revokedCount?: number;
@@ -1,24 +1,7 @@
1
1
  import 'dotenv/config';
2
2
  import { getBackendUrl } from '../utils/urlUtils.js';
3
- import { ApiError } from '../types.js';
4
3
  import { buildOperatorHeaders } from './operatorHeaders.js';
5
- function throwApiError(response, responseBody, defaultMessage) {
6
- let message = defaultMessage;
7
- if (responseBody) {
8
- try {
9
- const d = JSON.parse(responseBody);
10
- message =
11
- d['details'] ||
12
- d['error'] ||
13
- d['message'] ||
14
- defaultMessage;
15
- }
16
- catch {
17
- message = responseBody || defaultMessage;
18
- }
19
- }
20
- throw new ApiError(message, response.status, responseBody);
21
- }
4
+ import { throwApiError } from '../shared/apiError.js';
22
5
  /**
23
6
  * ZIG-411 context grant management — list / issue / delegate / revoke.
24
7
  */
@@ -113,6 +96,41 @@ export class ContextGrantsClient {
113
96
  truncatedAgreements: parsed.truncatedAgreements ?? 0,
114
97
  };
115
98
  }
99
+ /**
100
+ * ZIG-1037 — share an artifact THIS agent authored with another agent.
101
+ *
102
+ * Not a delegation: an agent holds no grant over its own output, so there is
103
+ * no parent to attenuate. Authorship is the authority, held by the agent's
104
+ * owner — which is why the receiver decides the outcome. A receiver already
105
+ * inside the sharer's trust boundary (same owner, same org, or an active link)
106
+ * is granted immediately; anyone else opens a request the owner approves,
107
+ * exactly like `delegateGrant`'s new-party path.
108
+ */
109
+ async shareArtifact(artifactId, input) {
110
+ const res = await fetch(`${this.baseUrl}/context/artifacts/${encodeURIComponent(artifactId)}/share`, {
111
+ method: 'POST',
112
+ headers: buildOperatorHeaders(this.operatorKey, this.agentId, {
113
+ 'content-type': 'application/json',
114
+ }),
115
+ body: JSON.stringify(input),
116
+ });
117
+ const body = await res.text().catch(() => '');
118
+ if (!res.ok) {
119
+ throwApiError(res, body, `shareArtifact failed: ${res.status} ${res.statusText}`);
120
+ }
121
+ const parsed = JSON.parse(body);
122
+ if (parsed.status === 'pending_approval' && parsed.agreementId) {
123
+ return {
124
+ status: 'pending_approval',
125
+ agreementId: parsed.agreementId,
126
+ ownerId: parsed.ownerId,
127
+ };
128
+ }
129
+ if (!parsed.grant?.grantId) {
130
+ throw new Error('Invalid response: expected { grant } or { status: "pending_approval" } from POST /context/artifacts/:id/share');
131
+ }
132
+ return { status: 'granted', grant: parsed.grant };
133
+ }
116
134
  async revokeGrant(grantId) {
117
135
  const res = await fetch(`${this.baseUrl}/context/grants/${encodeURIComponent(grantId)}`, {
118
136
  method: 'DELETE',
@@ -1,6 +1,6 @@
1
1
  import 'dotenv/config';
2
2
  import { getBackendUrl } from '../utils/urlUtils.js';
3
- import { ApiError } from '../types.js';
3
+ import { throwApiError } from '../shared/apiError.js';
4
4
  function getMarketplaceBaseUrl() { return `${getBackendUrl()}/marketplace`; }
5
5
  function buildHeaders(creds) {
6
6
  return {
@@ -15,15 +15,6 @@ function assertCreds(creds, op) {
15
15
  if (!creds?.agentId)
16
16
  throw new Error(`agentId is required for ${op}`);
17
17
  }
18
- function throwApiError(response, body, defaultMsg) {
19
- let msg = defaultMsg;
20
- try {
21
- const d = JSON.parse(body);
22
- msg = d['details'] || d['error'] || d['message'] || defaultMsg;
23
- }
24
- catch { /**/ }
25
- throw new ApiError(msg, response.status);
26
- }
27
18
  export async function publishOffer(payload, creds) {
28
19
  assertCreds(creds, 'marketplace offer publish');
29
20
  const res = await fetch(`${getMarketplaceBaseUrl()}/offers/publish`, {
@@ -2,20 +2,10 @@ import 'dotenv/config';
2
2
  import { getBackendUrl } from '../utils/urlUtils.js';
3
3
  import { buildOperatorHeaders } from './operatorHeaders.js';
4
4
  import { GrantsClient } from './GrantsClient.js';
5
+ import { parseErrorMessage } from '../shared/apiError.js';
5
6
  function randomIdempotencyKey(prefix = 'op') {
6
7
  return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
7
8
  }
8
- function parseError(text, fallback) {
9
- if (!text)
10
- return fallback;
11
- try {
12
- const parsed = JSON.parse(text);
13
- return parsed['error'] || parsed['message'] || text;
14
- }
15
- catch {
16
- return text;
17
- }
18
- }
19
9
  /**
20
10
  * ZIG-894 — the one payments client for every surface (agent-sdk, ziggs-mcp,
21
11
  * scripts). Consolidates the former agent-sdk private ZiggsPayClient. Wallet
@@ -227,7 +217,7 @@ export class PaymentsClient {
227
217
  const response = await fetch(`${this.baseUrl}${path}`, init);
228
218
  const text = await response.text();
229
219
  if (!response.ok) {
230
- const err = new Error(parseError(text, `HTTP ${response.status}`));
220
+ const err = new Error(parseErrorMessage(text, `HTTP ${response.status}`));
231
221
  err.status = response.status;
232
222
  err.body = text;
233
223
  throw err;
@@ -1,6 +1,6 @@
1
1
  import 'dotenv/config';
2
2
  import { getBackendUrl } from '../utils/urlUtils.js';
3
- import { ApiError } from '../types.js';
3
+ import { throwApiError } from '../shared/apiError.js';
4
4
  function getTaskBaseUrl() { return `${getBackendUrl()}/tasks`; }
5
5
  function buildHeaders(creds) {
6
6
  return {
@@ -15,26 +15,6 @@ function assertCreds(creds, op) {
15
15
  if (!creds?.agentId)
16
16
  throw new Error(`agentId is required for ${op}`);
17
17
  }
18
- function parseErrorMessage(responseBody, defaultMessage) {
19
- if (!responseBody)
20
- return defaultMessage;
21
- try {
22
- const d = JSON.parse(responseBody);
23
- // NestJS ValidationPipe returns { message: string[] | string, error: 'Bad
24
- // Request', statusCode }. Prefer the detailed `message` over the generic
25
- // `error` so the real cause (which field failed validation) is surfaced
26
- // instead of a bare "Bad Request". (ZIG-832)
27
- const msg = d['message'];
28
- const detailed = Array.isArray(msg) ? msg.join('; ') : (typeof msg === 'string' ? msg : undefined);
29
- return d['details'] || detailed || d['error'] || defaultMessage;
30
- }
31
- catch {
32
- return responseBody || defaultMessage;
33
- }
34
- }
35
- function throwApiError(response, responseBody, defaultMessage) {
36
- throw new ApiError(parseErrorMessage(responseBody, defaultMessage), response.status, responseBody);
37
- }
38
18
  function extractTask(data) {
39
19
  if (!data || typeof data !== 'object')
40
20
  return null;
@@ -22,7 +22,7 @@ export declare function proposeUnified(input: UnifiedProposeInput, creds: Creds)
22
22
  agreement: Agreement;
23
23
  shape: ProposeShape;
24
24
  }>;
25
- export type ClaimedKind = 'link' | 'offer' | 'quest';
25
+ export type ClaimedKind = 'link' | 'offer' | 'quest' | 'hand-off';
26
26
  /**
27
27
  * ZIG-1021 — one claim verb for any open broadcast. Fetches the agreement to
28
28
  * route: link invites and quests claim through POST /agreements/:id/claim;
@@ -76,5 +76,10 @@ export async function claimOpenAgreement(agreementId, creds) {
76
76
  return { agreement, kind: 'offer' };
77
77
  }
78
78
  const { agreement } = await claimAgreement(agreementId, creds);
79
- return { agreement, kind: 'quest' };
79
+ // ZIG-1059: a pinned provider inverts the quest reading — the publisher's
80
+ // hired agent does the work and the claimer is who it is done FOR.
81
+ return {
82
+ agreement,
83
+ kind: existing.providerPinned === true ? 'hand-off' : 'quest',
84
+ };
80
85
  }
@@ -11,7 +11,18 @@
11
11
  * watermark are presented as `temporal` / `watermark_at` caveats.
12
12
  */
13
13
  export type GrantHealth = 'active' | 'expired' | 'revoked';
14
- export type GrantScopeKind = 'chat' | 'agreement' | 'org' | 'connection' | 'wallet';
14
+ /**
15
+ * The context rail's scope kinds, as VALUES — the type below is derived from
16
+ * them, so widening the rail is one edit rather than a type change plus however
17
+ * many hand-written literal lists happen to exist. A literal subset still
18
+ * typechecks against the union, which is how the CLI's grant listings silently
19
+ * stopped showing a whole scope kind after `artifact` was added; anything that
20
+ * means "every context scope" should read this.
21
+ */
22
+ export declare const CONTEXT_GRANT_SCOPE_KINDS: readonly ["chat", "agreement", "org", "artifact"];
23
+ /** Every scope kind across every rail: context + connection + payment. */
24
+ export declare const GRANT_SCOPE_KINDS: readonly ["chat", "agreement", "org", "artifact", "connection", "wallet"];
25
+ export type GrantScopeKind = (typeof GRANT_SCOPE_KINDS)[number];
15
26
  export interface GrantScopeView {
16
27
  kind: GrantScopeKind;
17
28
  id: string;
@@ -1,3 +1,24 @@
1
+ /**
2
+ * The context rail's scope kinds, as VALUES — the type below is derived from
3
+ * them, so widening the rail is one edit rather than a type change plus however
4
+ * many hand-written literal lists happen to exist. A literal subset still
5
+ * typechecks against the union, which is how the CLI's grant listings silently
6
+ * stopped showing a whole scope kind after `artifact` was added; anything that
7
+ * means "every context scope" should read this.
8
+ */
9
+ export const CONTEXT_GRANT_SCOPE_KINDS = [
10
+ 'chat',
11
+ 'agreement',
12
+ 'org',
13
+ // ZIG-1037: narrowest context scope
14
+ 'artifact',
15
+ ];
16
+ /** Every scope kind across every rail: context + connection + payment. */
17
+ export const GRANT_SCOPE_KINDS = [
18
+ ...CONTEXT_GRANT_SCOPE_KINDS,
19
+ 'connection',
20
+ 'wallet',
21
+ ];
1
22
  /** Value of the first caveat of `type` on a grant, or undefined. */
2
23
  export function grantCaveat(grant, type) {
3
24
  return grant.caveats.find((c) => c.type === type)?.value;
@@ -15,7 +15,7 @@ export { GrantsClient } from './GrantsClient.js';
15
15
  export type { ListGrantsQuery, ListGrantsResult, UnreadableRail } from './GrantsClient.js';
16
16
  export { ContextGrantsClient } from './ContextGrantsClient.js';
17
17
  export type { ContextGrantRecord, ContextGrantScope, ContextGrantScopeKind, ContextTemporal, IssueContextGrantInput, DelegateContextGrantInput, DelegateContextGrantResult, ReachEntry, GrantReachResult, } from './ContextGrantsClient.js';
18
- export { grantCaveat } from './grants.js';
18
+ export { grantCaveat, CONTEXT_GRANT_SCOPE_KINDS, GRANT_SCOPE_KINDS, } from './grants.js';
19
19
  export type { GrantView, GrantScopeKind, GrantScopeView, GrantCaveatView, GrantHealth, } from './grants.js';
20
20
  export { PaymentsClient } from './PaymentsClient.js';
21
21
  export type { PaymentsError, WalletBalance, WalletRef, PaymentTransactionView, TransferResult, HoldResult, ReleaseResult, PaymentGrantView, PaymentGrantEnvelope, RevokeGrantResult, PaymentApproval, WaitForApprovalResult, } from './PaymentsClient.js';
@@ -11,7 +11,7 @@ export { ContextReadClient } from './ContextReadClient.js';
11
11
  export { ContextDiscoveryClient } from './ContextDiscoveryClient.js';
12
12
  export { GrantsClient } from './GrantsClient.js';
13
13
  export { ContextGrantsClient } from './ContextGrantsClient.js';
14
- export { grantCaveat } from './grants.js';
14
+ export { grantCaveat, CONTEXT_GRANT_SCOPE_KINDS, GRANT_SCOPE_KINDS, } from './grants.js';
15
15
  export { PaymentsClient } from './PaymentsClient.js';
16
16
  export { ConnectionsClient, assertNoLeakedConnectionSecret, } from './ConnectionsClient.js';
17
17
  export { fetchMyOrgs, resolveOrgSelector, fetchDelegateAccess, } from './OrgsClient.js';
package/dist/index.d.ts CHANGED
@@ -7,5 +7,6 @@ export { EntryTypes, ContentTypes, OPEN_AGREEMENT_TARGET, ORG_AGREEMENT_TARGET,
7
7
  export { getBackendUrl, getWebSocketUrl } from './utils/urlUtils.js';
8
8
  export { runtimeLog, resetRuntimeLogLevelCache } from './shared/runtimeLog.js';
9
9
  export { RateLimitedError, isRateLimited, parseRetryAfterMs, pollSurfaceError, } from './shared/rateLimit.js';
10
+ export { parseErrorMessage, throwApiError } from './shared/apiError.js';
10
11
  export type { Creds, Task, TaskState, PlanStep, PlanStepStatus, Agreement, EngagementKind, BroadcastAudience, EntryType, ContentType, MessageMetadata, MessageHandler, ApiError, } from './types.js';
11
12
  export type { ProposeTerms, ProposeDirectInput, ProposeBroadcastInput, ProposeAgreementData, } from './http/AgreementClient.js';
package/dist/index.js CHANGED
@@ -7,3 +7,7 @@ export { getBackendUrl, getWebSocketUrl } from './utils/urlUtils.js';
7
7
  export { runtimeLog, resetRuntimeLogLevelCache } from './shared/runtimeLog.js';
8
8
  // ZIG-1019: retry loops need the server's own wait, not a guess.
9
9
  export { RateLimitedError, isRateLimited, parseRetryAfterMs, pollSurfaceError, } from './shared/rateLimit.js';
10
+ // One reader for the one error shape the API answers in — exported so nothing has
11
+ // to re-implement the field precedence (it was copy-pasted into six clients, and
12
+ // half of them had drifted).
13
+ export { parseErrorMessage, throwApiError } from './shared/apiError.js';
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Read the message out of a Ziggs error response.
3
+ *
4
+ * The API answers every failure in one shape — `{ error: "<what went wrong>" }`,
5
+ * plus `code` when the server supplied a machine-readable one — because one global
6
+ * exception filter builds it (backend `common/http-exception.filter.ts`, pinned in
7
+ * `common/error-response.spec.ts`). So `error` is read first and is normally the
8
+ * whole answer.
9
+ *
10
+ * `message` and `details` remain as fallbacks for responses that did NOT come from
11
+ * that filter: a proxy, a gateway, or a Nest-shaped body from some other service.
12
+ * On a Nest-shaped body `error` holds the reason phrase ("Bad Request") while
13
+ * `message` holds the real cause, so when both are present the more specific one
14
+ * wins — that ordering is why this lives in ONE place. It used to be copy-pasted
15
+ * into five clients, two of which had been fixed for it and three of which had not,
16
+ * so the same failure read differently depending on which client you called.
17
+ */
18
+ export declare function parseErrorMessage(responseBody: string, defaultMessage: string): string;
19
+ /** Throw the parsed failure as an {@link ApiError}, preserving status and body. */
20
+ export declare function throwApiError(response: {
21
+ status: number;
22
+ }, responseBody: string, defaultMessage: string): never;
@@ -0,0 +1,57 @@
1
+ import { ApiError } from '../types.js';
2
+ /**
3
+ * Read the message out of a Ziggs error response.
4
+ *
5
+ * The API answers every failure in one shape — `{ error: "<what went wrong>" }`,
6
+ * plus `code` when the server supplied a machine-readable one — because one global
7
+ * exception filter builds it (backend `common/http-exception.filter.ts`, pinned in
8
+ * `common/error-response.spec.ts`). So `error` is read first and is normally the
9
+ * whole answer.
10
+ *
11
+ * `message` and `details` remain as fallbacks for responses that did NOT come from
12
+ * that filter: a proxy, a gateway, or a Nest-shaped body from some other service.
13
+ * On a Nest-shaped body `error` holds the reason phrase ("Bad Request") while
14
+ * `message` holds the real cause, so when both are present the more specific one
15
+ * wins — that ordering is why this lives in ONE place. It used to be copy-pasted
16
+ * into five clients, two of which had been fixed for it and three of which had not,
17
+ * so the same failure read differently depending on which client you called.
18
+ */
19
+ export function parseErrorMessage(responseBody, defaultMessage) {
20
+ if (!responseBody)
21
+ return defaultMessage;
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse(responseBody);
25
+ }
26
+ catch {
27
+ // Not JSON at all — an HTML error page or a bare string is still better than
28
+ // nothing, so hand back what the server actually said.
29
+ return responseBody || defaultMessage;
30
+ }
31
+ // Valid JSON that is not an object: a quoted string IS the message; anything
32
+ // else (null, a number) carries no message, and echoing its raw text would put
33
+ // "null" in front of a caller.
34
+ if (typeof parsed !== 'object' || parsed === null) {
35
+ return typeof parsed === 'string' && parsed ? parsed : defaultMessage;
36
+ }
37
+ const body = parsed;
38
+ const text = (value) => {
39
+ if (Array.isArray(value)) {
40
+ const joined = value.map(String).filter(Boolean).join('; ');
41
+ return joined || undefined;
42
+ }
43
+ return typeof value === 'string' && value ? value : undefined;
44
+ };
45
+ const error = text(body['error']);
46
+ const message = text(body['message']);
47
+ // A Nest-shaped body carries both: prefer the specific `message` over the
48
+ // reason phrase sitting in `error`.
49
+ const preferMessage = !!message && !!body['statusCode'];
50
+ return (text(body['details']) ??
51
+ (preferMessage ? message : (error ?? message)) ??
52
+ defaultMessage);
53
+ }
54
+ /** Throw the parsed failure as an {@link ApiError}, preserving status and body. */
55
+ export function throwApiError(response, responseBody, defaultMessage) {
56
+ throw new ApiError(parseErrorMessage(responseBody, defaultMessage), response.status, responseBody);
57
+ }
package/dist/types.d.ts CHANGED
@@ -113,6 +113,12 @@ export interface Agreement {
113
113
  } | null;
114
114
  /** Set on a link that was formed by claiming the invite with this id. */
115
115
  linkInviteTemplateId?: string | null;
116
+ /**
117
+ * ZIG-997/ZIG-1059 — the provider slot is FIXED (a hand-off): claiming or
118
+ * approving this makes you the party the work is done FOR, not the worker —
119
+ * and, when priced, the payer.
120
+ */
121
+ providerPinned?: boolean;
116
122
  metadata?: Record<string, unknown>;
117
123
  createdAt?: string;
118
124
  updatedAt?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/api-client",
3
- "version": "0.6.3",
3
+ "version": "0.8.0",
4
4
  "description": "HTTP and WebSocket client for the Ziggs backend API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",