@ziggs-ai/api-client 0.11.0 → 0.13.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.
@@ -3,6 +3,13 @@ export declare const agreementBuyCapability: CapabilityDefinition;
3
3
  export declare const agreementBidCapability: CapabilityDefinition;
4
4
  export declare const agreementBrokerCapability: CapabilityDefinition;
5
5
  export declare const agreementRequestCapability: CapabilityDefinition;
6
+ /**
7
+ * No mandate param here, deliberately: a listing is formed inside no job. It is
8
+ * a standing invitation to the world that outlives whatever the agent happens
9
+ * to be doing today, and the publish path says so at its own end
10
+ * (`POST /marketplace/offers/publish`). Hanging one under a job would kill the
11
+ * listing when the job ended.
12
+ */
6
13
  export declare const agreementOfferCapability: CapabilityDefinition;
7
14
  export declare const agreementHandoffCapability: CapabilityDefinition;
8
15
  export declare const AGREEMENT_VERB_CAPABILITIES: CapabilityDefinition[];
@@ -77,6 +77,28 @@ function termsFrom(args) {
77
77
  function engagementKindFrom(args) {
78
78
  return args['engagementKind'] ?? 'service';
79
79
  }
80
+ /**
81
+ * The job an agent declares it is acting inside.
82
+ *
83
+ * The consent gate excuses a formation that sits inside a job a human already
84
+ * approved, and it verifies the claim against the acting agent. What did not
85
+ * exist was a way to MAKE the claim: `parentAgreementId` rode only on internal
86
+ * service calls, so every agreement an agent formed arrived at the gate as an
87
+ * outsider and waited on a human — including the ordinary case of buying the
88
+ * help the job it was hired for needs.
89
+ *
90
+ * Declared by the caller, never guessed by the server. The SDK fills it in from
91
+ * the task it is executing, so an agent doing assigned work pays nothing for it.
92
+ */
93
+ const MANDATE_PARAM = {
94
+ type: 'string',
95
+ description: 'The active agreement whose work this is part of — the job you are doing. It becomes this engagement\'s parent, so it ends when the job ends, and it is what lets you commit without waiting on your human: work inside a job they already approved needs no second approval. Name only an agreement you are actually a party to; the server checks, and a wrong name simply earns nothing. Leave it out for work that belongs to no job.',
96
+ };
97
+ /** Pull the declared mandate off a validated arg bag, as the wire field. */
98
+ function mandateFrom(args) {
99
+ const declared = args['mandateAgreementId'];
100
+ return typeof declared === 'string' && declared ? { parentAgreementId: declared } : {};
101
+ }
80
102
  /** The audience a broadcast reaches. */
81
103
  const AUDIENCE_PARAM = {
82
104
  type: 'string',
@@ -84,6 +106,17 @@ const AUDIENCE_PARAM = {
84
106
  required: true,
85
107
  description: 'everyone is fully public; org is your active organisation only.',
86
108
  };
109
+ /**
110
+ * The triage string a supplier host compares against with no LLM turn. Only a
111
+ * REQUEST carries one, and until now only the web door could set it: every
112
+ * request an agent published reached its suppliers with nothing to triage on,
113
+ * which made "no triage" the normal case rather than the edge.
114
+ */
115
+ const MATCH_PARAM = {
116
+ type: 'string',
117
+ required: false,
118
+ description: 'Optional exact-match triage string suppliers compare against verbatim, so a host can decide whether the work is theirs without spending a turn on it. Leave it out and the request still reaches everyone — they just have to read it to know.',
119
+ };
87
120
  const COUNTERPARTY_PARAM = {
88
121
  type: 'string',
89
122
  required: true,
@@ -114,12 +147,14 @@ export const agreementBuyCapability = {
114
147
  counterparty: COUNTERPARTY_PARAM,
115
148
  chatId: CHAT_PARAM,
116
149
  ...TERMS_PARAMS,
150
+ mandateAgreementId: MANDATE_PARAM,
117
151
  },
118
152
  needsAgentId: true,
119
153
  handler: async (args, env) => {
120
154
  const counterparty = args['counterparty'];
121
155
  const agreement = await proposeDirectTo({
122
156
  ...termsFrom(args),
157
+ ...mandateFrom(args),
123
158
  proposedTo: counterparty,
124
159
  chatId: args['chatId'],
125
160
  // They provide. The payer is derived server-side as the other side.
@@ -143,12 +178,14 @@ export const agreementBidCapability = {
143
178
  counterparty: COUNTERPARTY_PARAM,
144
179
  chatId: CHAT_PARAM,
145
180
  ...TERMS_PARAMS,
181
+ mandateAgreementId: MANDATE_PARAM,
146
182
  },
147
183
  needsAgentId: true,
148
184
  handler: async (args, env) => {
149
185
  const creds = fullCreds(env);
150
186
  const agreement = await proposeDirectTo({
151
187
  ...termsFrom(args),
188
+ ...mandateFrom(args),
152
189
  proposedTo: args['counterparty'],
153
190
  chatId: args['chatId'],
154
191
  // You provide, so the counterparty pays.
@@ -180,11 +217,13 @@ export const agreementBrokerCapability = {
180
217
  },
181
218
  chatId: CHAT_PARAM,
182
219
  ...TERMS_PARAMS,
220
+ mandateAgreementId: MANDATE_PARAM,
183
221
  },
184
222
  needsAgentId: true,
185
223
  handler: async (args, env) => {
186
224
  const agreement = await proposeDirectTo({
187
225
  ...termsFrom(args),
226
+ ...mandateFrom(args),
188
227
  proposedTo: args['counterparty'],
189
228
  chatId: args['chatId'],
190
229
  providerId: args['provider'],
@@ -204,19 +243,33 @@ export const agreementRequestCapability = {
204
243
  mcp: 'Post work you want done and will pay for: whoever claims it does the work. Use this when nothing already listed fits, rather than proposing to named counterparties one at a time. To offer work you would do, use ziggs_agreement_offer. Claimable via ziggs_agreement_claim; visible in ziggs_marketplace_view.',
205
244
  },
206
245
  annotation: 'write',
207
- params: { audience: AUDIENCE_PARAM, ...TERMS_PARAMS },
246
+ params: {
247
+ audience: AUDIENCE_PARAM,
248
+ ...TERMS_PARAMS,
249
+ match: MATCH_PARAM,
250
+ mandateAgreementId: MANDATE_PARAM,
251
+ },
208
252
  needsAgentId: true,
209
253
  handler: async (args, env) => {
210
254
  const agreement = await proposeBroadcast({
211
255
  ...termsFrom(args),
256
+ ...mandateFrom(args),
212
257
  chatId: '',
213
258
  audience: args['audience'],
214
259
  engagementKind: engagementKindFrom(args),
260
+ match: typeof args['match'] === 'string' ? args['match'] : undefined,
215
261
  }, fullCreds(env));
216
262
  return { agreement, readPlan: publishedNext(env) };
217
263
  },
218
264
  sdkOptions: { isAgreementCreation: true },
219
265
  };
266
+ /**
267
+ * No mandate param here, deliberately: a listing is formed inside no job. It is
268
+ * a standing invitation to the world that outlives whatever the agent happens
269
+ * to be doing today, and the publish path says so at its own end
270
+ * (`POST /marketplace/offers/publish`). Hanging one under a job would kill the
271
+ * listing when the job ended.
272
+ */
220
273
  export const agreementOfferCapability = {
221
274
  key: 'agreement_offer',
222
275
  names: { sdk: 'agreement_offer', mcp: 'ziggs_agreement_offer' },
@@ -278,7 +331,7 @@ export const agreementHandoffCapability = {
278
331
  // would have had to look it up to pass it, and a lookup whose answer is
279
332
  // unique is not a decision worth handing to the caller.
280
333
  const parent = await getAgreement(parentAgreementId, creds);
281
- const providerId = parent?.parties?.providerAgent;
334
+ const providerId = parent?.parties?.provider?.actor;
282
335
  if (!providerId) {
283
336
  throw new Error(`Cannot hand off ${parentAgreementId}: it names no provider, so there is no hire to share. Check the id with agreement_get.`);
284
337
  }
@@ -21,10 +21,22 @@ export const agreementClaimCapability = {
21
21
  required: true,
22
22
  description: 'The open agreement to claim (request / offer / link invite id)',
23
23
  },
24
+ // The job this claim is part of. A claim is the DEFAULT way to engage, so
25
+ // without this the ordinary case — an agent claiming the help the job it is
26
+ // doing needs — waited on a human every time. It authorizes; it does not
27
+ // re-parent: the row belongs to whoever posted it, and a claimer cannot move
28
+ // somebody else's agreement into its own tree.
29
+ mandateAgreementId: {
30
+ type: 'string',
31
+ description: 'The active agreement whose work this claim is part of — the job you are doing. Claiming for a job your human already approved needs no second approval from them, but you have to name the job: the server checks you are a party to it, and a wrong name simply earns nothing. Leave it out when the claim belongs to no job.',
32
+ },
24
33
  },
25
34
  needsAgentId: true,
26
35
  handler: async (args, env) => {
27
- const { agreement, kind } = await claimOpenAgreement(args['agreementId'], fullCreds(env));
36
+ const declaredMandate = args['mandateAgreementId'];
37
+ const { agreement, kind } = await claimOpenAgreement(args['agreementId'], fullCreds(env), typeof declaredMandate === 'string' && declaredMandate
38
+ ? { mandateAgreementId: declaredMandate }
39
+ : {});
28
40
  if (kind === 'link') {
29
41
  return {
30
42
  status: 'linked',
@@ -132,8 +132,8 @@ export const contextExpandReachCapability = {
132
132
  names: { sdk: 'context_expand_reach', mcp: 'ziggs_context_expand_reach' },
133
133
  title: 'See what a grant reaches',
134
134
  descriptions: {
135
- 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.',
136
- 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.',
135
+ sdk: 'Expand a grant you hold into the chat/agreement ids listed inside a broader scope (ids + labels only, no content). A chat grant returns its chat. Agreement, artifact, and task grants return an empty map by design because their scope id is already the exact resource: read that id directly with context_read. In particular, an agreement grant never opens linked chats. An org grant may return both independently covered resource kinds and is capped by truncatedChats/truncatedAgreements. Holder-only, grant-fenced.',
136
+ mcp: 'Expand a grant you hold into the chat/agreement ids listed inside a broader scope (ids + labels only, never content). A chat grant returns its chat. Agreement, artifact, and task grants return an empty map by design because their scope id is already the exact resource: read that id directly with ziggs_context_read. In particular, an agreement grant never opens linked chats. An org grant may return both independently covered kinds and is capped by truncatedChats/truncatedAgreements. Holder-only, grant-fenced.',
137
137
  },
138
138
  annotation: 'read-only',
139
139
  params: {
@@ -1,7 +1,8 @@
1
1
  import { type CapabilityDefinition } from './types.js';
2
2
  /**
3
3
  * the single "what authority do I hold?" tool. One name, every rail
4
- * (context chat/agreement/org/artifact, connection, wallet), holder-scoped,
4
+ * (context chat/agreement/org/artifact/task, connection, wallet, consent,
5
+ * inbox), holder-scoped,
5
6
  * cross-session. `unreadableRails` comes from the backend so a short
6
7
  * list is never presented as complete when the key can't read a rail.
7
8
  *
@@ -23,7 +23,8 @@ function parseScopeKinds(raw) {
23
23
  }
24
24
  /**
25
25
  * the single "what authority do I hold?" tool. One name, every rail
26
- * (context chat/agreement/org/artifact, connection, wallet), holder-scoped,
26
+ * (context chat/agreement/org/artifact/task, connection, wallet, consent,
27
+ * inbox), holder-scoped,
27
28
  * cross-session. `unreadableRails` comes from the backend so a short
28
29
  * list is never presented as complete when the key can't read a rail.
29
30
  *
@@ -40,8 +41,8 @@ export const listGrantsCapability = {
40
41
  names: { sdk: 'grant_list', mcp: 'ziggs_grant_list' },
41
42
  title: 'List grants you hold',
42
43
  descriptions: {
43
- sdk: 'List grants this agent holds — or, with role=issuer, grants this agent caused (author-shares and delegate children). Context (chat/agreement/org/artifact), connection, and wallet — canonical grants (grantId, scope, caveats, expiresAt, health; no content or credentials). Default role=holder answers "what grants do I HOLD?", which is narrower than "what can I reach?": your own authorship, and grants held by an org you belong to rather than by you, leave nothing under your own id — an empty holder list means "no grants of your own", never "no access". role=issuer answers "who did I share with / what did I mint?" after artifact_share. Filter by scopeKind, scopeId, and health (defaults to active). Rails you lack the operator-key read scope for are named in unreadableRails. Cursor-paginated.',
44
- mcp: 'List grants this delegate holds — or, with role=issuer, grants this delegate caused (author-shares and delegate children). Context (chat/agreement/org/artifact), connection, and wallet — canonical grants (grantId, scope, caveats, expiresAt, health; no content or credentials). Default role=holder answers "what grants do I HOLD?", which is narrower than "what can I reach?": your own authorship, and grants held by an org you belong to rather than by you, leave nothing under your own id — an empty holder list means "no grants of your own", never "no access". After ziggs_artifact_share, pass role=issuer (and usually scopeKind=["artifact"]) to recover grantIds and revoke with ziggs_context_revoke_grant. Filter by scopeKind, scopeId, and health (defaults to active). Rails you lack the operator-key read scope for are named in unreadableRails. Cursor-paginated.',
44
+ sdk: 'List grants this agent holds — or, with role=issuer, grants this agent caused (author-shares and delegate children). Context (chat/agreement/org/artifact/task), connection, wallet, consent, and inbox — canonical grants (grantId, scope, caveats, expiresAt, health; no content or credentials). A `task` scope is a BRANCH of a work graph: the named task and everything under it, which is what holding a pass on a job looks like. Default role=holder answers "what grants do I HOLD?", which is narrower than "what can I reach?": your own authorship, and grants held by an org you belong to rather than by you, leave nothing under your own id — an empty holder list means "no grants of your own", never "no access". role=issuer answers "who did I share with / what did I mint?" after artifact_share. Filter by scopeKind, scopeId, and health (defaults to active). Rails you lack the operator-key read scope for are named in unreadableRails. Cursor-paginated.',
45
+ mcp: 'List grants this delegate holds — or, with role=issuer, grants this delegate caused (author-shares and delegate children). Context (chat/agreement/org/artifact/task), connection, wallet, consent, and inbox — canonical grants (grantId, scope, caveats, expiresAt, health; no content or credentials). A `task` scope is a BRANCH of a work graph: the named task and everything under it, which is what holding a pass on a job looks like. Default role=holder answers "what grants do I HOLD?", which is narrower than "what can I reach?": your own authorship, and grants held by an org you belong to rather than by you, leave nothing under your own id — an empty holder list means "no grants of your own", never "no access". After ziggs_artifact_share, pass role=issuer (and usually scopeKind=["artifact"]) to recover grantIds and revoke with ziggs_context_revoke_grant. Filter by scopeKind, scopeId, and health (defaults to active). Rails you lack the operator-key read scope for are named in unreadableRails. Cursor-paginated.',
45
46
  },
46
47
  annotation: 'read-only',
47
48
  params: {
@@ -1,8 +1,8 @@
1
1
  export { type CapabilitySurface, type CapabilityAnnotation, type CapabilityParam, type CapabilityEnv, type CapabilityDefinition, fullCreds, rethrowWithContext, } from './types.js';
2
- export { nextCall, peerAgentId, type NextCall } from './nextCall.js';
2
+ export { nextCall, peerPrincipalId, peerPrincipalForCourier, type NextCall, } from './nextCall.js';
3
3
  export { AGREEMENT_VERB_CAPABILITIES, agreementBuyCapability, agreementBidCapability, agreementBrokerCapability, agreementRequestCapability, agreementOfferCapability, agreementHandoffCapability, } from './agreementVerbs.js';
4
4
  export { PAYMENT_CAPABILITIES, paymentBalanceCapability } from './payments.js';
5
- export { LINK_CAPABILITIES, createLinkInviteCapability, listLinksCapability, proposeLinkCapability, linkIsReachOnly, } from './links.js';
5
+ export { LINK_CAPABILITIES, listLinksCapability, proposeLinkCapability, linkIsReachOnly, } from './links.js';
6
6
  export { AGREEMENT_CAPABILITIES, agreementClaimCapability } from './agreements.js';
7
7
  export { AGREEMENT_PROPOSE_PROVIDER_ID_DESCRIPTION } from './proposeProviderId.js';
8
8
  export { MARKETPLACE_CAPABILITIES, marketplaceViewCapability } from './marketplace.js';
@@ -1,8 +1,8 @@
1
1
  export { fullCreds, rethrowWithContext, } from './types.js';
2
- export { nextCall, peerAgentId } from './nextCall.js';
2
+ export { nextCall, peerPrincipalId, peerPrincipalForCourier, } from './nextCall.js';
3
3
  export { AGREEMENT_VERB_CAPABILITIES, agreementBuyCapability, agreementBidCapability, agreementBrokerCapability, agreementRequestCapability, agreementOfferCapability, agreementHandoffCapability, } from './agreementVerbs.js';
4
4
  export { PAYMENT_CAPABILITIES, paymentBalanceCapability } from './payments.js';
5
- export { LINK_CAPABILITIES, createLinkInviteCapability, listLinksCapability, proposeLinkCapability, linkIsReachOnly, } from './links.js';
5
+ export { LINK_CAPABILITIES, listLinksCapability, proposeLinkCapability, linkIsReachOnly, } from './links.js';
6
6
  export { AGREEMENT_CAPABILITIES, agreementClaimCapability } from './agreements.js';
7
7
  export { AGREEMENT_PROPOSE_PROVIDER_ID_DESCRIPTION } from './proposeProviderId.js';
8
8
  export { MARKETPLACE_CAPABILITIES, marketplaceViewCapability } from './marketplace.js';
@@ -10,16 +10,27 @@ import { type CapabilityDefinition, type CapabilityEnv } from './types.js';
10
10
  * already exist (an older chat, an agreement, an org).
11
11
  */
12
12
  export declare function linkIsReachOnly(env: CapabilityEnv): string;
13
- export declare const createLinkInviteCapability: CapabilityDefinition;
14
13
  export declare const listLinksCapability: CapabilityDefinition;
15
14
  /**
16
- * A link proposed to an agent you can name.
15
+ * The one way to connect with someone.
17
16
  *
18
17
  * This used to ride the propose grammar as `engagementKind: "link"`, which put
19
18
  * bilateral trust on the same verb as commercial terms it has none of: no
20
- * price, no chat, no work. It belongs here, beside the invite and the list, so
21
- * links have one rail. `link_create_invite` is the same act when you do NOT
22
- * have an agent id and need a shareable page instead.
19
+ * price, no chat, no work. It moved here, and then absorbed the other two ways
20
+ * of doing the same thing: naming an agent id, naming an email — a form the
21
+ * backend had but no tool ever sent, so an assistant could not name a person at
22
+ * all — and minting a share link.
23
+ *
24
+ * `to` is an email or an agent id, and the link is with the PERSON either way.
25
+ * An id is only a way to find its owner: naming one specific assistant is
26
+ * fragile, because people connect more than one, swap them, and someone who has
27
+ * connected none has no agent to name. An agent that answers to an ORG is
28
+ * refused rather than resolved, since that would link the caller to whoever
29
+ * happens to own the org, who consented to nothing.
30
+ *
31
+ * The answer is constant. It never says whether the address belonged to anyone,
32
+ * whether the two were already connected, or whether this was a repeat — a
33
+ * response that distinguished those would be a way to check who has an account.
23
34
  */
24
35
  export declare const proposeLinkCapability: CapabilityDefinition;
25
36
  export declare const LINK_CAPABILITIES: CapabilityDefinition[];
@@ -1,5 +1,5 @@
1
1
  import { createLink, listAgreements } from '../http/AgreementClient.js';
2
- import { nextCall, peerAgentId } from './nextCall.js';
2
+ import { nextCall, peerPrincipalForCourier, } from './nextCall.js';
3
3
  import { fullCreds } from './types.js';
4
4
  const DEFAULT_WEB_URL = 'https://ziggsai.com';
5
5
  function webAppOrigin(env) {
@@ -31,8 +31,8 @@ function inviteShareUrl(env, agreementId) {
31
31
  */
32
32
  export function linkIsReachOnly(env) {
33
33
  return env.surface === 'mcp'
34
- ? 'A link is reach-only — it shares no context on its own. Open a chat with the peer (ziggs_chat_open, participantId = peer agent id): that admits both linked agents to read and post from then on. To share context that already exists, issue a grant on it with ziggs_context_issue_grant or share a slice of one you hold with ziggs_context_delegate.'
35
- : 'A link is reach-only — it shares no context on its own. Opening a chat with the peer admits both linked agents to it from then on. To share context that already exists, share a slice of a grant you hold with context_delegate, or ask the peer owner to issue one.';
34
+ ? 'A link is reach-only — it shares no context on its own. Open a chat with the PERSON on the other side (ziggs_chat_open, participantId = the peer principal): delivery lands in their mailbox and whoever runs that side picks it up, which is not yours to choose. Opening the room admits both sides to read and post from then on. To share context that already exists, issue a grant on it with ziggs_context_issue_grant or share a slice of one you hold with ziggs_context_delegate.'
35
+ : 'A link is reach-only — it shares no context on its own. Open a chat with the PERSON on the other side; delivery lands in their mailbox and whoever runs that side picks it up, which is not yours to choose. Opening the room admits both sides to it from then on. To share context that already exists, share a slice of a grant you hold with context_delegate, or ask the peer owner to issue one.';
36
36
  }
37
37
  const LINK_STATUSES = ['active', 'open', 'cancelled', 'all'];
38
38
  /**
@@ -41,62 +41,20 @@ const LINK_STATUSES = ['active', 'open', 'cancelled', 'all'];
41
41
  * the limit instead of letting an agent discover it by getting a 400.
42
42
  */
43
43
  const MAX_LINK_INVITE_CLAIMS = 25;
44
- ///1022 — the link rail shrank to two tools. Links are agreements, so
45
- // the agreement verbs carry the rest: request a direct link with
46
- // link_propose (counterparty = the agent id), claim
44
+ // Two tools. Links are agreements, so the agreement verbs carry the rest: claim
47
45
  // an invite with agreement_claim, end a link with agreement_revoke.
48
- export const createLinkInviteCapability = {
49
- key: 'link_create_invite',
50
- names: { sdk: 'link_create_invite', mcp: 'ziggs_link_create_invite' },
51
- title: 'Create a link invite',
52
- descriptions: {
53
- 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: link_propose with counterparty = that id.",
54
- 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_link_propose with counterparty = that id.',
55
- },
56
- annotation: 'write',
57
- params: {
58
- message: {
59
- type: 'string',
60
- description: 'Optional note shown to whoever opens the invite (agreement description)',
61
- },
62
- maxClaims: {
63
- type: 'number',
64
- description: `How many people may claim this one link (default 1, max ${MAX_LINK_INVITE_CLAIMS}). Each claimer forms their own separate connection with you — this does not create a group.`,
65
- },
66
- },
67
- needsAgentId: true,
68
- handler: async (args, env) => {
69
- const maxClaims = args['maxClaims'];
70
- const { agreement } = await createLink({
71
- description: args['message'],
72
- ...(maxClaims == null ? {} : { maxClaims }),
73
- }, fullCreds(env));
74
- const shareUrl = inviteShareUrl(env, agreement.agreementId);
75
- const seats = agreement.linkInvite?.maxClaims ?? 1;
76
- const seatNote = seats > 1
77
- ? `valid 7 days and claimable by up to ${seats} people (each gets their own separate connection — not a group)`
78
- : 'single-use and valid 7 days';
79
- return {
80
- status: 'open',
81
- inviteId: agreement.agreementId,
82
- shareUrl,
83
- maxClaims: seats,
84
- seatsRemaining: seats - (agreement.linkInvite?.claimsUsed ?? 0),
85
- message: env.surface === 'mcp'
86
- ? `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's side of the link is one of their agents — their assistant by default — and it must be running before the link carries anything.`
87
- : `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. Their side of the link is one of their agents (their assistant by default), and it must be running before the link carries anything. No agent id needed on either side. Revoke with agreement_revoke to disable.`,
88
- agreement,
89
- };
90
- },
91
- sdkOptions: { isAgreementCreation: true },
92
- };
46
+ //
47
+ // There were three. `link_create_invite` was the same act as `link_propose` with
48
+ // a different way of naming the other side, and an assistant had to choose
49
+ // between them before knowing which applied. One verb takes an email, an agent
50
+ // id, or nothing.
93
51
  export const listLinksCapability = {
94
52
  key: 'link_list',
95
53
  names: { sdk: 'link_list', mcp: 'ziggs_link_list' },
96
54
  title: 'List your links',
97
55
  descriptions: {
98
- sdk: 'List link agreements for this agent — bilateral agent-to-agent trust relationships (GET /agreements?engagementKind=link). Any agent can link with any other agent. Defaults to ACTIVE links only; pass status to see pending proposals ("open") or revoked ones ("cancelled"). Each item is a link summary: agreementId, status, proposalStatus, parties. Request a new link with link_propose, or link_create_invite when you lack the agent id; end one with agreement_revoke.',
99
- mcp: 'List link agreements for this agent — bilateral agent-to-agent trust relationships, NOT third-party service connections (see ziggs_connection_list for those) (GET /agreements?engagementKind=link). Any agent can link with any other agent. Defaults to ACTIVE links only; pass status to see pending proposals ("open") or revoked ones ("cancelled"). Each item is a link summary: agreementId, status, proposalStatus, parties.creatorAgent (requester), parties.providerAgent (target), parties.proposedTo (target owner). Approve pending links via ziggs_agreement_respond; request a new one with ziggs_link_propose, or ziggs_link_create_invite when you lack the agent id; end one with ziggs_agreement_revoke.',
56
+ sdk: 'List link agreements for this agent — bilateral agent-to-agent trust relationships (GET /agreements?engagementKind=link). Any agent can link with any other agent. Defaults to ACTIVE links only; pass status to see pending proposals ("open") or revoked ones ("cancelled"). Each item is a link summary: agreementId, status, proposalStatus, parties. The two principals on a link are the two PEOPLE it connects; the actor slots record which agent carried the paperwork and are not addresses. Connect with someone new using link_propose; end a link with agreement_revoke.',
57
+ mcp: 'List link agreements for this agent — bilateral agent-to-agent trust relationships, NOT third-party service connections (see ziggs_connection_list for those) (GET /agreements?engagementKind=link). Any agent can link with any other agent. Defaults to ACTIVE links only; pass status to see pending proposals ("open") or revoked ones ("cancelled"). Each item is a link summary: agreementId, status, proposalStatus, and parties — where parties.creator.principal and parties.provider.principal are the two PEOPLE the link connects. The actor slots record which agent carried the paperwork and are not addresses: message the person. Approve pending links via ziggs_agreement_respond; connect with someone new using ziggs_link_propose; end one with ziggs_agreement_revoke.',
100
58
  },
101
59
  annotation: 'read-only',
102
60
  params: {
@@ -119,15 +77,28 @@ export const listLinksCapability = {
119
77
  const active = links.filter((a) => a.status === 'active');
120
78
  // A link grants reach and nothing else, so the move after seeing one is
121
79
  // always the same: open a room with that peer, or share context explicitly.
122
- // Both were named in prose, and the peer id had to be dug out of
123
- // `parties` by whoever read it — while being right here.
80
+ // Both were named in prose, and the peer id had to be dug out of `parties`
81
+ // by whoever read it — while being right here.
82
+ //
83
+ // The peer is a PERSON. This used to pre-fill the peer's `actor`, which is
84
+ // courier info and null on most links, so the suggestion either named an
85
+ // agent that is no longer a door or silently disappeared. It fills in only
86
+ // when this agent's own stamp identifies which side is ours; otherwise the
87
+ // peer is named in prose and read off the rows below, because a pre-filled
88
+ // call naming the wrong party would get run.
124
89
  const readPlan = [];
90
+ let unnamedPeers = 0;
125
91
  for (const link of active.slice(0, 3)) {
126
- const peer = peerAgentId(link.parties, env.creds.agentId);
127
- if (!peer)
92
+ const peer = peerPrincipalForCourier(link.parties, env.creds.agentId);
93
+ if (!peer) {
94
+ unnamedPeers += 1;
128
95
  continue;
96
+ }
129
97
  readPlan.push(nextCall(env, 'chat_open', { participantId: peer }, 'open a room with this linked peer — a link alone carries no context'));
130
98
  }
99
+ if (unnamedPeers > 0) {
100
+ readPlan.push(nextCall(env, 'chat_open', undefined, 'open a room with a linked peer: participantId is the other principal on the link (parties.creator / parties.provider), which is a person'));
101
+ }
131
102
  if (active.length) {
132
103
  readPlan.push(nextCall(env, 'context_issue_grant', undefined, 'share context that already exists: a link does not share any on its own'));
133
104
  }
@@ -142,53 +113,70 @@ export const listLinksCapability = {
142
113
  sdkOptions: { isGenericFallback: true },
143
114
  };
144
115
  /**
145
- * A link proposed to an agent you can name.
116
+ * The one way to connect with someone.
146
117
  *
147
118
  * This used to ride the propose grammar as `engagementKind: "link"`, which put
148
119
  * bilateral trust on the same verb as commercial terms it has none of: no
149
- * price, no chat, no work. It belongs here, beside the invite and the list, so
150
- * links have one rail. `link_create_invite` is the same act when you do NOT
151
- * have an agent id and need a shareable page instead.
120
+ * price, no chat, no work. It moved here, and then absorbed the other two ways
121
+ * of doing the same thing: naming an agent id, naming an email — a form the
122
+ * backend had but no tool ever sent, so an assistant could not name a person at
123
+ * all — and minting a share link.
124
+ *
125
+ * `to` is an email or an agent id, and the link is with the PERSON either way.
126
+ * An id is only a way to find its owner: naming one specific assistant is
127
+ * fragile, because people connect more than one, swap them, and someone who has
128
+ * connected none has no agent to name. An agent that answers to an ORG is
129
+ * refused rather than resolved, since that would link the caller to whoever
130
+ * happens to own the org, who consented to nothing.
131
+ *
132
+ * The answer is constant. It never says whether the address belonged to anyone,
133
+ * whether the two were already connected, or whether this was a repeat — a
134
+ * response that distinguished those would be a way to check who has an account.
152
135
  */
153
136
  export const proposeLinkCapability = {
154
137
  key: 'link_propose',
155
138
  names: { sdk: 'link_propose', mcp: 'ziggs_link_propose' },
156
- title: 'Propose a link to an agent',
139
+ title: 'Connect with someone',
157
140
  descriptions: {
158
- sdk: "Propose bilateral trust to an agent you can name — no chat, no money, no work. Approval is routed to that agent's owner, because a person decides who their delegate trusts. When you do NOT have the agent id, use link_create_invite for a shareable page instead.",
159
- mcp: "Propose bilateral trust to an agent you can name (NOT a third-party service connection — see ziggs_connection_list for that). No chat, no money, no work: a link is reach, and reach only. The server routes approval to that agent's owner human, because a person decides who their delegate trusts, so the id in the response may differ from the one you passed; `note` says so when it does. Approve via ziggs_agreement_respond. When you do NOT have the agent id, use ziggs_link_create_invite for a shareable page instead.",
141
+ sdk: "Connect with a person: pass their email, or the id of an agent that answers to them, and Ziggs delivers the invitation. The link is with the PERSON either way — an agent id is only a way to find its owner, and an agent that answers to an organisation is refused, because a link connects two people. Leave `to` out to get a share link you hand over yourself. A link is reach and nothing else: no chat, no money, no work. The answer is the same every time, so it never tells you whether that address has an account.",
142
+ mcp: "Connect with a person (NOT a third-party service connection — see ziggs_connection_list for that): pass their email, or the id of an agent that answers to them, and Ziggs delivers the invitation. The link is with the PERSON either way — an agent id is only a way to find its owner, and an agent answering to an organisation is refused, because a link connects two people. Leave `to` out and you get shareUrl: one public page that is the whole invite, which you hand to your human to paste wherever they like. Someone with no Ziggs account signs up from that page and accepts in the same step, and their assistant handed the same URL reads the connect instructions off it. A link is reach and nothing else: no chat, no money, no work. The answer is identical every time, so it never reveals whether an address has an account. Approve links proposed to you with ziggs_agreement_respond; end one with ziggs_agreement_revoke.",
160
143
  },
161
144
  annotation: 'write',
162
145
  params: {
163
- counterparty: {
146
+ to: {
164
147
  type: 'string',
165
- required: true,
166
- description: 'Agent id to link with.',
148
+ description: "The person to connect with: their email address, or the id of an agent that answers to them. Leave it out for a share link you pass on yourself.",
167
149
  },
168
150
  message: {
169
151
  type: 'string',
170
- description: 'Optional note shown to whoever approves it.',
152
+ description: 'Optional note shown to whoever is asked to accept it.',
153
+ },
154
+ maxClaims: {
155
+ type: 'number',
156
+ description: `Share links only: how many people may claim this one link (default 1, max ${MAX_LINK_INVITE_CLAIMS}). Each claimer forms their own separate connection with you — this does not create a group.`,
171
157
  },
172
158
  },
173
159
  needsAgentId: true,
174
160
  handler: async (args, env) => {
175
- const counterparty = args['counterparty'];
176
- const { agreement } = await createLink({
177
- targetAgentId: counterparty,
161
+ const to = args['to']?.trim();
162
+ const maxClaims = args['maxClaims'];
163
+ const { shareUrl } = await createLink({
164
+ ...(to ? { to } : {}),
178
165
  ...(args['message'] ? { description: args['message'] } : {}),
166
+ ...(maxClaims == null || to ? {} : { maxClaims }),
179
167
  }, fullCreds(env));
180
- // The owner rewrite is surfaced rather than left to be noticed: an agent
181
- // that passed one id and reads back another otherwise concludes it was
182
- // ignored.
183
- const routedTo = agreement?.parties?.proposedTo;
184
- const note = routedTo && routedTo !== counterparty
185
- ? `Approval routed to the target agent's owner (${routedTo}): a person decides who their delegate trusts. You passed ${counterparty}.`
186
- : undefined;
168
+ // Two messages, chosen by what the CALLER passed rather than by what came
169
+ // back. That is the whole discipline: the caller knows whether it named
170
+ // somebody, so branching on it discloses nothing, while branching on
171
+ // anything the server learned about the target would.
187
172
  return {
188
- agreement,
189
- ...(note ? { note } : {}),
173
+ status: 'sent',
174
+ shareUrl,
175
+ message: to
176
+ ? `Invitation sent to ${to}. You will not be told whether they already had an account, whether you were already connected, or whether this repeated an earlier invitation — the answer is the same in every case, on purpose. It becomes a live connection when they accept. ${linkIsReachOnly(env)}`
177
+ : `Share link created, valid 7 days. Give your human shareUrl and nothing else: it is the whole invite. ${linkIsReachOnly(env)}`,
190
178
  readPlan: [
191
- nextCall(env, 'link_list', { status: 'open' }, 'check whether it has been approved yet'),
179
+ nextCall(env, 'link_list', { status: 'open' }, 'check whether it has been accepted yet'),
192
180
  ],
193
181
  };
194
182
  },
@@ -196,6 +184,5 @@ export const proposeLinkCapability = {
196
184
  };
197
185
  export const LINK_CAPABILITIES = [
198
186
  proposeLinkCapability,
199
- createLinkInviteCapability,
200
187
  listLinksCapability,
201
188
  ];
@@ -47,9 +47,9 @@ function toListingRow(a, kind) {
47
47
  ...(terms.maxExecutions != null ? { maxExecutions: terms.maxExecutions } : {}),
48
48
  // Who does the work on an offer, who is paying on a request. The other slot is
49
49
  // the open one you would be filling by claiming, so it carries no name yet.
50
- ...(named(parties.providerAgent) ? { providerAgent: parties.providerAgent } : {}),
51
- ...(named(parties.provider) ? { provider: parties.provider } : {}),
52
- ...(named(parties.payer) ? { payer: parties.payer } : {}),
50
+ ...(named(parties.provider?.actor) ? { providerAgent: parties.provider.actor } : {}),
51
+ ...(named(parties.provider?.principal) ? { provider: parties.provider.principal } : {}),
52
+ ...(named(parties.payer?.principal) ? { payer: parties.payer.principal } : {}),
53
53
  // Access the job cannot be done without — worth knowing before claiming it.
54
54
  ...(requiredConnections.length ? { requiredConnections } : {}),
55
55
  createdAt: a?.createdAt,
@@ -1,3 +1,4 @@
1
+ import type { AgreementParties } from '../types.js';
1
2
  import type { CapabilityEnv } from './types.js';
2
3
  /**
3
4
  * One runnable next step: a tool name, pre-filled arguments, and why.
@@ -32,13 +33,43 @@ export interface NextCall {
32
33
  */
33
34
  export declare function nextCall(env: CapabilityEnv, capabilityKey: string, args: Record<string, unknown> | undefined, why: string): NextCall;
34
35
  /**
35
- * The other party in a two-party agreement, from the perspective of `selfId`.
36
+ * The other PRINCIPAL in a two-party agreement, from the perspective of
37
+ * `selfId`.
38
+ *
39
+ * This was `peerAgentId`, and it read the `actor` columns — the agents that
40
+ * carried the paperwork. Those became courier info when a link was re-keyed to
41
+ * the two people it belongs to, and on most links they are null, so the hint it
42
+ * fed either pre-filled a call with an agent that is no longer a door or
43
+ * silently vanished because the helper returned nothing.
44
+ *
45
+ * Named for the SLOT, not for what a link happens to put in it. On a link both
46
+ * principals are guaranteed to be people, because links are person-only and
47
+ * both sides are userIds by construction — but the same slots hold an org or an
48
+ * agent on other engagement kinds, so a caller who read "person" here and
49
+ * trusted it on a hire would be wrong. The person guarantee is a link-only
50
+ * property; ask the kind before relying on it.
36
51
  *
37
52
  * Returns null rather than guessing when the row does not identify one, because
38
53
  * a pre-filled call naming the wrong counterparty is worse than no pre-filled
39
54
  * call: the caller would run it, and it would do something they did not ask for.
40
55
  */
41
- export declare function peerAgentId(parties: {
42
- creatorAgent?: string | null;
43
- providerAgent?: string | null;
44
- } | undefined, selfId: string | undefined): string | null;
56
+ export declare function peerPrincipalId(parties: AgreementParties | undefined, selfId: string | undefined): string | null;
57
+ /**
58
+ * The peer's principal, located by finding MY side rather than by knowing my
59
+ * own principal id.
60
+ *
61
+ * An agent knows its own agent id and not the id of the person it answers for,
62
+ * so it cannot ask {@link peerPrincipalId} which of two people it is. What it
63
+ * CAN recognise is its own courier stamp: if my agent id is in one side's
64
+ * `actor`, that side is mine and the other side's principal is the peer.
65
+ *
66
+ * Note the difference from the bug this replaced. Reading the peer's `actor` as
67
+ * the peer's address was wrong — those slots are courier info, null on most
68
+ * links, and never a door. Reading MY OWN `actor` to work out which side I am on
69
+ * is sound, because I am comparing against an id I hold.
70
+ *
71
+ * Returns null when neither side carries my stamp, which is the common case for
72
+ * a link two people formed from the web. No pre-filled call is the right answer
73
+ * there: naming the wrong counterparty would get run.
74
+ */
75
+ export declare function peerPrincipalForCourier(parties: AgreementParties | undefined, myAgentId: string | undefined): string | null;