@ziggs-ai/ziggs-mcp 0.14.3 → 0.15.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.
package/README.md CHANGED
@@ -168,7 +168,7 @@ Startup validates the key shape, expiry (JWT `exp`), and agent resolution — er
168
168
  | `ziggs_context_issue_grant` | Chat admission or `POST /context/grants` |
169
169
  | `ziggs_context_delegate` | `POST /context/grants/:id/delegate` |
170
170
  | `ziggs_context_revoke_grant` | `DELETE /context/grants/:id` |
171
- | `ziggs_link_create_invite` | `POST /agreements` `{engagementKind:"link"}` open invite (claimUrl + paste text) |
171
+ | `ziggs_link_propose` | `POST /agreements/links` connect with someone by email or agent id, or mint a share link (claimUrl + paste text) |
172
172
  | `ziggs_link_list` | `GET /agreements?engagementKind=link` |
173
173
  | `ziggs_agreement_revoke` | `DELETE /agreements/:id` — any agreement (hire/service/request/offer/link) |
174
174
  | `ziggs_context_snapshot` | `GET /context/snapshot?via=chat:` — one-shot chat orientation (history + agreements + roster), grant-fenced |
package/dist/config.d.ts CHANGED
@@ -49,15 +49,20 @@ export interface ZiggsMcpConfig extends EnvConfig {
49
49
  */
50
50
  coreOnly: boolean;
51
51
  /**
52
- * True for a connection the caller configured themselves (the
53
- * stdio package plus their own operator key), false for the remote endpoint
54
- * the connector directory advertises.
52
+ * True unless the credential was minted by the MCP OAuth consent flow.
55
53
  *
56
- * The two are the two halves of `server.json`: `packages` is what someone
57
- * installs deliberately, `remotes` is what a directory user is handed. A
58
- * freeform action-plus-payload tool is fine in the first and is the exact
59
- * shape reviewers reject in the second, so the surface differs by which door
60
- * the caller came through not by who they are.
54
+ * The consent flow is how a connected assistant (Claude, ChatGPT, Cursor)
55
+ * boards, and it is the door the connector directory hands out. A store
56
+ * reviewer has to be able to tell from a tool's definition what it will do,
57
+ * so that surface carries no tool whose target is whatever the caller names
58
+ * with a freeform payload: the connection rail. Everyone else configured
59
+ * this server themselves with an operator key, on stdio or against the
60
+ * remote endpoint, and gets the whole surface.
61
+ *
62
+ * The line is the credential, not the transport. The backend stamps
63
+ * `issuedVia: "mcp_oauth"` on the tokens the consent flow mints, and this is
64
+ * that stamp read back. It decides which tools are registered, not what a
65
+ * call may do: every call is still authorized by the backend.
61
66
  */
62
67
  directConnection: boolean;
63
68
  }
package/dist/config.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import 'dotenv/config';
2
2
  import { z } from 'zod';
3
3
  import { configureApiClient } from '@ziggs-ai/api-client';
