@ziggs-ai/api-client 0.12.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.
@@ -106,6 +106,17 @@ const AUDIENCE_PARAM = {
106
106
  required: true,
107
107
  description: 'everyone is fully public; org is your active organisation only.',
108
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
+ };
109
120
  const COUNTERPARTY_PARAM = {
110
121
  type: 'string',
111
122
  required: true,
@@ -235,6 +246,7 @@ export const agreementRequestCapability = {
235
246
  params: {
236
247
  audience: AUDIENCE_PARAM,
237
248
  ...TERMS_PARAMS,
249
+ match: MATCH_PARAM,
238
250
  mandateAgreementId: MANDATE_PARAM,
239
251
  },
240
252
  needsAgentId: true,
@@ -245,6 +257,7 @@ export const agreementRequestCapability = {
245
257
  chatId: '',
246
258
  audience: args['audience'],
247
259
  engagementKind: engagementKindFrom(args),
260
+ match: typeof args['match'] === 'string' ? args['match'] : undefined,
248
261
  }, fullCreds(env));
249
262
  return { agreement, readPlan: publishedNext(env) };
250
263
  },
@@ -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: {
@@ -44,6 +44,12 @@ export interface ProposeDirectInput extends ProposeTerms {
44
44
  */
45
45
  export type ProposeBroadcastInput = Omit<ProposeDirectInput, 'proposedTo'> & {
46
46
  audience?: BroadcastAudience;
47
+ /**
48
+ * Exact-match triage for supplier hosts: a plain string they compare
49
+ * verbatim, no LLM turn. Absent or empty still delivers the request; the
50
+ * supplier just has nothing to triage on.
51
+ */
52
+ match?: string;
47
53
  };
48
54
  /**
49
55
  * A trust link's agreement, as every caller sees it.
@@ -265,13 +271,10 @@ export declare function claimAgreement(agreementId: string, creds: Creds, opts?:
265
271
  kind?: ClaimedKind;
266
272
  }>;
267
273
  /**
268
- * Link types a caller may ASK for — the server's `CALLER_LINK_TYPES`, not its
269
- * full `LINK_TYPES`. `space` is deliberately absent: an agreement space gets
270
- * exactly one system-minted room, and letting a request name that type would
271
- * burn the root's single slot on an unrelated chat. Shorter than the server
272
- * union on purpose, which is why this list carries the reason with it.
274
+ * Link types a caller may ask for. `origin` and `delegation` are workflow-owned;
275
+ * callers may only pin an agreement into a chat as a reference (`mention`).
273
276
  */
274
- export declare const CHAT_LINK_TYPES: readonly ["origin", "mention", "delegation", "join"];
277
+ export declare const CHAT_LINK_TYPES: readonly ["mention"];
275
278
  export type ChatLinkType = (typeof CHAT_LINK_TYPES)[number];
276
279
  export declare function linkAgreementToChat(agreementId: string, chatId: string, linkType: ChatLinkType | undefined, creds: Creds): Promise<unknown | null>;
277
280
  export declare function getChatsForAgreement(agreementId: string, creds: Creds): Promise<unknown[]>;
@@ -279,10 +282,6 @@ export declare const ARTIFACT_LINK_TYPES: readonly ["produced", "referenced"];
279
282
  export type ArtifactLinkType = (typeof ARTIFACT_LINK_TYPES)[number];
280
283
  export declare function linkArtifactToAgreement(agreementId: string, artifactId: string, linkType: ArtifactLinkType | undefined, creds: Creds): Promise<unknown | null>;
281
284
  export declare function getArtifactsForAgreement(agreementId: string, creds: Creds): Promise<unknown[]>;
282
- export declare const AGREEMENT_USER_ROLES: readonly ["payer", "provider", "participant", "observer"];
283
- export type UserRole = (typeof AGREEMENT_USER_ROLES)[number];
284
- export declare function linkUserToAgreement(agreementId: string, userId: string, role: UserRole, creds: Creds): Promise<unknown | null>;
285
- export declare function getUsersForAgreement(agreementId: string, creds: Creds): Promise<unknown[]>;
286
285
  export declare class AgreementClient {
287
286
  private creds;
288
287
  /**
@@ -319,6 +318,4 @@ export declare class AgreementClient {
319
318
  listChats(id: string): Promise<unknown[]>;
320
319
  linkArtifact(id: string, artifactId: string, linkType?: ArtifactLinkType): Promise<unknown>;
321
320
  listArtifacts(id: string): Promise<unknown[]>;
322
- linkUser(id: string, userId: string, role: UserRole): Promise<unknown>;
323
- listUsers(id: string): Promise<unknown[]>;
324
321
  }
@@ -514,18 +514,10 @@ export async function claimAgreement(agreementId, creds, opts = {}) {
514
514
  // Chat links
515
515
  // ---------------------------------------------------------------------------
516
516
  /**
517
- * Link types a caller may ASK for — the server's `CALLER_LINK_TYPES`, not its
518
- * full `LINK_TYPES`. `space` is deliberately absent: an agreement space gets
519
- * exactly one system-minted room, and letting a request name that type would
520
- * burn the root's single slot on an unrelated chat. Shorter than the server
521
- * union on purpose, which is why this list carries the reason with it.
517
+ * Link types a caller may ask for. `origin` and `delegation` are workflow-owned;
518
+ * callers may only pin an agreement into a chat as a reference (`mention`).
522
519
  */
523
- export const CHAT_LINK_TYPES = [
524
- 'origin',
525
- 'mention',
526
- 'delegation',
527
- 'join',
528
- ];
520
+ export const CHAT_LINK_TYPES = ['mention'];
529
521
  export async function linkAgreementToChat(agreementId, chatId, linkType = 'mention', creds) {
530
522
  if (!agreementId || !chatId)
531
523
  return null;
@@ -618,59 +610,6 @@ export async function getArtifactsForAgreement(agreementId, creds) {
618
610
  return [];
619
611
  }
620
612
  }
621
- // ---------------------------------------------------------------------------
622
- // User links
623
- // ---------------------------------------------------------------------------
624
- export const AGREEMENT_USER_ROLES = [
625
- 'payer',
626
- 'provider',
627
- 'participant',
628
- 'observer',
629
- ];
630
- export async function linkUserToAgreement(agreementId, userId, role, creds) {
631
- if (!agreementId || !userId || !role)
632
- return null;
633
- assertCreds(creds, 'link user to agreement');
634
- try {
635
- const res = await fetch(`${getAgreementBaseUrl()}/${agreementId}/users`, {
636
- method: 'POST',
637
- headers: buildHeaders(creds),
638
- body: JSON.stringify({ userId, role }),
639
- });
640
- if (!res.ok) {
641
- const body = await res.text().catch(() => '');
642
- runtimeLog.warn('AgreementClient', `⚠️ Link user failed: ${res.status} ${res.statusText} ${body?.slice(0, 200)}`);
643
- return null;
644
- }
645
- return await res.json().catch(() => null);
646
- }
647
- catch (e) {
648
- runtimeLog.warn('AgreementClient', `⚠️ Link user failed: ${e.message}`);
649
- return null;
650
- }
651
- }
652
- export async function getUsersForAgreement(agreementId, creds) {
653
- if (!agreementId)
654
- return [];
655
- assertCreds(creds, 'get users for agreement');
656
- try {
657
- const res = await fetch(`${getAgreementBaseUrl()}/${agreementId}/users`, {
658
- method: 'GET',
659
- headers: buildHeaders(creds),
660
- });
661
- if (!res.ok) {
662
- const body = await res.text().catch(() => '');
663
- runtimeLog.warn('AgreementClient', `⚠️ Get users failed: ${res.status} ${res.statusText} ${body?.slice(0, 200)}`);
664
- return [];
665
- }
666
- const data = await res.json().catch(() => null);
667
- return Array.isArray(data?.['users']) ? data['users'] : [];
668
- }
669
- catch (e) {
670
- runtimeLog.warn('AgreementClient', `⚠️ Get users failed: ${e.message}`);
671
- return [];
672
- }
673
- }
674
613
  export class AgreementClient {
675
614
  creds;
676
615
  /**
@@ -703,6 +642,4 @@ export class AgreementClient {
703
642
  listChats(id) { return getChatsForAgreement(id, this.creds); }
704
643
  linkArtifact(id, artifactId, linkType) { return linkArtifactToAgreement(id, artifactId, linkType ?? 'produced', this.creds); }
705
644
  listArtifacts(id) { return getArtifactsForAgreement(id, this.creds); }
706
- linkUser(id, userId, role) { return linkUserToAgreement(id, userId, role, this.creds); }
707
- listUsers(id) { return getUsersForAgreement(id, this.creds); }
708
645
  }
@@ -1,10 +1,16 @@
1
+ import { CONTEXT_GRANT_SCOPE_KINDS } from './grants.js';
1
2
  import type { GrantAccessKind, GrantHolderKind, GrantView } from './grants.js';
2
3
  /**
3
- * added `artifact` — the narrowest context scope: one specific artifact,
4
- * shared without sharing any chat or agreement it sits in. Still the context
5
- * rail, not a new grant primitive.
4
+ * The context rail's scopes. `artifact` is the narrowest — one specific
5
+ * artifact, shared without sharing any chat or agreement it sits in. `task` is
6
+ * a branch of a work graph: the named task and everything under it. Both are
7
+ * still the context rail, not new grant primitives.
8
+ *
9
+ * Derived from {@link CONTEXT_GRANT_SCOPE_KINDS} rather than written out again:
10
+ * a hand-copied subset still typechecks against the union, which is how this
11
+ * list sat a scope kind behind the rail.
6
12
  */
7
- export type ContextGrantScopeKind = 'chat' | 'agreement' | 'org' | 'artifact';
13
+ export type ContextGrantScopeKind = (typeof CONTEXT_GRANT_SCOPE_KINDS)[number];
8
14
  export type ContextTemporal = 'from-now' | 'from-start';
9
15
  export interface ContextGrantScope {
10
16
  kind: ContextGrantScopeKind;
@@ -23,21 +23,6 @@ export interface PullOffersOptions {
23
23
  since?: string;
24
24
  }
25
25
  export declare function pullOffers(options: PullOffersOptions | undefined, creds: Creds): Promise<Agreement[]>;
26
- export interface PublishRequestPayload {
27
- /** See PublishOfferPayload.billing. */
28
- billing?: 'total' | 'per_task';
29
- description: string;
30
- chatId?: string;
31
- payerId?: string;
32
- price?: number;
33
- lifecycle?: string;
34
- expiresAt?: string;
35
- maxExecutions?: number;
36
- agreementDescription?: string;
37
- /** Broadcast audience: 'everyone' (default, fully public) or 'org' (members of your active org only). */
38
- audience?: BroadcastAudience;
39
- }
40
- export declare function publishRequest(payload: PublishRequestPayload, creds: Creds): Promise<Agreement>;
41
26
  export interface PullRequestsOptions {
42
27
  limit?: number;
43
28
  since?: string;
@@ -52,6 +37,5 @@ export declare class MarketplaceClient {
52
37
  constructor(operatorKey: string, agentId?: string);
53
38
  publishOffer(payload: PublishOfferPayload): Promise<Agreement>;
54
39
  pullOffers(options?: PullOffersOptions): Promise<Agreement[]>;
55
- publishRequest(payload: PublishRequestPayload): Promise<Agreement>;
56
40
  pullRequests(options?: PullRequestsOptions): Promise<Agreement[]>;
57
41
  }
@@ -52,22 +52,6 @@ export async function pullOffers(options, creds) {
52
52
  const data = await res.json().catch(() => null);
53
53
  return data?.['offers'] ?? [];
54
54
  }
55
- export async function publishRequest(payload, creds) {
56
- assertCreds(creds, 'request publish');
57
- const res = await fetch(`${getMarketplaceBaseUrl()}/requests/publish`, {
58
- method: 'POST',
59
- headers: buildHeaders(creds),
60
- body: JSON.stringify(payload || {}),
61
- });
62
- if (!res.ok) {
63
- const body = await res.text().catch(() => '');
64
- throwApiError(res, body, `Request publish failed: ${res.status}`);
65
- }
66
- const data = await res.json().catch(() => null);
67
- if (!data?.['agreement'])
68
- throw new Error('Request publish returned no agreement');
69
- return shapeAgreement(data['agreement']);
70
- }
71
55
  export async function pullRequests(options, creds) {
72
56
  assertCreds(creds, 'request pull');
73
57
  const params = new URLSearchParams();
@@ -102,6 +86,5 @@ export class MarketplaceClient {
102
86
  }
103
87
  publishOffer(payload) { return publishOffer(payload, this.creds); }
104
88
  pullOffers(options) { return pullOffers(options, this.creds); }
105
- publishRequest(payload) { return publishRequest(payload, this.creds); }
106
89
  pullRequests(options) { return pullRequests(options, this.creds); }
107
90
  }
@@ -37,10 +37,24 @@ export interface CreateTaskData {
37
37
  planReviewTiming?: PlanReviewTiming;
38
38
  requireMidWorkPlanAck?: boolean;
39
39
  idempotencyKey?: string;
40
- /** Explicit delegation target — must be a party to the agreement; validated server-side. */
40
+ /**
41
+ * Explicit delegation target — must hold a grant on the agreement, so work
42
+ * can only be handed to someone that contract already reaches; validated
43
+ * server-side.
44
+ */
41
45
  assigneeId?: string;
42
46
  /** prior-step artifact handles consumed as structured input links. */
43
47
  inputArtifactIds?: string[];
48
+ /**
49
+ * Tasks this one waits for. The task is created immediately, but its assignee
50
+ * is not woken and cannot start it until every task named here is terminal.
51
+ * Declaring an edge needs read reach on the task you name.
52
+ *
53
+ * Declared rather than enacted: state the join up front and the platform
54
+ * wakes the joiner once, instead of a live agent polling for it and holding
55
+ * the shape in its own control flow where nothing else can see it.
56
+ */
57
+ waitsOn?: string[];
44
58
  }
45
59
  export declare function createTask(taskData: CreateTaskData, creds: Creds): Promise<Task>;
46
60
  export declare function getTask(taskId: string, creds: Creds): Promise<Task>;
@@ -36,9 +36,15 @@ export type GrantBasisKind = 'creation' | 'publication' | 'membership' | 'accept
36
36
  * stopped showing a whole scope kind after `artifact` was added; anything that
37
37
  * means "every context scope" should read this.
38
38
  */
39
- export declare const CONTEXT_GRANT_SCOPE_KINDS: readonly ["chat", "agreement", "org", "artifact"];
40
- /** Every scope kind across every rail: context + connection + payment. */
41
- export declare const GRANT_SCOPE_KINDS: readonly ["chat", "agreement", "org", "artifact", "connection", "wallet"];
39
+ export declare const CONTEXT_GRANT_SCOPE_KINDS: readonly ["chat", "agreement", "org", "artifact", "task"];
40
+ /**
41
+ * Every scope kind across every rail: context + connection + payment + consent
42
+ * + carrier. Mirrors the server's `GRANT_SCOPE_KINDS` (grants/grant-view.ts),
43
+ * which is the list `GET /grants?scopeKind=` validates against — a kind missing
44
+ * here cannot be asked for at all, which is the failure the comment above
45
+ * describes.
46
+ */
47
+ export declare const GRANT_SCOPE_KINDS: readonly ["chat", "agreement", "org", "artifact", "task", "connection", "wallet", "consent", "inbox"];
42
48
  export type GrantScopeKind = (typeof GRANT_SCOPE_KINDS)[number];
43
49
  export interface GrantScopeView {
44
50
  kind: GrantScopeKind;
@@ -12,12 +12,25 @@ export const CONTEXT_GRANT_SCOPE_KINDS = [
12
12
  'org',
13
13
  // narrowest context scope
14
14
  'artifact',
15
+ // a branch of a work graph: the named task and everything under it, now and
16
+ // in future. Holding one is what being in a job means.
17
+ 'task',
15
18
  ];
16
- /** Every scope kind across every rail: context + connection + payment. */
19
+ /**
20
+ * Every scope kind across every rail: context + connection + payment + consent
21
+ * + carrier. Mirrors the server's `GRANT_SCOPE_KINDS` (grants/grant-view.ts),
22
+ * which is the list `GET /grants?scopeKind=` validates against — a kind missing
23
+ * here cannot be asked for at all, which is the failure the comment above
24
+ * describes.
25
+ */
17
26
  export const GRANT_SCOPE_KINDS = [
18
27
  ...CONTEXT_GRANT_SCOPE_KINDS,
19
28
  'connection',
20
29
  'wallet',
30
+ // approval authority — who may answer a slot
31
+ 'consent',
32
+ // carrier grants — who reads whose mailbox
33
+ 'inbox',
21
34
  ];
22
35
  /** Value of the first caveat of `type` on a grant, or undefined. */
23
36
  export function grantCaveat(grant, type) {
@@ -1,5 +1,5 @@
1
1
  import type { Agreement, Creds } from '../types.js';
2
- import { claimOpenAgreement, delegateAgreement, getMyAgreements, pullOffers } from '../http/index.js';
2
+ import { claimOpenAgreement, delegateAgreement, getMyAgreements, pullOffers, type DelegateContextGrantResult, type GrantView } from '../http/index.js';
3
3
  /** Minimal relay step shape — keep field names aligned with agents/coordinators/relayTypes.ts */
4
4
  export interface RelayStepInput {
5
5
  stepId: string;
@@ -21,19 +21,72 @@ export interface RelayPayloadShape {
21
21
  }
22
22
  export type ProvisionMethod = 'existing' | 'claim' | 'delegate';
23
23
  export type StepProvisionStatus = 'active' | 'pending_approval';
24
+ /**
25
+ * What happened to the coordinator's pass on this worker contract.
26
+ *
27
+ * - `granted` — the coordinator may create work citing this contract.
28
+ * - `reused` — it already held one; nothing was minted.
29
+ * - `pending_approval` — the pass needs a person. An agent cannot MINT a root
30
+ * agreement grant at all: `POST /context/grants` requires the `context:grant`
31
+ * operator scope, which a delegate never holds ("no re-mint or grant will
32
+ * satisfy this"). So an initiator agent's only route is delegating the
33
+ * participation grant it holds — and handing a slice to a third party in
34
+ * another org opens a card that owner must approve.
35
+ * - `deferred` — the contract itself is not active yet, so there is no
36
+ * participation grant to delegate from. Provision again after it activates.
37
+ * - `failed` — named so the caller stops rather than kicking off a run whose
38
+ * branches will each be refused one at a time.
39
+ */
40
+ export type GrantProvisionStatus = 'granted' | 'reused' | 'pending_approval' | 'deferred' | 'failed';
41
+ export interface StepGrantProvision {
42
+ status: GrantProvisionStatus;
43
+ grantId?: string;
44
+ /** The approval card's agreement id, when a person has to say yes. */
45
+ approvalAgreementId?: string;
46
+ error?: string;
47
+ }
24
48
  export type ProvisionedRelayStep = RelayPayloadShape['steps'][number] & {
25
49
  provisionMethod: ProvisionMethod;
26
50
  status: StepProvisionStatus;
51
+ /**
52
+ * The coordinator's pass on this step's contract. Handing over an agreement
53
+ * id is not enough: membership in work is a grant, and the coordinator is not
54
+ * a party to this contract, so nothing about it reaches the coordinator
55
+ * otherwise.
56
+ */
57
+ grant: StepGrantProvision;
27
58
  };
28
59
  export interface RelayProvisionDeps {
29
60
  getMyAgreements: typeof getMyAgreements;
30
61
  pullOffers: typeof pullOffers;
31
62
  claimOpenAgreement: typeof claimOpenAgreement;
32
63
  delegateAgreement: typeof delegateAgreement;
64
+ /**
65
+ * Grants on one agreement scope. `role` picks which side: `holder` is what
66
+ * this initiator holds (the participation grant to delegate FROM), `issuer` is
67
+ * what it has already caused (so a re-provision reuses rather than re-mints).
68
+ */
69
+ listAgreementGrants: (args: {
70
+ agreementId: string;
71
+ role: 'holder' | 'issuer';
72
+ }, creds: Creds) => Promise<GrantView[]>;
73
+ delegateAgreementGrant: (args: {
74
+ parentGrantId: string;
75
+ holderId: string;
76
+ agreementId: string;
77
+ }, creds: Creds) => Promise<DelegateContextGrantResult>;
33
78
  }
34
79
  export interface ProvisionRelayWorkersInput {
35
80
  creds: Creds;
36
81
  hireAgreementId: string;
82
+ /**
83
+ * The coordinator that will create the work. Required, because provisioning a
84
+ * worker FOR a coordinator is two acts and this names who the second one is
85
+ * for: without an `agreement`-scope grant on each worker contract, the
86
+ * coordinator's very first `task_create` is refused with "holds no grant on
87
+ * agreement …" and every branch of the run fails the same way.
88
+ */
89
+ coordinatorId: string;
37
90
  chatId?: string;
38
91
  steps: RelayStepInput[];
39
92
  inputArtifactIds?: string[];
@@ -41,6 +94,11 @@ export interface ProvisionRelayWorkersInput {
41
94
  export interface ProvisionRelayWorkersResult {
42
95
  payload: RelayPayloadShape;
43
96
  steps: ProvisionedRelayStep[];
97
+ /**
98
+ * Everything a person still has to approve before kickoff — agreement
99
+ * activations AND grant hand-overs, in one list. They gate the run
100
+ * identically, so splitting them would just make a caller check two.
101
+ */
44
102
  pendingApprovals: string[];
45
103
  readyForKickoff: boolean;
46
104
  }
@@ -49,5 +107,13 @@ type AgreementWithParent = Agreement & {
49
107
  };
50
108
  export declare function findExistingWorkerDelegation(hireAgreementId: string, assigneeId: string, agreements: AgreementWithParent[]): AgreementWithParent | null;
51
109
  export declare function findOpenOfferForAgent(assigneeId: string, offers: Agreement[]): Agreement | null;
110
+ /**
111
+ * The default grant plumbing, built on the two clients that own these routes.
112
+ *
113
+ * A thin function seam rather than the clients themselves, so a test can drive
114
+ * provisioning without a backend — the same reason the agreement calls are
115
+ * injected.
116
+ */
117
+ export declare function defaultRelayProvisionDeps(): RelayProvisionDeps;
52
118
  export declare function provisionRelayWorkers(input: ProvisionRelayWorkersInput, deps?: RelayProvisionDeps): Promise<ProvisionRelayWorkersResult>;
53
119
  export {};
@@ -1,4 +1,4 @@
1
- import { claimOpenAgreement, delegateAgreement, getMyAgreements, pullOffers, } from '../http/index.js';
1
+ import { ContextGrantsClient, GrantsClient, claimOpenAgreement, delegateAgreement, getMyAgreements, pullOffers, } from '../http/index.js';
2
2
  function providerAgentId(agreement) {
3
3
  return agreement.parties?.provider?.actor ?? null;
4
4
  }
@@ -27,23 +27,123 @@ export function findOpenOfferForAgent(assigneeId, offers) {
27
27
  return providerAgentId(o) === assigneeId;
28
28
  }) ?? null);
29
29
  }
30
- export async function provisionRelayWorkers(input, deps = {
31
- getMyAgreements,
32
- pullOffers,
33
- claimOpenAgreement,
34
- delegateAgreement,
35
- }) {
36
- const { creds, hireAgreementId, chatId, steps, inputArtifactIds } = input;
30
+ /**
31
+ * The default grant plumbing, built on the two clients that own these routes.
32
+ *
33
+ * A thin function seam rather than the clients themselves, so a test can drive
34
+ * provisioning without a backend — the same reason the agreement calls are
35
+ * injected.
36
+ */
37
+ export function defaultRelayProvisionDeps() {
38
+ return {
39
+ getMyAgreements,
40
+ pullOffers,
41
+ claimOpenAgreement,
42
+ delegateAgreement,
43
+ listAgreementGrants: async ({ agreementId, role }, creds) => {
44
+ const client = new GrantsClient(creds.operatorKey, creds.agentId);
45
+ return client.listAllGrants({
46
+ scopeKind: 'agreement',
47
+ scopeId: agreementId,
48
+ role,
49
+ health: 'active',
50
+ });
51
+ },
52
+ delegateAgreementGrant: async ({ parentGrantId, holderId, agreementId }, creds) => {
53
+ const client = new ContextGrantsClient(creds.operatorKey, creds.agentId);
54
+ return client.delegateGrant(parentGrantId, {
55
+ holderId,
56
+ holderKind: 'agent',
57
+ // `write` is exactly what `canActUnderAgreement` tests. Deliberately not
58
+ // `admit`: that would let a coordinator widen its own crew.
59
+ kind: 'write',
60
+ scope: { kind: 'agreement', id: agreementId },
61
+ // from-start, not from-now: the contract predates the grant, so a
62
+ // from-now watermark would exclude the very row the grant is about.
63
+ temporal: 'from-start',
64
+ });
65
+ },
66
+ };
67
+ }
68
+ /**
69
+ * Hand the coordinator a pass on one worker contract.
70
+ *
71
+ * Membership in work is a grant, which is what lets a coordinator create work
72
+ * under a contract it is not a party to. Issuing that grant is the half that has
73
+ * to happen here: without it the recipe fails at the coordinator's first
74
+ * `task_create` with "Caller <coordinator> holds no grant on agreement <worker>,
75
+ * so it cannot create work citing it."
76
+ *
77
+ * Reused before minted, because provisioning is re-run: a second call after an
78
+ * agreement activates should not stack a second pass on a contract that already
79
+ * has one.
80
+ */
81
+ async function grantCoordinatorAccess(args, deps) {
82
+ const { agreementId, coordinatorId, status, creds } = args;
83
+ // Participation grants are minted at ACTIVATION, so a contract still waiting
84
+ // on a signature has nothing to delegate from yet. Say so rather than
85
+ // reporting a failure the caller cannot act on.
86
+ if (status !== 'active') {
87
+ return {
88
+ status: 'deferred',
89
+ error: `${agreementId} is not active yet, so there is no participation grant to ` +
90
+ 'delegate from — provision again once it activates',
91
+ };
92
+ }
93
+ try {
94
+ const alreadyIssued = await deps.listAgreementGrants({ agreementId, role: 'issuer' }, creds);
95
+ const existing = alreadyIssued.find((g) => g.holderId === coordinatorId);
96
+ if (existing?.grantId)
97
+ return { status: 'reused', grantId: existing.grantId };
98
+ const held = await deps.listAgreementGrants({ agreementId, role: 'holder' }, creds);
99
+ // Only a `write` or `admit` parent can beget the `write` child the act rule
100
+ // tests; a `read` participation grant cannot be widened by delegating it.
101
+ // `access` is optional on GrantView because some rails do not record it —
102
+ // context grants always do, so an absent value here is a malformed row and
103
+ // must not be treated as strong enough.
104
+ const parent = held.find((g) => g.access === 'write' || g.access === 'admit');
105
+ if (!parent?.grantId) {
106
+ return {
107
+ status: 'failed',
108
+ error: `no write-or-stronger grant on ${agreementId} to delegate from (found ` +
109
+ `${held.length ? held.map((g) => g.access ?? 'unstated').join(', ') : 'none'}) — ` +
110
+ 'the initiator must be a party to the worker contract it is provisioning',
111
+ };
112
+ }
113
+ const result = await deps.delegateAgreementGrant({ parentGrantId: parent.grantId, holderId: coordinatorId, agreementId }, creds);
114
+ if (result.status === 'pending_approval') {
115
+ return { status: 'pending_approval', approvalAgreementId: result.agreementId };
116
+ }
117
+ return { status: 'granted', grantId: result.grant.grantId };
118
+ }
119
+ catch (err) {
120
+ return {
121
+ status: 'failed',
122
+ error: err instanceof Error ? err.message : String(err),
123
+ };
124
+ }
125
+ }
126
+ export async function provisionRelayWorkers(input, deps = defaultRelayProvisionDeps()) {
127
+ const { creds, hireAgreementId, coordinatorId, chatId, steps, inputArtifactIds } = input;
37
128
  if (!steps.length)
38
129
  throw new Error('steps must not be empty');
130
+ if (!coordinatorId?.trim()) {
131
+ throw new Error('coordinatorId is required: provisioning a worker for a coordinator means both ' +
132
+ 'the contract and an agreement-scope grant to that coordinator on it, and ' +
133
+ 'without the grant every branch of the run is refused');
134
+ }
39
135
  const myAgreements = (await deps.getMyAgreements({}, creds));
40
136
  let offersCache = null;
41
- const provisioned = [];
137
+ // The contracts first, then the passes. Two passes rather than one because a
138
+ // grant can only be delegated from a participation grant, which exists only
139
+ // once the contract is active — so the grant decision needs the settled
140
+ // status of the row, not the intent that created it.
141
+ const contracted = [];
42
142
  const pendingApprovals = [];
43
143
  for (const step of steps) {
44
144
  const existing = findExistingWorkerDelegation(hireAgreementId, step.assigneeId, myAgreements);
45
145
  if (existing?.agreementId) {
46
- provisioned.push({
146
+ contracted.push({
47
147
  stepId: step.stepId,
48
148
  order: step.order,
49
149
  agreementId: existing.agreementId,
@@ -62,7 +162,7 @@ export async function provisionRelayWorkers(input, deps = {
62
162
  : 'pending_approval';
63
163
  if (status === 'pending_approval')
64
164
  pendingApprovals.push(agreementId);
65
- provisioned.push({
165
+ contracted.push({
66
166
  stepId: step.stepId,
67
167
  order: step.order,
68
168
  agreementId,
@@ -85,7 +185,7 @@ export async function provisionRelayWorkers(input, deps = {
85
185
  : 'pending_approval';
86
186
  if (status === 'pending_approval')
87
187
  pendingApprovals.push(agreementId);
88
- provisioned.push({
188
+ contracted.push({
89
189
  stepId: step.stepId,
90
190
  order: step.order,
91
191
  agreementId,
@@ -115,7 +215,7 @@ export async function provisionRelayWorkers(input, deps = {
115
215
  : 'pending_approval';
116
216
  if (status === 'pending_approval')
117
217
  pendingApprovals.push(agreementId);
118
- provisioned.push({
218
+ contracted.push({
119
219
  stepId: step.stepId,
120
220
  order: step.order,
121
221
  agreementId,
@@ -125,7 +225,21 @@ export async function provisionRelayWorkers(input, deps = {
125
225
  status,
126
226
  });
127
227
  }
128
- provisioned.sort((a, b) => a.order - b.order);
228
+ contracted.sort((a, b) => a.order - b.order);
229
+ // ── the second act of provisioning: the coordinator's passes ─────────────
230
+ const provisioned = [];
231
+ for (const step of contracted) {
232
+ const grant = await grantCoordinatorAccess({
233
+ agreementId: step.agreementId,
234
+ coordinatorId,
235
+ status: step.status,
236
+ creds,
237
+ }, deps);
238
+ if (grant.status === 'pending_approval' && grant.approvalAgreementId) {
239
+ pendingApprovals.push(grant.approvalAgreementId);
240
+ }
241
+ provisioned.push({ ...step, grant });
242
+ }
129
243
  const payload = {
130
244
  inputArtifactIds,
131
245
  steps: provisioned.map(({ stepId, order, agreementId, assigneeId, description }) => ({
@@ -136,8 +250,14 @@ export async function provisionRelayWorkers(input, deps = {
136
250
  description,
137
251
  })),
138
252
  };
253
+ /**
254
+ * Ready means BOTH acts are done for every step. A run whose contracts are all
255
+ * active but whose coordinator holds no passes looks provisioned and has every
256
+ * branch refused, so the grant has to count here or this flag goes on lying.
257
+ */
139
258
  const readyForKickoff = pendingApprovals.length === 0 &&
140
- provisioned.every((s) => s.status === 'active');
259
+ provisioned.every((s) => s.status === 'active' &&
260
+ (s.grant.status === 'granted' || s.grant.status === 'reused'));
141
261
  return {
142
262
  payload,
143
263
  steps: provisioned,
package/dist/types.d.ts CHANGED
@@ -71,6 +71,14 @@ export interface Task {
71
71
  updatedAt?: string;
72
72
  parentTaskId?: string;
73
73
  rootTaskId?: string;
74
+ /** Tasks this one waits for before it may start. */
75
+ waitsOn?: string[];
76
+ /**
77
+ * True while {@link waitsOn} is unmet: the task exists but has not been
78
+ * delivered, and its assignee cannot take the processing lock yet. Distinct
79
+ * from "nobody has picked it up" — this one is waiting on purpose.
80
+ */
81
+ heldByDeps?: boolean;
74
82
  plan?: {
75
83
  steps?: PlanStep[];
76
84
  };
@@ -361,6 +369,15 @@ export interface InboxProposalRef {
361
369
  agreementId: string;
362
370
  title: string;
363
371
  proposedAt: string | null;
372
+ /**
373
+ * The agent designated to wake for this proposal, or null when it is
374
+ * ambient/human work in this reader's merged inbox.
375
+ *
376
+ * Optional for rolling deploys. When an older server omits it, clients must
377
+ * only use an explicit agent-keyed approval/responder slot as a safe fallback;
378
+ * org visibility alone is never assignment.
379
+ */
380
+ assignedAgentId?: string | null;
364
381
  /**
365
382
  * party ids still owing a decision, and the named responder slot.
366
383
  * The inbox lists proposals awaiting the agent OR its human, and only the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/api-client",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "HTTP and WebSocket client for the Ziggs backend API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",