4
- import { MINT_KEY_HELP, resolveDelegateAgentId } from './operatorKey.js';
4
+ import { MINT_KEY_HELP, decodeOperatorKeyClaims, isDirectoryBoarded, resolveDelegateAgentId, } from './operatorKey.js';
5
5
  const envSchema = z.object({
6
6
  ZIGGS_API_URL: z.string().optional(),
7
7
  HTTP_URL: z.string().optional(),
@@ -71,8 +71,6 @@ export function loadConfig() {
71
71
  resolvedAgentId,
72
72
  debugTools: parseBoolFlag(parsed.data.ZIGGS_MCP_DEBUG),
73
73
  coreOnly: parseBoolFlag(parsed.data.ZIGGS_MCP_CORE_ONLY),
74
- // The stdio path: someone installed this package and pointed their own
75
- // operator key at it. Not the door the directory hands out.
76
- directConnection: true,
74
+ directConnection: !isDirectoryBoarded(decodeOperatorKeyClaims(parsed.data.ZIGGS_OPERATOR_KEY)),
77
75
  };
78
76
  }
@@ -1,5 +1,5 @@
1
1
  import { configureApiClient } from '@ziggs-ai/api-client';
2
- import { resolveDelegateAgentId, MINT_KEY_HELP } from './operatorKey.js';
2
+ import { MINT_KEY_HELP, decodeOperatorKeyClaims, isDirectoryBoarded, resolveDelegateAgentId, } from './operatorKey.js';
3
3
  export function parseBearerAuthorization(header) {
4
4
  const raw = Array.isArray(header) ? header[0] : header;
5
5
  if (!raw?.startsWith('Bearer ')) {
@@ -29,9 +29,11 @@ export function connectionFromBearer(bearer, httpBaseUrl, ownerUserId) {
29
29
  resolvedAgentId,
30
30
  debugTools: false,
31
31
  coreOnly: false,
32
- // The remote endpoint is what the connector directory advertises, so this
33
- // is the listed surface.
34
- directConnection: false,
32
+ // The listed surface for a token the OAuth consent flow minted (a connected
33
+ // assistant), the whole surface for an operator key someone pasted here
34
+ // themselves. The line is the credential, not this endpoint; see
35
+ // ZiggsMcpConfig.directConnection.
36
+ directConnection: !isDirectoryBoarded(decodeOperatorKeyClaims(bearer)),
35
37
  };
36
38
  return {
37
39
  creds: { operatorKey: bearer, agentId: resolvedAgentId },
@@ -4,12 +4,44 @@ export interface OperatorKeyClaims {
4
4
  keyId?: string;
5
5
  ownerId?: string;
6
6
  boundAgentId?: string | null;
7
+ /**
8
+ * How the key was minted. The backend stamps `mcp_oauth` on every access
9
+ * token the MCP OAuth consent flow issues, refreshes included; a key minted
10
+ * anywhere else (dashboard, CLI, provisioning) carries nothing here.
11
+ */
12
+ issuedVia?: string;
7
13
  exp?: number;
8
14
  }
9
15
  declare const MINT_KEY_HELP: string;
10
16
  /** Decode operator JWT payload without verifying signature (boundAgentId). */
11
17
  export declare function decodeOperatorKeyClaims(token: string): OperatorKeyClaims | null;
12
18
  export declare function isOperatorKeyExpired(claims: OperatorKeyClaims | null): boolean;
19
+ /** The stamp the MCP OAuth authorization-code consent flow puts on its tokens. */
20
+ export declare const ISSUED_VIA_MCP_OAUTH = "mcp_oauth";
21
+ /** The stamp the MCP OAuth device-code flow puts on its tokens. */
22
+ export declare const ISSUED_VIA_DEVICE_CODE = "device_code";
23
+ /**
24
+ * Did a person board this credential through the connector directory?
25
+ *
26
+ * That is the question the tool surface turns on: a connected assistant
27
+ * (Claude, ChatGPT, Cursor) gets the listed, directory-reviewed surface, and
28
+ * anyone who configured this server themselves with an operator key gets the
29
+ * whole thing.
30
+ *
31
+ * Ask it as a predicate, never as `issuedVia === 'mcp_oauth'`. Two consent rails
32
+ * board an assistant through that door — the authorization-code flow and the
33
+ * device-code flow — and both want the same narrowed surface, but the server
34
+ * records them as the different acts they are. An equality test against one
35
+ * value serves the whole catalogue to every credential from the other one.
36
+ *
37
+ * Read off the unverified payload. On the remote endpoint the backend verified
38
+ * the signature before this ran; on stdio the key is the caller's own. Either
39
+ * way the payload is the one the backend signed. And what hangs on the answer
40
+ * is which tools are registered, not what a call may do: every call is still
41
+ * authorized by the backend. Provenance may decide what is OFFERED, never what
42
+ * is PERMITTED.
43
+ */
44
+ export declare function isDirectoryBoarded(claims: OperatorKeyClaims | null): boolean;
13
45
  /**
14
46
  * Resolve delegate agent id: agent-scoped key (boundAgentId) wins; else ZIGGS_AGENT_ID.
15
47
  */
@@ -15,6 +15,7 @@ export function decodeOperatorKeyClaims(token) {
15
15
  keyId: payload.keyId,
16
16
  ownerId: payload.ownerId,
17
17
  boundAgentId: payload.boundAgentId ?? null,
18
+ issuedVia: typeof payload.issuedVia === 'string' ? payload.issuedVia : undefined,
18
19
  exp: payload.exp,
19
20
  };
20
21
  }
@@ -27,6 +28,35 @@ export function isOperatorKeyExpired(claims) {
27
28
  return false;
28
29
  return claims.exp * 1000 <= Date.now();
29
30
  }
31
+ /** The stamp the MCP OAuth authorization-code consent flow puts on its tokens. */
32
+ export const ISSUED_VIA_MCP_OAUTH = 'mcp_oauth';
33
+ /** The stamp the MCP OAuth device-code flow puts on its tokens. */
34
+ export const ISSUED_VIA_DEVICE_CODE = 'device_code';
35
+ /**
36
+ * Did a person board this credential through the connector directory?
37
+ *
38
+ * That is the question the tool surface turns on: a connected assistant
39
+ * (Claude, ChatGPT, Cursor) gets the listed, directory-reviewed surface, and
40
+ * anyone who configured this server themselves with an operator key gets the
41
+ * whole thing.
42
+ *
43
+ * Ask it as a predicate, never as `issuedVia === 'mcp_oauth'`. Two consent rails
44
+ * board an assistant through that door — the authorization-code flow and the
45
+ * device-code flow — and both want the same narrowed surface, but the server
46
+ * records them as the different acts they are. An equality test against one
47
+ * value serves the whole catalogue to every credential from the other one.
48
+ *
49
+ * Read off the unverified payload. On the remote endpoint the backend verified
50
+ * the signature before this ran; on stdio the key is the caller's own. Either
51
+ * way the payload is the one the backend signed. And what hangs on the answer
52
+ * is which tools are registered, not what a call may do: every call is still
53
+ * authorized by the backend. Provenance may decide what is OFFERED, never what
54
+ * is PERMITTED.
55
+ */
56
+ export function isDirectoryBoarded(claims) {
57
+ return (claims?.issuedVia === ISSUED_VIA_MCP_OAUTH ||
58
+ claims?.issuedVia === ISSUED_VIA_DEVICE_CODE);
59
+ }
30
60
  /**
31
61
  * Resolve delegate agent id: agent-scoped key (boundAgentId) wins; else ZIGGS_AGENT_ID.
32
62
  */
@@ -25,18 +25,18 @@ export function resolveWebAppOrigin(webUrl) {
25
25
  return (webUrl?.trim() || 'https://ziggsai.com').replace(/\/$/, '');
26
26
  }
27
27
  export function agreementAppUrl(origin, agreementId) {
28
- return `${origin}/app/agreements/${encodeURIComponent(agreementId)}`;
28
+ return `${origin}/app/work/agreements/${encodeURIComponent(agreementId)}`;
29
29
  }
30
30
  export function agreementsListAppUrl(origin) {
31
- return `${origin}/app/agreements`;
31
+ return `${origin}/app/work/agreements`;
32
32
  }
33
33
  /** Where the human connects MCP servers and grants tools. */
34
34
  export function connectionsSettingsAppUrl(origin) {
35
- return `${origin}/app/settings/connections`;
35
+ return `${origin}/app/access`;
36
36
  }
37
37
  /** Where the human decides paused transfers. */
38
38
  export function walletAppUrl(origin) {
39
- return `${origin}/app/wallet`;
39
+ return `${origin}/app/settings/organization/billing`;
40
40
  }
41
41
  function truncateText(text, max = TITLE_MAX) {
42
42
  const oneLine = text.replace(/\s+/g, ' ').trim();
@@ -16,11 +16,12 @@ export declare const PROTOCOL: {
16
16
  readonly tagline: "You are a delegate agent on a Ziggs team. The MCP tools are the connection; operate by this protocol.";
17
17
  /**
18
18
  * The surface is names-first: the everyday tools load natively and everything
19
- * else is named by ziggs_tools and called through ziggs_tool. Said on connect
19
+ * else is named by ziggs_tools and called through ziggs_tool_read or
20
+ * ziggs_tool_write. Said on connect
20
21
  * because a client that never calls ziggs_tools would otherwise conclude the
21
22
  * everyday set is all there is.
22
23
  */
23
- readonly surface: "Only the everyday tools are loaded; the rest of the surface is one call away. ziggs_tools lists every tool that exists (name + one line), ziggs_tools describe=[\"<name>\"] returns its full schema, and ziggs_tool { tool, args } calls it. Nothing is hidden check ziggs_tools before concluding a capability is missing.";
24
+ readonly surface: "Only the everyday tools are loaded; the rest of the surface is one call away. ziggs_tools lists every tool that exists (name + one line, and whether it is read-only), ziggs_tools describe=[\"<name>\"] returns its full schema, ziggs_tool_read { tool, args } calls a read-only tool and ziggs_tool_write { tool, args } calls one that changes something. Nothing is hidden: check ziggs_tools before concluding a capability is missing.";
24
25
  /** The working loop, as the `ziggs_inbox` description phrases it. */
25
26
  readonly loop: "Flow: inbox → read → act → ack.";
26
27
  /**
@@ -16,11 +16,12 @@ export const PROTOCOL = {
16
16
  tagline: 'You are a delegate agent on a Ziggs team. The MCP tools are the connection; operate by this protocol.',
17
17
  /**
18
18
  * The surface is names-first: the everyday tools load natively and everything
19
- * else is named by ziggs_tools and called through ziggs_tool. Said on connect
19
+ * else is named by ziggs_tools and called through ziggs_tool_read or
20
+ * ziggs_tool_write. Said on connect
20
21
  * because a client that never calls ziggs_tools would otherwise conclude the
21
22
  * everyday set is all there is.
22
23
  */
23
- surface: 'Only the everyday tools are loaded; the rest of the surface is one call away. ziggs_tools lists every tool that exists (name + one line), ziggs_tools describe=["<name>"] returns its full schema, and ziggs_tool { tool, args } calls it. Nothing is hidden check ziggs_tools before concluding a capability is missing.',
24
+ surface: 'Only the everyday tools are loaded; the rest of the surface is one call away. ziggs_tools lists every tool that exists (name + one line, and whether it is read-only), ziggs_tools describe=["<name>"] returns its full schema, ziggs_tool_read { tool, args } calls a read-only tool and ziggs_tool_write { tool, args } calls one that changes something. Nothing is hidden: check ziggs_tools before concluding a capability is missing.',
24
25
  /** The working loop, as the `ziggs_inbox` description phrases it. */
25
26
  loop: 'Flow: inbox → read → act → ack.',
26
27
  /**
package/dist/surface.d.ts CHANGED
@@ -3,8 +3,9 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  * The tools a cold session gets natively, with their full schemas.
4
4
  *
5
5
  * Everything else is in the catalog: named by `ziggs_tools`, described on
6
- * demand, called through `ziggs_tool` — or by its own name, which stays
7
- * callable. The line is drawn at what a session cannot start without:
6
+ * demand, called through `ziggs_tool_read` or `ziggs_tool_write` — or by its
7
+ * own name, which stays callable. The line is drawn at what a session cannot
8
+ * start without:
8
9
  *
9
10
  * - `ziggs_inbox` is the session-start read — the other two orientation tools
10
11
  * folded into it — so it is where a caller finds out where it stands and
@@ -15,7 +16,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
15
16
  * - `ziggs_chat_send` and `ziggs_task_set_result` are the two ways to answer:
16
17
  * conversation, and finished work. An agent that can read its mail and
17
18
  * cannot reply is worse off than one that pays for a schema it never used.
18
- * - the two catalog tools themselves, or none of the above matters.
19
+ * - the catalog tools themselves, or none of the above matters.
19
20
  *
20
21
  * Deliberately NOT native: the agreement verbs, payments, artifacts, grants,
21
22
  * links, connections, tasks beyond the result. Every one of them is a real
@@ -23,18 +24,20 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
23
24
  */
24
25
  export declare const NATIVE_TOOLS: readonly string[];
25
26
  /**
26
- * Irreversible tools stay natively listed, and the dispatcher refuses them.
27
+ * Irreversible tools stay natively listed, and the write dispatcher refuses
28
+ * them.
27
29
  *
28
30
  * MCP annotations are static per tool and are read at `tools/list` time, before
29
31
  * anyone knows which tool a dispatch will land on. So a `destructiveHint` tool
30
- * reached through `ziggs_tool` presents to the host as an ordinary write, and
31
- * the host asks the human to confirm "Call a Ziggs tool" instead of "Revoke an
32
- * agreement". That is exactly the moment the warning exists for.
32
+ * reached through `ziggs_tool_write` presents to the host as an ordinary write,
33
+ * and the host asks the human to confirm "Call a Ziggs tool that changes
34
+ * something" instead of "Revoke an agreement". That is exactly the moment the
35
+ * warning exists for.
33
36
  *
34
37
  * Two tools carry `destructiveHint` today, so keeping them native costs two
35
38
  * schemas and buys back the host's ability to warn before something that cannot
36
39
  * be undone. Refusing them on the dispatcher closes the other half: a caller
37
- * that reaches for `ziggs_tool` out of habit is sent to the annotated call
40
+ * that reaches for the dispatcher out of habit is sent to the annotated call
38
41
  * rather than quietly slipping past the prompt.
39
42
  *
40
43
  * If a third destructive tool ever appears, it belongs in both lists. The test
@@ -42,8 +45,20 @@ export declare const NATIVE_TOOLS: readonly string[];
42
45
  */
43
46
  export declare const DESTRUCTIVE_STAY_NATIVE: readonly string[];
44
47
  /**
45
- * Narrow `tools/list` to the native set, then register the two tools that make
46
- * the rest reachable.
48
+ * The catalog and its two dispatchers. Always listed, whatever the tier.
49
+ *
50
+ * Two dispatchers rather than one, split by annotation. A host and a store
51
+ * reviewer both read what a tool is from its annotation, and one dispatcher
52
+ * that carried reads and writes was marked write for all of them: every read
53
+ * through it asked the human to confirm, and a directory reviewer saw exactly
54
+ * the one-tool-for-safe-and-unsafe-operations shape they reject. Each
55
+ * dispatcher accepts only its own lane and names the other lane's dispatcher
56
+ * when it refuses.
57
+ */
58
+ export declare const CATALOG_TOOLS: readonly string[];
59
+ /**
60
+ * Narrow `tools/list` to the native set, then register the tools that make the
61
+ * rest reachable: the catalog, and one dispatcher per annotation lane.
47
62
  *
48
63
  * Narrowing by `.disable()` would be the shorter way to do this, and it is the
49
64
  * wrong one: disabling takes a tool out of `tools/list` AND out of
@@ -54,7 +69,7 @@ export declare const DESTRUCTIVE_STAY_NATIVE: readonly string[];
54
69
  * the tool exists, where it went, or how to reach it.
55
70
  *
56
71
  * So every tool stays ENABLED, and the listing is narrowed instead. A name is
57
- * always callable; the list is still the native set plus the catalog pair.
72
+ * always callable; the list is still the native set plus the catalog tools.
58
73
  *
59
74
  * The listing delegates to the handler the SDK installed rather than rebuilding
60
75
  * one: the SDK owns how a registered tool becomes a `tools/list` entry, and
package/dist/surface.js CHANGED
@@ -10,8 +10,9 @@ import { catalogEntry, catalogFor, catalogRow, describeEntry, removeCatalogEntry
10
10
  * The tools a cold session gets natively, with their full schemas.
11
11
  *
12
12
  * Everything else is in the catalog: named by `ziggs_tools`, described on
13
- * demand, called through `ziggs_tool` — or by its own name, which stays
14
- * callable. The line is drawn at what a session cannot start without:
13
+ * demand, called through `ziggs_tool_read` or `ziggs_tool_write` — or by its
14
+ * own name, which stays callable. The line is drawn at what a session cannot
15
+ * start without:
15
16
  *
16
17
  * - `ziggs_inbox` is the session-start read — the other two orientation tools
17
18
  * folded into it — so it is where a caller finds out where it stands and
@@ -22,7 +23,7 @@ import { catalogEntry, catalogFor, catalogRow, describeEntry, removeCatalogEntry
22
23
  * - `ziggs_chat_send` and `ziggs_task_set_result` are the two ways to answer:
23
24
  * conversation, and finished work. An agent that can read its mail and
24
25
  * cannot reply is worse off than one that pays for a schema it never used.
25
- * - the two catalog tools themselves, or none of the above matters.
26
+ * - the catalog tools themselves, or none of the above matters.
26
27
  *
27
28
  * Deliberately NOT native: the agreement verbs, payments, artifacts, grants,
28
29
  * links, connections, tasks beyond the result. Every one of them is a real
@@ -38,18 +39,20 @@ export const NATIVE_TOOLS = [
38
39
  'ziggs_context_revoke_grant',
39
40
  ];
40
41
  /**
41
- * Irreversible tools stay natively listed, and the dispatcher refuses them.
42
+ * Irreversible tools stay natively listed, and the write dispatcher refuses
43
+ * them.
42
44
  *
43
45
  * MCP annotations are static per tool and are read at `tools/list` time, before
44
46
  * anyone knows which tool a dispatch will land on. So a `destructiveHint` tool
45
- * reached through `ziggs_tool` presents to the host as an ordinary write, and
46
- * the host asks the human to confirm "Call a Ziggs tool" instead of "Revoke an
47
- * agreement". That is exactly the moment the warning exists for.
47
+ * reached through `ziggs_tool_write` presents to the host as an ordinary write,
48
+ * and the host asks the human to confirm "Call a Ziggs tool that changes
49
+ * something" instead of "Revoke an agreement". That is exactly the moment the
50
+ * warning exists for.
48
51
  *
49
52
  * Two tools carry `destructiveHint` today, so keeping them native costs two
50
53
  * schemas and buys back the host's ability to warn before something that cannot
51
54
  * be undone. Refusing them on the dispatcher closes the other half: a caller
52
- * that reaches for `ziggs_tool` out of habit is sent to the annotated call
55
+ * that reaches for the dispatcher out of habit is sent to the annotated call
53
56
  * rather than quietly slipping past the prompt.
54
57
  *
55
58
  * If a third destructive tool ever appears, it belongs in both lists. The test
@@ -60,25 +63,61 @@ export const DESTRUCTIVE_STAY_NATIVE = [
60
63
  'ziggs_context_revoke_grant',
61
64
  ];
62
65
  const TOOLS_CATALOG_NAME = 'ziggs_tools';
63
- const TOOL_CALL_NAME = 'ziggs_tool';
64
- /** The two tools that reach the catalog. Always listed, whatever the tier. */
65
- const CATALOG_TOOL_NAMES = new Set([
66
+ const TOOL_READ_NAME = 'ziggs_tool_read';
67
+ const TOOL_WRITE_NAME = 'ziggs_tool_write';
68
+ /**
69
+ * The catalog and its two dispatchers. Always listed, whatever the tier.
70
+ *
71
+ * Two dispatchers rather than one, split by annotation. A host and a store
72
+ * reviewer both read what a tool is from its annotation, and one dispatcher
73
+ * that carried reads and writes was marked write for all of them: every read
74
+ * through it asked the human to confirm, and a directory reviewer saw exactly
75
+ * the one-tool-for-safe-and-unsafe-operations shape they reject. Each
76
+ * dispatcher accepts only its own lane and names the other lane's dispatcher
77
+ * when it refuses.
78
+ */
79
+ export const CATALOG_TOOLS = [
66
80
  TOOLS_CATALOG_NAME,
67
- TOOL_CALL_NAME,
68
- ]);
81
+ TOOL_READ_NAME,
82
+ TOOL_WRITE_NAME,
83
+ ];
84
+ const CATALOG_TOOL_NAMES = new Set(CATALOG_TOOLS);
85
+ /**
86
+ * A tool that takes a freeform target and payload has to say which API it
87
+ * reaches; a store reviewer cannot otherwise tell what it does.
88
+ */
89
+ const ZIGGS_API = 'the Ziggs API (https://api.ziggsai.com, reference at https://ziggsai.com/docs)';
69
90
  const TOOLS_DESCRIPTION = 'Every Ziggs tool that exists: name plus one line, for the whole surface. ' +
70
- 'A handful of everyday tools are already loaded natively; the rest live here and are called with ziggs_tool. ' +
91
+ 'A handful of everyday tools are already loaded natively; the rest live here. Each row says whether the tool is read-only: call read-only tools with ziggs_tool_read and the others with ziggs_tool_write. ' +
71
92
  'Pass `describe` with one or more tool names to get their full descriptions and input schemas — do that before calling a tool you have not used in this session, so you pass the right arguments. ' +
72
93
  'Pass `search` to filter the listing by a word in the name or title (e.g. "agreement", "artifact"). ' +
73
94
  'Nothing is hidden: if a capability exists, its name is in this list.';
74
- const TOOL_CALL_DESCRIPTION = 'Call any Ziggs tool by name the way to reach everything ziggs_tools lists but that is not natively loaded. ' +
75
- "Look the tool up with ziggs_tools describe=[<name>] first when you do not already know its parameters: `args` is validated against the real schema and a wrong key is refused, not silently dropped. " +
95
+ const TOOL_READ_DESCRIPTION = `Call a read-only Ziggs tool by name, against ${ZIGGS_API}. ` +
96
+ 'This reaches every read-only tool ziggs_tools lists that is not natively loaded, and it never changes anything: a tool that writes is refused here and pointed at ziggs_tool_write. ' +
97
+ 'Look the tool up with ziggs_tools describe=[<name>] first when you do not already know its parameters: `args` is validated against the real schema and a wrong key is refused, not silently dropped. ' +
98
+ 'The result is exactly what the tool itself returns.';
99
+ const TOOL_WRITE_DESCRIPTION = `Call a Ziggs tool that creates or changes something, by name, against ${ZIGGS_API}. ` +
100
+ 'This reaches every writing tool ziggs_tools lists that is not natively loaded. Read-only tools are refused here and pointed at ziggs_tool_read; irreversible tools are loaded natively and are called by their own name. ' +
101
+ 'Look the tool up with ziggs_tools describe=[<name>] first when you do not already know its parameters: `args` is validated against the real schema and a wrong key is refused, not silently dropped. ' +
76
102
  'The result is exactly what the tool itself returns.';
77
103
  function sortRows(entries) {
78
104
  return [...entries].sort((a, b) => a.name.localeCompare(b.name));
79
105
  }
106
+ /** Which dispatcher a catalog entry is called through. */
107
+ function laneOf(entry) {
108
+ return entry.annotations.readOnlyHint ? TOOL_READ_NAME : TOOL_WRITE_NAME;
109
+ }
110
+ /** The hint under a describe: the dispatcher to call next, named when one fits all. */
111
+ function nextCall(entries) {
112
+ const lanes = new Set(entries.map(laneOf));
113
+ if (lanes.size === 1) {
114
+ for (const lane of lanes)
115
+ return `Call it with ${lane} { tool, args }.`;
116
+ }
117
+ return `Call read-only tools with ${TOOL_READ_NAME} { tool, args } and the rest with ${TOOL_WRITE_NAME} { tool, args }.`;
118
+ }
80
119
  /**
81
- * Keep `tools/list` to `listed` (plus the catalog pair, registered after this
120
+ * Keep `tools/list` to `listed` (plus the catalog tools, registered after this
82
121
  * runs) by filtering the response the SDK's own handler builds.
83
122
  *
84
123
  * The handler is read back off the underlying `Server` so the SDK stays the
@@ -107,8 +146,62 @@ function listOnly(server, listed) {
107
146
  });
108
147
  }
109
148
  /**
110
- * Narrow `tools/list` to the native set, then register the two tools that make
111
- * the rest reachable.
149
+ * One dispatcher: reaches every catalog tool of its lane by name, validates the
150
+ * arguments against that tool's own strict schema, and runs its stored handler.
151
+ */
152
+ function registerDispatcher(server, lane) {
153
+ registerStrictTool(server, lane.name, lane.description, {
154
+ tool: z
155
+ .string()
156
+ .describe(`Tool name from ${TOOLS_CATALOG_NAME}, e.g. ${lane.example}.`),
157
+ args: z
158
+ .record(z.unknown())
159
+ .optional()
160
+ .describe("Arguments for that tool, as its own schema defines them."),
161
+ }, lane.annotations, async ({ tool, args }) => {
162
+ const entry = catalogEntry(server, tool);
163
+ if (!entry) {
164
+ return toolError(`No such tool: ${tool}. Call ${TOOLS_CATALOG_NAME} for the full list of names.`);
165
+ }
166
+ // An irreversible action must go through the call the host can annotate,
167
+ // or the human is asked to confirm the wrong sentence.
168
+ if (entry.annotations.destructiveHint) {
169
+ return toolError(`${entry.name} cannot be called through ${lane.name}: it is irreversible, and the host shows the human what it is about to allow from the tool it is asked to run. ` +
170
+ `${entry.name} is loaded directly for that reason — call it by name.`);
171
+ }
172
+ // Each lane carries one kind of tool, so its annotation is true of every
173
+ // call through it. The other lane is named, not guessed at.
174
+ const entryReadOnly = entry.annotations.readOnlyHint === true;
175
+ if (entryReadOnly !== lane.readOnly) {
176
+ return toolError(entryReadOnly
177
+ ? `${entry.name} is read-only: call it with ${TOOL_READ_NAME} { tool, args }, not ${lane.name}.`
178
+ : `${entry.name} changes state: call it with ${TOOL_WRITE_NAME} { tool, args }, not ${lane.name}.`);
179
+ }
180
+ // Validate here rather than letting the handler read undefined fields:
181
+ // the strict schema is what turns a misspelled key into a named refusal
182
+ // instead of a call that runs and reports success for the wrong thing.
183
+ // It is the object registration already built — the catalog carries it,
184
+ // so a dispatch does not rebuild a schema that cannot have changed.
185
+ const parsed = entry.params.safeParse(args ?? {});
186
+ if (!parsed.success) {
187
+ const detail = parsed.error.issues
188
+ .map((i) => (i.path.length ? `${i.path.join('.')}: ${i.message}` : i.message))
189
+ .join('; ');
190
+ // The unknown-key refusal already names the tool and lists what it
191
+ // accepts; don't say the name twice.
192
+ return toolError(detail.includes(entry.name) ? detail : `${entry.name} did not run: ${detail}`);
193
+ }
194
+ try {
195
+ return (await entry.call(parsed.data));
196
+ }
197
+ catch (e) {
198
+ return toolError(e);
199
+ }
200
+ });
201
+ }
202
+ /**
203
+ * Narrow `tools/list` to the native set, then register the tools that make the
204
+ * rest reachable: the catalog, and one dispatcher per annotation lane.
112
205
  *
113
206
  * Narrowing by `.disable()` would be the shorter way to do this, and it is the
114
207
  * wrong one: disabling takes a tool out of `tools/list` AND out of
@@ -119,7 +212,7 @@ function listOnly(server, listed) {
119
212
  * the tool exists, where it went, or how to reach it.
120
213
  *
121
214
  * So every tool stays ENABLED, and the listing is narrowed instead. A name is
122
- * always callable; the list is still the native set plus the catalog pair.
215
+ * always callable; the list is still the native set plus the catalog tools.
123
216
  *
124
217
  * The listing delegates to the handler the SDK installed rather than rebuilding
125
218
  * one: the SDK owns how a registered tool becomes a `tools/list` entry, and
@@ -150,7 +243,7 @@ export function applySurfacePolicy(server) {
150
243
  for (const name of describe) {
151
244
  const entry = catalogEntry(server, name);
152
245
  if (entry)
153
- found.push(describeEntry(entry));
246
+ found.push(entry);
154
247
  else
155
248
  unknown.push(name);
156
249
  }
@@ -158,9 +251,9 @@ export function applySurfacePolicy(server) {
158
251
  return toolError(`No such tool: ${unknown.join(', ')}. Call ${TOOLS_CATALOG_NAME} with no arguments for the full list of names.`);
159
252
  }
160
253
  return textResult({
161
- tools: found,
254
+ tools: found.map(describeEntry),
162
255
  ...(unknown.length ? { unknown } : {}),
163
- next: `Call it with ${TOOL_CALL_NAME} { tool, args }.`,
256
+ next: nextCall(found),
164
257
  });
165
258
  }
166
259
  const all = sortRows(catalogFor(server));
@@ -174,59 +267,30 @@ export function applySurfacePolicy(server) {
174
267
  count: rows.length,
175
268
  ...(needle ? { search: needle, total: all.length } : {}),
176
269
  tools: rows,
177
- next: `Get full schemas with ${TOOLS_CATALOG_NAME} { describe: ["<name>"] }, then call with ${TOOL_CALL_NAME} { tool, args }.`,
270
+ next: `Get full schemas with ${TOOLS_CATALOG_NAME} { describe: ["<name>"] }, then call read-only rows with ${TOOL_READ_NAME} { tool, args } and the rest with ${TOOL_WRITE_NAME} { tool, args }.`,
178
271
  });
179
272
  }
180
273
  catch (e) {
181
274
  return toolError(e);
182
275
  }
183
276
  });
184
- registerStrictTool(server, TOOL_CALL_NAME, TOOL_CALL_DESCRIPTION, {
185
- tool: z
186
- .string()
187
- .describe(`Tool name from ${TOOLS_CATALOG_NAME}, e.g. ziggs_agreement_list.`),
188
- args: z
189
- .record(z.unknown())
190
- .optional()
191
- .describe("Arguments for that tool, as its own schema defines them."),
192
- }, write('Call a Ziggs tool'), async ({ tool, args }) => {
193
- const entry = catalogEntry(server, tool);
194
- if (!entry) {
195
- return toolError(`No such tool: ${tool}. Call ${TOOLS_CATALOG_NAME} for the full list of names.`);
196
- }
197
- if (entry.name === TOOL_CALL_NAME) {
198
- return toolError(`${TOOL_CALL_NAME} cannot call itself — pass the tool you actually want.`);
199
- }
200
- // An irreversible action must go through the call the host can annotate,
201
- // or the human is asked to confirm the wrong sentence.
202
- if (entry.annotations.destructiveHint) {
203
- return toolError(`${entry.name} cannot be called through ${TOOL_CALL_NAME}: it is irreversible, and the host shows the human what it is about to allow from the tool it is asked to run. ` +
204
- `${entry.name} is loaded directly for that reason — call it by name.`);
205
- }
206
- // Validate here rather than letting the handler read undefined fields:
207
- // the strict schema is what turns a misspelled key into a named refusal
208
- // instead of a call that runs and reports success for the wrong thing.
209
- // It is the object registration already built — the catalog carries it,
210
- // so a dispatch does not rebuild a schema that cannot have changed.
211
- const parsed = entry.params.safeParse(args ?? {});
212
- if (!parsed.success) {
213
- const detail = parsed.error.issues
214
- .map((i) => (i.path.length ? `${i.path.join('.')}: ${i.message}` : i.message))
215
- .join('; ');
216
- // The unknown-key refusal already names the tool and lists what it
217
- // accepts; don't say the name twice.
218
- return toolError(detail.includes(entry.name) ? detail : `${entry.name} did not run: ${detail}`);
219
- }
220
- try {
221
- return (await entry.call(parsed.data));
222
- }
223
- catch (e) {
224
- return toolError(e);
225
- }
277
+ registerDispatcher(server, {
278
+ name: TOOL_READ_NAME,
279
+ description: TOOL_READ_DESCRIPTION,
280
+ annotations: readOnly('Call a read-only Ziggs tool'),
281
+ readOnly: true,
282
+ example: 'ziggs_agreement_list',
283
+ });
284
+ registerDispatcher(server, {
285
+ name: TOOL_WRITE_NAME,
286
+ description: TOOL_WRITE_DESCRIPTION,
287
+ annotations: write('Call a Ziggs tool that changes something'),
288
+ readOnly: false,
289
+ example: 'ziggs_task_create',
226
290
  });
227
- // The two dispatcher tools registered through the same path as everything
228
- // else, so they are in the catalog too. Take them out: listing ziggs_tools
229
- // inside its own listing is noise, and ziggs_tool calling itself is a loop.
230
- removeCatalogEntry(server, TOOLS_CATALOG_NAME);
231
- removeCatalogEntry(server, TOOL_CALL_NAME);
291
+ // The catalog tools registered through the same path as everything else, so
292
+ // they are in the catalog too. Take them out: listing ziggs_tools inside its
293
+ // own listing is noise, and a dispatcher calling a dispatcher is a loop.
294
+ for (const name of CATALOG_TOOLS)
295
+ removeCatalogEntry(server, name);
232
296
  }
@@ -20,7 +20,10 @@ import type { TitledAnnotations } from './toolAnnotations.js';
20
20
  * MCP has no name-only tool: every entry in `tools/list` carries a schema. So
21
21
  * the catalog is a dispatcher — the shape `ziggs_mcp_tools_list` /
22
22
  * `ziggs_mcp_tool_call` already use for proxied external MCP, and the one that
23
- * needs no cooperation from any client. Nothing is hidden: `ziggs_tools` names
23
+ * needs no cooperation from any client. Two of them, one per annotation lane
24
+ * (`ziggs_tool_read`, `ziggs_tool_write`): a host and a store reviewer read
25
+ * what a tool is from its annotation, and one dispatcher cannot be read-only
26
+ * and writing at once. Nothing is hidden: `ziggs_tools` names
24
27
  * every tool that exists, and `ziggs_tools` with `describe` hands back full
25
28
  * schemas on demand. An agent can always find what exists and ask for it,
26
29
  * which is what made per-caller filtering the wrong trade.
@@ -45,7 +48,7 @@ export interface CatalogEntry {
45
48
  */
46
49
  params: ZodTypeAny;
47
50
  /**
48
- * The registered handler. `ziggs_tool` dispatches through this rather than
51
+ * The registered handler. The dispatchers call through this rather than
49
52
  * re-entering the server: one call, one validation, no nested request.
50
53
  */
51
54
  call: (args: Record<string, unknown>) => Promise<unknown>;
package/dist/tools.js CHANGED
@@ -194,23 +194,26 @@ async function loadSessionBinding(creds, cfg) {
194
194
  // ziggs_task_create are the same moves, one step at a time. Provisioning a
195
195
  // whole relay is operator work and belongs on an operator surface.
196
196
  function registerConnectionTools(server, creds, cfg) {
197
- // The freeform action-plus-payload shape is exactly what connector
198
- // reviewers reject: they cannot tell what the tool does or cite the upstream
199
- // API it calls, and its one write annotation cannot describe a surface whose
200
- // behaviour changes with its arguments. It stays whole for a caller who
201
- // configured this server themselves, and is off the door the directory hands
202
- // out. Named per-provider tools would be the better surface; that scales with
203
- // the number of providers and is a separate call on which ones earn one.
197
+ // A tool that takes a target name plus a freeform payload is the shape store
198
+ // reviewers reject: nothing in its definition says what it will do or which
199
+ // API it calls, and one annotation cannot cover reads and writes at once.
200
+ // The connection rail is that shape three times over (the named-connector
201
+ // proxy and the brokered-MCP pair), and asking a person for a grant the
202
+ // caller could never use here would be a trap. So on the listed surface (a
203
+ // connected assistant, see ZiggsMcpConfig.directConnection) the rail is the
204
+ // read-only lister and nothing else, while a caller who configured this
205
+ // server with their own operator key keeps it whole. Nothing is lost on the
206
+ // listed side: the rail exists so hosted agents can borrow a person's
207
+ // connection under limits, and an assistant already has its own connectors.
204
208
  if (cfg.directConnection) {
205
209
  registerCapability(server, connectionProxyCapability, creds);
206
210
  }
207
211
  registerStrictTool(server, 'ziggs_connection_list', 'Discover the third-party connections (credentials like GitHub/Jira, and remote MCP servers — NOT agent-to-agent Links; see ziggs_link_list for that) you hold grants for, without the owner sharing connectionId/grantId out of band. ' +
208
212
  'Returns, per connection: connectionId, provider, and the grant(s) you hold — each as the canonical grant shape (grantId, scope, caveats, and grant health active/expired/revoked). ' +
209
213
  'Read-only — never returns credential material. ' +
210
- 'How to use a row: provider "mcp" → ziggs_mcp_tools_list / ziggs_mcp_tool_call' +
211
214
  (cfg.directConnection
212
- ? '; named connectors (github, jira, …) → ziggs_connection_proxy.'
213
- : '. Named-connector rows (github, jira, …) are readable here but not callable over this connection that rail is only on a self-configured Ziggs MCP connection.'), {}, readOnly('List connections you can use'), async () => {
215
+ ? 'How to use a row: provider "mcp" → ziggs_mcp_tools_list / ziggs_mcp_tool_call; named connectors (github, jira, …) → ziggs_connection_proxy.'
216
+ : 'Rows are readable here but not callable over this connection: using a connection (an MCP server or a named connector such as github or jira) is only on a self-configured Ziggs MCP connection with an operator key.'), {}, readOnly('List connections you can use'), async () => {
214
217
  try {
215
218
  const connections = await new ConnectionsClient(creds.operatorKey, creds.agentId).listForHolder();
216
219
  return textResult({ connections });
@@ -219,6 +222,9 @@ function registerConnectionTools(server, creds, cfg) {
219
222
  return toolError(e);
220
223
  }
221
224
  });
225
+ // Everything below uses or asks for a connection; see the note at the top.
226
+ if (!cfg.directConnection)
227
+ return;
222
228
  // Brokered MCP connections (provider: mcp). Not connection_proxy.
223
229
  registerStrictTool(server, 'ziggs_mcp_tools_list', "List the tools a connected MCP server exposes, through the Ziggs gateway — the owner's credential stays server-side. " +
224
230
  'The grant may allow only some of them; a call outside the grant is refused by the gateway. ' +
@@ -252,10 +258,7 @@ function registerConnectionTools(server, creds, cfg) {
252
258
  }
253
259
  });
254
260
  registerStrictTool(server, 'ziggs_mcp_tool_call', 'Call one tool on a connected MCP server through the Ziggs gateway. ' +
255
- 'Use this when the connection provider is a remote MCP server' +
256
- (cfg.directConnection
257
- ? ' — ziggs_connection_proxy is for named connectors (github, jira) and refuses MCP with "Unknown provider: mcp". '
258
- : '; named connectors (github, jira) are a different rail and are not callable here. ') +
261
+ 'Use this when the connection provider is a remote MCP server — ziggs_connection_proxy is for named connectors (github, jira) and refuses MCP with "Unknown provider: mcp". ' +
259
262
  "The gateway injects the owner's token and checks the tool name against your grant. Discover names with ziggs_mcp_tools_list.", {
260
263
  tool: z
261
264
  .string()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/ziggs-mcp",
3
- "version": "0.14.3",
3
+ "version": "0.15.0",
4
4
  "description": "MCP server for Claude Code, Cursor, and other MCP hosts — act as your Ziggs delegate agent",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@modelcontextprotocol/sdk": "^1.29.0",
42
- "@ziggs-ai/api-client": "0.14.2",
42
+ "@ziggs-ai/api-client": "0.14.3",
43
43
  "dotenv": "^16.6.1",
44
44
  "zod": "^3.24.2",
45
45
  "zod-to-json-schema": "^3.25.1"
@@ -3,7 +3,7 @@
3
3
 
4
4
  You are a delegate agent on a Ziggs team. The MCP tools are the connection; operate by this protocol.
5
5
 
6
- - Only the everyday tools are loaded; the rest of the surface is one call away. ziggs_tools lists every tool that exists (name + one line), ziggs_tools describe=["<name>"] returns its full schema, and ziggs_tool { tool, args } calls it. Nothing is hidden check ziggs_tools before concluding a capability is missing.
6
+ - Only the everyday tools are loaded; the rest of the surface is one call away. ziggs_tools lists every tool that exists (name + one line, and whether it is read-only), ziggs_tools describe=["<name>"] returns its full schema, ziggs_tool_read { tool, args } calls a read-only tool and ziggs_tool_write { tool, args } calls one that changes something. Nothing is hidden: check ziggs_tools before concluding a capability is missing.
7
7
  - Flow: inbox → read → act → ack.
8
8
  - Reading never advances the watermark; once you have handled what an envelope carried, pass its `ackTo` back VERBATIM as ack (it is opaque — never construct or edit one) together with `handledResourceIds` for every delivery ASSIGNED to you (assigneeId = you; requests too) in that window. Rows without your stamp are context another window handles — read them, never ack them as yours. Never rewind an ack to an older value.
9
9
  - Work is a task under an agreement (the ticket). Read it from the inbox — or, if handed a bare taskId, open it with ziggs_task_get — then post progress by naming the steps that changed with ziggs_task_update_steps. Restructure the checklist with ziggs_task_replace_plan (full list).
@@ -22,7 +22,7 @@ You represent a **delegate agent** on Ziggs. MCP tools are the connection; this
22
22
  <!-- BEGIN GENERATED: delegate-protocol — generated file, do not edit by hand -->
23
23
  _You are a delegate agent on a Ziggs team. The MCP tools are the connection; operate by this protocol._
24
24
 
25
- - Only the everyday tools are loaded; the rest of the surface is one call away. ziggs_tools lists every tool that exists (name + one line), ziggs_tools describe=["<name>"] returns its full schema, and ziggs_tool { tool, args } calls it. Nothing is hidden check ziggs_tools before concluding a capability is missing.
25
+ - Only the everyday tools are loaded; the rest of the surface is one call away. ziggs_tools lists every tool that exists (name + one line, and whether it is read-only), ziggs_tools describe=["<name>"] returns its full schema, ziggs_tool_read { tool, args } calls a read-only tool and ziggs_tool_write { tool, args } calls one that changes something. Nothing is hidden: check ziggs_tools before concluding a capability is missing.
26
26
  - Flow: inbox → read → act → ack.
27
27
  - Reading never advances the watermark; once you have handled what an envelope carried, pass its `ackTo` back VERBATIM as ack (it is opaque — never construct or edit one) together with `handledResourceIds` for every delivery ASSIGNED to you (assigneeId = you; requests too) in that window. Rows without your stamp are context another window handles — read them, never ack them as yours. Never rewind an ack to an older value.
28
28
  - Work is a task under an agreement (the ticket). Read it from the inbox — or, if handed a bare taskId, open it with ziggs_task_get — then post progress by naming the steps that changed with ziggs_task_update_steps. Restructure the checklist with ziggs_task_replace_plan (full list).
@@ -5,7 +5,7 @@
5
5
  <!-- BEGIN GENERATED: delegate-protocol — generated file, do not edit by hand -->
6
6
  _You are a delegate agent on a Ziggs team. The MCP tools are the connection; operate by this protocol._
7
7
 
8
- - Only the everyday tools are loaded; the rest of the surface is one call away. ziggs_tools lists every tool that exists (name + one line), ziggs_tools describe=["<name>"] returns its full schema, and ziggs_tool { tool, args } calls it. Nothing is hidden check ziggs_tools before concluding a capability is missing.
8
+ - Only the everyday tools are loaded; the rest of the surface is one call away. ziggs_tools lists every tool that exists (name + one line, and whether it is read-only), ziggs_tools describe=["<name>"] returns its full schema, ziggs_tool_read { tool, args } calls a read-only tool and ziggs_tool_write { tool, args } calls one that changes something. Nothing is hidden: check ziggs_tools before concluding a capability is missing.
9
9
  - Flow: inbox → read → act → ack.
10
10
  - Reading never advances the watermark; once you have handled what an envelope carried, pass its `ackTo` back VERBATIM as ack (it is opaque — never construct or edit one) together with `handledResourceIds` for every delivery ASSIGNED to you (assigneeId = you; requests too) in that window. Rows without your stamp are context another window handles — read them, never ack them as yours. Never rewind an ack to an older value.
11
11
  - Work is a task under an agreement (the ticket). Read it from the inbox — or, if handed a bare taskId, open it with ziggs_task_get — then post progress by naming the steps that changed with ziggs_task_update_steps. Restructure the checklist with ziggs_task_replace_plan (full list).
@@ -5,7 +5,7 @@
5
5
  <!-- BEGIN GENERATED: delegate-protocol — generated file, do not edit by hand -->
6
6
  _You are a delegate agent on a Ziggs team. The MCP tools are the connection; operate by this protocol._
7
7
 
8
- - Only the everyday tools are loaded; the rest of the surface is one call away. ziggs_tools lists every tool that exists (name + one line), ziggs_tools describe=["<name>"] returns its full schema, and ziggs_tool { tool, args } calls it. Nothing is hidden check ziggs_tools before concluding a capability is missing.
8
+ - Only the everyday tools are loaded; the rest of the surface is one call away. ziggs_tools lists every tool that exists (name + one line, and whether it is read-only), ziggs_tools describe=["<name>"] returns its full schema, ziggs_tool_read { tool, args } calls a read-only tool and ziggs_tool_write { tool, args } calls one that changes something. Nothing is hidden: check ziggs_tools before concluding a capability is missing.
9
9
  - Flow: inbox → read → act → ack.
10
10
  - Reading never advances the watermark; once you have handled what an envelope carried, pass its `ackTo` back VERBATIM as ack (it is opaque — never construct or edit one) together with `handledResourceIds` for every delivery ASSIGNED to you (assigneeId = you; requests too) in that window. Rows without your stamp are context another window handles — read them, never ack them as yours. Never rewind an ack to an older value.
11
11
  - Work is a task under an agreement (the ticket). Read it from the inbox — or, if handed a bare taskId, open it with ziggs_task_get — then post progress by naming the steps that changed with ziggs_task_update_steps. Restructure the checklist with ziggs_task_replace_plan (full list).