@oneie/sdk 0.14.12 → 0.14.13

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.
Files changed (60) hide show
  1. package/README.md +6 -1
  2. package/dist/.build-fingerprint +1 -0
  3. package/dist/billing.d.ts +12 -0
  4. package/dist/billing.d.ts.map +1 -1
  5. package/dist/billing.js +32 -5
  6. package/dist/billing.js.map +1 -1
  7. package/dist/blocks.d.ts +126 -0
  8. package/dist/blocks.d.ts.map +1 -0
  9. package/dist/blocks.js +407 -0
  10. package/dist/blocks.js.map +1 -0
  11. package/dist/client.d.ts +0 -4
  12. package/dist/client.d.ts.map +1 -1
  13. package/dist/client.js +1 -23
  14. package/dist/client.js.map +1 -1
  15. package/dist/compile.d.ts.map +1 -1
  16. package/dist/compile.js +26 -6
  17. package/dist/compile.js.map +1 -1
  18. package/dist/fetch.d.ts +17 -2
  19. package/dist/fetch.d.ts.map +1 -1
  20. package/dist/fetch.js +22 -26
  21. package/dist/fetch.js.map +1 -1
  22. package/dist/fn-allowlist.d.ts +1 -1
  23. package/dist/fn-allowlist.d.ts.map +1 -1
  24. package/dist/fn-allowlist.js +10 -1
  25. package/dist/fn-allowlist.js.map +1 -1
  26. package/dist/generated/fn-map.d.ts +1 -1
  27. package/dist/generated/fn-map.js +2 -2
  28. package/dist/generated/schemas/index.d.ts +1 -1
  29. package/dist/generated/schemas.d.ts +24 -6
  30. package/dist/generated/schemas.d.ts.map +1 -1
  31. package/dist/generated/schemas.js +15 -3
  32. package/dist/generated/schemas.js.map +1 -1
  33. package/dist/index.d.ts +1 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/market.d.ts +37 -1
  36. package/dist/market.d.ts.map +1 -1
  37. package/dist/market.js +26 -1
  38. package/dist/market.js.map +1 -1
  39. package/dist/receiver-action.d.ts +4 -0
  40. package/dist/receiver-action.d.ts.map +1 -0
  41. package/dist/receiver-action.js +38 -0
  42. package/dist/receiver-action.js.map +1 -0
  43. package/dist/receivers.d.ts +822 -22
  44. package/dist/receivers.d.ts.map +1 -1
  45. package/dist/receivers.js +1228 -35
  46. package/dist/receivers.js.map +1 -1
  47. package/dist/role-actions.d.ts +28 -0
  48. package/dist/role-actions.d.ts.map +1 -0
  49. package/dist/role-actions.js +68 -0
  50. package/dist/role-actions.js.map +1 -0
  51. package/dist/role-tiers.d.ts +45 -0
  52. package/dist/role-tiers.d.ts.map +1 -0
  53. package/dist/role-tiers.js +99 -0
  54. package/dist/role-tiers.js.map +1 -0
  55. package/dist/schemas.d.ts +23 -1
  56. package/dist/schemas.d.ts.map +1 -1
  57. package/dist/schemas.js +21 -0
  58. package/dist/schemas.js.map +1 -1
  59. package/dist/work-contract-grammar.json +33 -0
  60. package/package.json +18 -2
package/dist/receivers.js CHANGED
@@ -9,6 +9,19 @@ import { GroupEntitySchema } from "./generated/schemas/index.js";
9
9
  export function receiver(r) {
10
10
  return r;
11
11
  }
12
+ /**
13
+ * Receiver name → derived MCP tool name (`video:create-room` → `video_create_room`).
14
+ *
15
+ * ONE rule, two consumers — this module (to decide which receivers need an
16
+ * explicit `surfaces.mcp.name` override) and `mcpToolsFromRegistry()` in
17
+ * `@oneie/mcp`. A second copy is how the derived name and the registered name
18
+ * drift apart, which `check:tools` would then report as a missing tool.
19
+ *
20
+ * Deliberately NOT the channels rule (`/[^a-zA-Z0-9_-]/g`, which keeps hyphens):
21
+ * MCP tool names permit hyphens, but every one of the 101 hand-written MCP tools
22
+ * is underscore-only, so this matches the surface it feeds.
23
+ */
24
+ export const mcpToolName = (receiver) => receiver.replace(/[^a-zA-Z0-9_]/g, "_");
12
25
  const ok = z.object({ ok: z.boolean() });
13
26
  /**
14
27
  * The receiver catalog — one source of truth for capability. Object-literal keys
@@ -29,6 +42,7 @@ export const RECEIVERS = {
29
42
  }),
30
43
  "agent:own-link": receiver({
31
44
  receiver: "agent:own-link",
45
+ surfaces: { mcp: { name: "own_me" } },
32
46
  summary: "Generate a signed ownership invitation link for a registered agent; optionally deliver it as a native button on Telegram or Discord",
33
47
  request: z.object({
34
48
  agent_id: z.string().describe("The agent's actor ID (aid from world_actors / actorId from auth.md registration)"),
@@ -83,8 +97,11 @@ export const RECEIVERS = {
83
97
  }),
84
98
  "auth:agent": receiver({
85
99
  receiver: "auth:agent",
86
- summary: "Become an actor mint a uid, wallet, and scoped API key",
87
- request: z.object({ name: z.string().optional(), uid: z.string().optional(), kind: z.string().optional() }),
100
+ // `wallet` is a PUBLIC receiving address the caller derived in its own process.
101
+ // Nothing here generates keys: the address is stored on the TypeDB actor and
102
+ // registered through wallet:create, which is first-write-wins per chain.
103
+ summary: "Become an actor — mint a uid and a scoped API key, and register a wallets row (a public address if you supply one, an empty shell if not)",
104
+ request: z.object({ name: z.string().optional(), uid: z.string().optional(), kind: z.string().optional(), wallet: z.string().optional() }),
88
105
  response: z.object({
89
106
  uid: z.string(), name: z.string(), kind: z.string(),
90
107
  wallet: z.string().nullable(), apiKey: z.string(), keyId: z.string(), returning: z.boolean(),
@@ -95,6 +112,7 @@ export const RECEIVERS = {
95
112
  }),
96
113
  "agents:sync": receiver({
97
114
  receiver: "agents:sync",
115
+ surfaces: { mcp: { name: "sync_agent" } },
98
116
  summary: "Declare capability — sync agent definitions, skills, and memberships",
99
117
  // Accepts a single markdown body or a structured multi-agent world payload.
100
118
  request: z.union([
@@ -166,7 +184,7 @@ export const RECEIVERS = {
166
184
  summary: "Invite a human member to a workspace; email optional — returns inviteUrl when omitted",
167
185
  request: z.object({ workspace: z.string(), email: z.string().email().optional(), role: z.string().optional() }),
168
186
  response: z.object({ ok: z.boolean(), uid: z.string(), token: z.string(), inviteUrl: z.string().optional() }),
169
- effect: "ask", auth: "manage_members",
187
+ effect: "ask", auth: "manage_members", roleAction: "invite_member",
170
188
  }),
171
189
  "world:invite-agent": receiver({
172
190
  receiver: "world:invite-agent",
@@ -204,6 +222,7 @@ export const RECEIVERS = {
204
222
  }),
205
223
  "world:create-group": receiver({
206
224
  receiver: "world:create-group",
225
+ surfaces: { mcp: true },
207
226
  summary: "Create a group in the caller's workspace",
208
227
  request: z.object({
209
228
  name: z.string(),
@@ -215,17 +234,19 @@ export const RECEIVERS = {
215
234
  }),
216
235
  "world:update-group": receiver({
217
236
  receiver: "world:update-group",
237
+ surfaces: { mcp: true },
218
238
  summary: "Update a group's name, tags, or meta",
219
239
  request: z.object({ gid: z.string(), name: z.string().optional(), tags: z.array(z.string()).optional(), meta: z.unknown().optional() }),
220
- response: ok, effect: "ask", auth: "manage_groups",
240
+ response: ok, effect: "ask", auth: "manage_groups", roleAction: "update_group",
221
241
  }),
222
242
  "world:remove-group": receiver({
223
243
  receiver: "world:remove-group",
224
244
  summary: "Delete a group",
225
- request: z.object({ gid: z.string() }), response: ok, effect: "ask", auth: "manage_groups", reversible: false,
245
+ request: z.object({ gid: z.string() }), response: ok, effect: "ask", auth: "manage_groups", roleAction: "delete_group", reversible: false,
226
246
  }),
227
247
  "world:create-actor": receiver({
228
248
  receiver: "world:create-actor",
249
+ surfaces: { mcp: true },
229
250
  summary: "Create an actor (agent or human) in the world",
230
251
  request: z.object({
231
252
  name: z.string(), type: z.string(), group: z.string().optional(),
@@ -237,6 +258,7 @@ export const RECEIVERS = {
237
258
  }),
238
259
  "world:update-actor": receiver({
239
260
  receiver: "world:update-actor",
261
+ surfaces: { mcp: true },
240
262
  summary: "Update an actor's name, tags, prompt, model, or custom meta fields",
241
263
  request: z.object({ aid: z.string(), name: z.string().optional(), tags: z.array(z.string()).optional(), prompt: z.string().optional(), model: z.string().optional(), meta: z.unknown().optional() }),
242
264
  response: ok, effect: "ask", auth: "manage_actors",
@@ -390,6 +412,26 @@ export const RECEIVERS = {
390
412
  effect: "ask", auth: "manage_lifecycle", reversible: false,
391
413
  examples: [{ slug: "acme", lifecycle: "marketing", def: { id: "marketing", name: "Marketing Funnel", version: 1, stages: [], transitions: [], arcs: [], monotonic: true } }],
392
414
  }),
415
+ "lifecycle:of": receiver({
416
+ receiver: "lifecycle:of",
417
+ surfaces: { mcp: true },
418
+ summary: "Read another actor's lifecycle stage_transition history — authority-walked: the caller must control that actor's slug in the owners tree",
419
+ request: z.object({ actorId: z.string(), lifecycle: z.string().optional() }),
420
+ response: z.object({
421
+ ok: z.boolean(),
422
+ rows: z.array(z.object({
423
+ from_stage: z.string().nullable(),
424
+ to_stage: z.string(),
425
+ at: z.number(),
426
+ by: z.string(),
427
+ source: z.string(),
428
+ })).optional(),
429
+ error: z.string().optional(),
430
+ }),
431
+ effect: "ask", auth: "manage_lifecycle", roleAction: "manage_lifecycle",
432
+ cost: "free", idempotent: true, reversible: true,
433
+ examples: [{ actorId: "acme-agent", lifecycle: "wallet" }],
434
+ }),
393
435
  "tools:connect": receiver({
394
436
  receiver: "tools:connect",
395
437
  summary: "Connect a Composio API-KEY toolkit to a workspace headlessly (OAuth stays in the browser flow)",
@@ -408,12 +450,14 @@ export const RECEIVERS = {
408
450
  }),
409
451
  "world:create-thing": receiver({
410
452
  receiver: "world:create-thing",
453
+ surfaces: { mcp: true },
411
454
  summary: "Create a thing (skill, token, product) in the world",
412
455
  request: z.object({ name: z.string(), type: z.string(), group: z.string().optional(), tags: z.array(z.string()).optional(), price: z.number().optional(), meta: z.unknown().optional() }),
413
456
  response: z.object({ tid: z.string() }), effect: "ask", auth: "manage_things",
414
457
  }),
415
458
  "world:update-thing": receiver({
416
459
  receiver: "world:update-thing",
460
+ surfaces: { mcp: true },
417
461
  summary: "Update a thing's name, tags, price, or meta",
418
462
  request: z.object({ tid: z.string(), name: z.string().optional(), tags: z.array(z.string()).optional(), price: z.number().optional(), meta: z.unknown().optional() }),
419
463
  response: ok, effect: "ask", auth: "manage_things",
@@ -432,6 +476,7 @@ export const RECEIVERS = {
432
476
  }),
433
477
  "world:list-things": receiver({
434
478
  receiver: "world:list-things",
479
+ surfaces: { mcp: true },
435
480
  summary: "List things of a given type — relation pickers (browse, first 100 by name) and name resolution (pass `ids` to resolve exactly those, uncapped)",
436
481
  request: z.object({ group: z.string(), type: z.string(), ids: z.array(z.string()).optional() }),
437
482
  response: z.object({ items: z.array(z.object({ tid: z.string(), name: z.string() })) }),
@@ -439,6 +484,7 @@ export const RECEIVERS = {
439
484
  }),
440
485
  "world:list-actors": receiver({
441
486
  receiver: "world:list-actors",
487
+ surfaces: { mcp: true },
442
488
  summary: "List actors of a given type — relation pickers (browse, first 100 by name) and name resolution (pass `ids` to resolve exactly those, uncapped)",
443
489
  request: z.object({ group: z.string(), type: z.string(), ids: z.array(z.string()).optional() }),
444
490
  response: z.object({ items: z.array(z.object({ aid: z.string(), name: z.string() })) }),
@@ -572,6 +618,7 @@ export const RECEIVERS = {
572
618
  // ── identity ──
573
619
  "identity:address": receiver({
574
620
  receiver: "identity:address",
621
+ surfaces: { mcp: true },
575
622
  summary: "Resolve an actor's wallet address",
576
623
  request: z.object({ uid: z.string() }),
577
624
  response: z.object({ uid: z.string(), address: z.string() }),
@@ -592,6 +639,7 @@ export const RECEIVERS = {
592
639
  // ── groups ──
593
640
  "groups:join": receiver({
594
641
  receiver: "groups:join",
642
+ surfaces: { mcp: true },
595
643
  summary: "Join a group",
596
644
  request: z.object({ gid: z.string() }),
597
645
  response: z.object({ ok: z.boolean(), gid: z.string(), role: z.string() }),
@@ -599,6 +647,7 @@ export const RECEIVERS = {
599
647
  }),
600
648
  "groups:leave": receiver({
601
649
  receiver: "groups:leave",
650
+ surfaces: { mcp: true },
602
651
  summary: "Leave a group",
603
652
  request: z.object({ gid: z.string() }),
604
653
  response: ok,
@@ -613,6 +662,7 @@ export const RECEIVERS = {
613
662
  }),
614
663
  "groups:members": receiver({
615
664
  receiver: "groups:members",
665
+ surfaces: { mcp: true },
616
666
  summary: "List a group's members",
617
667
  request: z.object({ gid: z.string() }),
618
668
  response: z.object({
@@ -647,7 +697,16 @@ export const RECEIVERS = {
647
697
  receiver: "subscriptions:register",
648
698
  summary: "Subscribe to a receiver's events by tag",
649
699
  request: z.object({
650
- receiver: z.string(),
700
+ // OPTIONAL, and the default is the common case. `subscriptions.ts` reads
701
+ // this as `data['receiver'] || 'world:announce'` — an agent staking on
702
+ // tags omits it, which is how every `subscribes:` frontmatter seam works.
703
+ // Declaring it required was harmless while nothing validated the request;
704
+ // once `bind-receiver` began enforcing the declared schema it started
705
+ // rejecting the documented call with a 400 at the edge, before the
706
+ // resolver's default could apply. Caught by tests/e2e/marketplace.test.ts,
707
+ // which is the marketplace promise's proof observable — the seller's
708
+ // stake never registered, so no deal opened.
709
+ receiver: z.string().optional(),
651
710
  tags: z.array(z.string()),
652
711
  scope: z.enum(["private", "public"]).optional(),
653
712
  // Shared cross-tenant board — the one workspace two different ctx.ownerSlug
@@ -779,6 +838,7 @@ export const RECEIVERS = {
779
838
  }),
780
839
  "tasks:announce": receiver({
781
840
  receiver: "tasks:announce",
841
+ surfaces: { mcp: true },
782
842
  summary: "Announce a task into the world by its tags — the first caller of world:announce",
783
843
  request: z.object({
784
844
  taskId: z.string(),
@@ -792,6 +852,7 @@ export const RECEIVERS = {
792
852
  }),
793
853
  "tasks:mine": receiver({
794
854
  receiver: "tasks:mine",
855
+ surfaces: { mcp: true },
795
856
  summary: "My work queue: the open tasks whose words match what I subscribed to, ranked by learned weight (context-strength), not static priority",
796
857
  // `workspace` is not decoration — /api/ask reads a SERVICE caller's nominated slug off
797
858
  // validation.payload.slug|workspace, and zod .object() STRIPS unknown keys. Omitting the
@@ -822,6 +883,7 @@ export const RECEIVERS = {
822
883
  }),
823
884
  "tasks:everywhere": receiver({
824
885
  receiver: "tasks:everywhere",
886
+ surfaces: { mcp: true },
825
887
  summary: "Every workspace I belong to, one queue: open+picked tasks visible by assignment/follow/ownership, with claimable + mine + offeredByMe flags; closed:true returns the logbook (done/verified, newest first)",
826
888
  // See tasks:mine — a service caller nominates its workspace here, and zod strips any
827
889
  // field the schema does not declare.
@@ -953,6 +1015,7 @@ export const RECEIVERS = {
953
1015
  }),
954
1016
  "tasks:create": receiver({
955
1017
  receiver: "tasks:create",
1018
+ surfaces: { mcp: true },
956
1019
  summary: "Write one open task to the substrate and announce it by its tags in the same act — the quick-add entry point",
957
1020
  request: z.object({
958
1021
  title: z.string(),
@@ -972,6 +1035,7 @@ export const RECEIVERS = {
972
1035
  }),
973
1036
  "tasks:claim": receiver({
974
1037
  receiver: "tasks:claim",
1038
+ surfaces: { mcp: true },
975
1039
  summary: "Take a task off the queue: blocker-gate → status picked → tag the claimant @<slug>. Address it by tid, or by the /do slug the task was created under. The claimant is the attested caller, never a body field",
976
1040
  // Addressable two ways, and BOTH must be optional here. The resolver has always
977
1041
  // supported slug (resolveTaskBySlug) because create returns a random task:<traceId>
@@ -997,8 +1061,308 @@ export const RECEIVERS = {
997
1061
  }),
998
1062
  effect: "ask", idempotent: true,
999
1063
  }),
1064
+ "tasks:reap": receiver({
1065
+ receiver: "tasks:reap",
1066
+ surfaces: { mcp: true },
1067
+ summary: "Sweep stranded claim leases in a workspace: every `picked` task whose newest updated-at-ms is older than the 45-minute TTL goes back to `open`, its @claimant tags are stripped, and the abandoned path is warned",
1068
+ // The other half of the claim seam. tasks:claim is atomic, so two workers can
1069
+ // never double-lease — but a lease whose OWNER DIED is `picked` forever:
1070
+ // `claimable` requires `status === 'open' && ats.length === 0`, so the view
1071
+ // never surfaces it, so `leaseIsStale` (called only inside claim) is never
1072
+ // reached on the rows it was written for. Meanwhile ready-tasks blocks every
1073
+ // dependent on a `picked` blocker. This receiver queries `picked` DIRECTLY —
1074
+ // that is the whole point; asking the claimable view returns nothing.
1075
+ request: z.object({
1076
+ // Viewed workspace — same authorization contract as tasks:claim's workspace.
1077
+ workspace: z.string().optional(),
1078
+ }),
1079
+ response: z.object({
1080
+ ok: z.boolean().optional(),
1081
+ workspace: z.string().optional(),
1082
+ scanned: z.number().optional(),
1083
+ reaped: z.array(z.string()).optional(),
1084
+ failed: z.array(z.string()).optional(),
1085
+ error: z.string().optional(),
1086
+ }),
1087
+ // Idempotent: a second sweep finds nothing left stale and reaps nothing.
1088
+ effect: "ask", idempotent: true,
1089
+ }),
1090
+ // ── The rest of the board's verbs ────────────────────────────────────────────
1091
+ //
1092
+ // These twelve shipped as RESOLVERS ONLY (`tasksResolvers`, one.ie/web) and were
1093
+ // absent from this registry, so `bindReceiver` had no request schema to validate
1094
+ // and no auth label to apply — every one of them reached its handler with an
1095
+ // unchecked payload. They are also what the UI's detail pane, status menu, tag
1096
+ // chips and Gantt call, so the contract that was missing is the contract for most
1097
+ // of the board. `auth: "member"` is the label the family already resolves to
1098
+ // (an absent label falls to `authenticated`, same class) — stated, not changed.
1099
+ //
1100
+ // Every one takes an optional `workspace`, and it means the same thing here as on
1101
+ // tasks:create: the VIEWED workspace, honoured only when the caller is staff or
1102
+ // controls that tree, otherwise ignored in favour of the caller's own slug
1103
+ // (`effectiveWorkspace`). Declaring it is not decoration — zod `.object()` strips
1104
+ // undeclared keys, which is exactly how tasks:mine/everywhere were silently
1105
+ // un-nominatable until 2026-07-25.
1106
+ "tasks:status": receiver({
1107
+ receiver: "tasks:status",
1108
+ surfaces: { mcp: true },
1109
+ summary: "Move a task through the board: open · blocked · picked · done · verified · failed · dissolved. `verified` is refused for the claimant — it asserts a second pair of eyes. A `repeat:` tagged task that reaches done re-arms itself with the next due date instead of closing",
1110
+ request: z.object({
1111
+ tid: z.string(),
1112
+ status: z.enum(["open", "blocked", "picked", "done", "verified", "failed", "dissolved"]),
1113
+ workspace: z.string().optional(),
1114
+ }),
1115
+ response: z.object({
1116
+ ok: z.boolean().optional(),
1117
+ tid: z.string().optional(),
1118
+ status: z.string().optional(),
1119
+ // Set when a repeating task re-armed rather than closed.
1120
+ recurred: z.boolean().optional(),
1121
+ dueAt: z.string().optional(),
1122
+ error: z.string().optional(),
1123
+ }),
1124
+ effect: "ask", idempotent: true, auth: "member",
1125
+ }),
1126
+ "tasks:rename": receiver({
1127
+ receiver: "tasks:rename",
1128
+ surfaces: { mcp: true },
1129
+ summary: "Retitle a task in place. The tid is unchanged, so every link, blocker and follow survives the rename",
1130
+ request: z.object({
1131
+ tid: z.string(),
1132
+ title: z.string(),
1133
+ workspace: z.string().optional(),
1134
+ }),
1135
+ response: z.object({
1136
+ ok: z.boolean().optional(),
1137
+ tid: z.string().optional(),
1138
+ title: z.string().optional(),
1139
+ error: z.string().optional(),
1140
+ }),
1141
+ effect: "ask", idempotent: true, auth: "member",
1142
+ }),
1143
+ "tasks:list": receiver({
1144
+ receiver: "tasks:list",
1145
+ surfaces: { mcp: true },
1146
+ summary: "Every task carrying a tag, with its task-status — the read that lets a plan's checkboxes be a PROJECTION of task state instead of a second copy. Scoped to the caller's workspace on the server",
1147
+ request: z.object({
1148
+ tag: z.string(),
1149
+ workspace: z.string().optional(),
1150
+ }),
1151
+ response: z.object({
1152
+ ok: z.boolean().optional(),
1153
+ tag: z.string().optional(),
1154
+ tasks: z.array(z.object({
1155
+ tid: z.string(),
1156
+ status: z.string(),
1157
+ name: z.string().optional(),
1158
+ })).optional(),
1159
+ error: z.string().optional(),
1160
+ }),
1161
+ effect: "ask", idempotent: true, auth: "member",
1162
+ }),
1163
+ "tasks:notes": receiver({
1164
+ receiver: "tasks:notes",
1165
+ surfaces: { mcp: true },
1166
+ summary: "Set or clear a task's notes — the prose goal a puller reads to know what done means. An empty/absent `notes` CLEARS them, so never send the field unless you mean to change it",
1167
+ request: z.object({
1168
+ tid: z.string(),
1169
+ notes: z.string().nullable().optional(),
1170
+ workspace: z.string().optional(),
1171
+ }),
1172
+ response: z.object({
1173
+ ok: z.boolean().optional(),
1174
+ tid: z.string().optional(),
1175
+ notes: z.string().nullable().optional(),
1176
+ error: z.string().optional(),
1177
+ }),
1178
+ effect: "ask", idempotent: true, auth: "member",
1179
+ }),
1180
+ "tasks:priority": receiver({
1181
+ receiver: "tasks:priority",
1182
+ surfaces: { mcp: true },
1183
+ summary: "Set a task's priority from the 1–100 slider. Stored as the 0–1 `task-priority` attribute (slider/100), which is what the board's ranking reads",
1184
+ request: z.object({
1185
+ tid: z.string(),
1186
+ priority: z.number(),
1187
+ workspace: z.string().optional(),
1188
+ }),
1189
+ response: z.object({
1190
+ ok: z.boolean().optional(),
1191
+ tid: z.string().optional(),
1192
+ // The stored 0–1 value, not the 1–100 slider that was sent.
1193
+ priority: z.number().optional(),
1194
+ error: z.string().optional(),
1195
+ }),
1196
+ effect: "ask", idempotent: true, auth: "member",
1197
+ }),
1198
+ "tasks:schedule": receiver({
1199
+ receiver: "tasks:schedule",
1200
+ surfaces: { mcp: true },
1201
+ summary: "Set or clear a task's start/due dates — what the Gantt reads. PRESENCE of a field decides whether it is touched; an empty value clears it. An inverted range (start after due) is refused before any DB access",
1202
+ request: z.object({
1203
+ tid: z.string(),
1204
+ dueAt: z.string().nullable().optional(),
1205
+ startAt: z.string().nullable().optional(),
1206
+ workspace: z.string().optional(),
1207
+ }),
1208
+ response: z.object({
1209
+ ok: z.boolean().optional(),
1210
+ tid: z.string().optional(),
1211
+ dueAt: z.string().nullable().optional(),
1212
+ startAt: z.string().nullable().optional(),
1213
+ error: z.string().optional(),
1214
+ }),
1215
+ effect: "ask", idempotent: true, auth: "member",
1216
+ }),
1217
+ "tasks:tag": receiver({
1218
+ receiver: "tasks:tag",
1219
+ surfaces: { mcp: true },
1220
+ summary: "Add and/or remove board tags on a task. Plain words only — the `workspace:` and `@` namespaces are system-owned and are silently dropped, because they decide tenancy and assignment",
1221
+ request: z.object({
1222
+ tid: z.string(),
1223
+ add: z.array(z.string()).optional(),
1224
+ remove: z.array(z.string()).optional(),
1225
+ workspace: z.string().optional(),
1226
+ }),
1227
+ response: z.object({
1228
+ ok: z.boolean().optional(),
1229
+ tid: z.string().optional(),
1230
+ add: z.array(z.string()).optional(),
1231
+ remove: z.array(z.string()).optional(),
1232
+ error: z.string().optional(),
1233
+ }),
1234
+ effect: "ask", idempotent: true, auth: "member",
1235
+ }),
1236
+ "tasks:reassign": receiver({
1237
+ receiver: "tasks:reassign",
1238
+ surfaces: { mcp: true },
1239
+ summary: "Move one task to a different assignee IN PLACE — swaps the `@<slug>` tag and announces on the new tag set. An empty assignee unassigns it, and moves a `picked` task back to `open` so it is genuinely claimable again (claimable = open AND unassigned). This is the verb the task sheet's Reassign button was missing: it resolved through `buildAssign` to tasks:create and filed a duplicate `Handle: <title>` task instead",
1240
+ request: z.object({
1241
+ tid: z.string(),
1242
+ // Actor slug. Empty/null unassigns. A leading '@' is accepted and stripped.
1243
+ assignee: z.string().nullable().optional(),
1244
+ workspace: z.string().optional(),
1245
+ }),
1246
+ response: z.object({
1247
+ ok: z.boolean().optional(),
1248
+ tid: z.string().optional(),
1249
+ assignee: z.string().nullable().optional(),
1250
+ // Who held it before, so the caller can say "moved from X to Y" without a re-read.
1251
+ previous: z.string().nullable().optional(),
1252
+ tags: z.array(z.string()).optional(),
1253
+ unchanged: z.boolean().optional(),
1254
+ // Set only when unassigning reopened a `picked` task.
1255
+ status: z.string().optional(),
1256
+ reopened: z.boolean().optional(),
1257
+ error: z.string().optional(),
1258
+ }),
1259
+ effect: "ask", idempotent: true, auth: "member",
1260
+ }),
1261
+ "tasks:comment": receiver({
1262
+ receiver: "tasks:comment",
1263
+ surfaces: { mcp: true },
1264
+ summary: "Post a comment on a task's thread, or read the thread back when `body` is omitted. An @mention in the body subscribes that actor to the task. A reader (viewer) may comment even where it cannot move the task. Address by tid, or by the /do slug — same two-way contract as claim/link, so a wave-close heartbeat does not need the generated tid",
1265
+ request: z.object({
1266
+ tid: z.string().optional(),
1267
+ slug: z.string().optional(),
1268
+ body: z.string().optional(),
1269
+ workspace: z.string().optional(),
1270
+ }).refine((v) => !!v.tid || !!v.slug, {
1271
+ message: "address the task by tid or slug",
1272
+ path: ["tid"],
1273
+ }),
1274
+ response: z.object({
1275
+ ok: z.boolean().optional(),
1276
+ tid: z.string().optional(),
1277
+ messages: z.array(z.record(z.string(), z.unknown())).optional(),
1278
+ error: z.string().optional(),
1279
+ }),
1280
+ effect: "ask", idempotent: false, auth: "member",
1281
+ }),
1282
+ "tasks:subtask": receiver({
1283
+ receiver: "tasks:subtask",
1284
+ surfaces: { mcp: true },
1285
+ summary: "Create a child task and hang it under its parent with `containment` in one write. Reuses create's insert path — there is no second task-insert anywhere",
1286
+ request: z.object({
1287
+ parent: z.string(),
1288
+ title: z.string(),
1289
+ tags: z.array(z.string()).optional(),
1290
+ assignee: z.string().optional(),
1291
+ workspace: z.string().optional(),
1292
+ }),
1293
+ response: z.object({
1294
+ ok: z.boolean().optional(),
1295
+ tid: z.string().optional(),
1296
+ parent: z.string().optional(),
1297
+ tags: z.array(z.string()).optional(),
1298
+ error: z.string().optional(),
1299
+ }),
1300
+ effect: "ask", idempotent: false, auth: "member",
1301
+ }),
1302
+ "tasks:depend": receiver({
1303
+ receiver: "tasks:depend",
1304
+ surfaces: { mcp: true },
1305
+ summary: "Record that a task is blocked by another — the `blocks` edge the claim gate reads. Refuses a self-edge and a reverse edge (cycle); both ends must be in the caller's workspace",
1306
+ request: z.object({
1307
+ tid: z.string(),
1308
+ blockedBy: z.string(),
1309
+ workspace: z.string().optional(),
1310
+ }),
1311
+ response: z.object({
1312
+ ok: z.boolean().optional(),
1313
+ tid: z.string().optional(),
1314
+ blockedBy: z.string().optional(),
1315
+ error: z.string().optional(),
1316
+ }),
1317
+ effect: "ask", idempotent: true, auth: "member",
1318
+ }),
1319
+ "tasks:follow": receiver({
1320
+ receiver: "tasks:follow",
1321
+ surfaces: { mcp: true },
1322
+ summary: "Follow one task — a task-grain subscription (a `follows` row with receiver `tasks:announce`), so its announcements reach the caller's inbox. Refused across tenants",
1323
+ request: z.object({
1324
+ tid: z.string(),
1325
+ workspace: z.string().optional(),
1326
+ }),
1327
+ response: z.object({
1328
+ ok: z.boolean().optional(),
1329
+ tid: z.string().optional(),
1330
+ tags: z.array(z.string()).optional(),
1331
+ actor: z.string().optional(),
1332
+ error: z.string().optional(),
1333
+ }),
1334
+ effect: "ask", idempotent: true, auth: "member",
1335
+ }),
1336
+ "tasks:unfollow": receiver({
1337
+ receiver: "tasks:unfollow",
1338
+ surfaces: { mcp: true },
1339
+ summary: "Stop following one task — deletes exactly that task-grain subscription row, leaving tag-level subscriptions alone",
1340
+ request: z.object({
1341
+ tid: z.string(),
1342
+ workspace: z.string().optional(),
1343
+ }),
1344
+ response: z.object({
1345
+ ok: z.boolean().optional(),
1346
+ tid: z.string().optional(),
1347
+ actor: z.string().optional(),
1348
+ error: z.string().optional(),
1349
+ }),
1350
+ effect: "ask", idempotent: true, auth: "member",
1351
+ }),
1352
+ "tasks:follows": receiver({
1353
+ receiver: "tasks:follows",
1354
+ summary: "List the tasks the caller follows (task-grain subscriptions only, newest first, cap 100)",
1355
+ request: z.object({
1356
+ workspace: z.string().optional(),
1357
+ }),
1358
+ response: z.object({
1359
+ follows: z.array(z.record(z.string(), z.unknown())),
1360
+ }),
1361
+ effect: "ask", idempotent: true, auth: "member",
1362
+ }),
1000
1363
  "tasks:undepend": receiver({
1001
1364
  receiver: "tasks:undepend",
1365
+ surfaces: { mcp: true },
1002
1366
  summary: "Remove one blocks edge — the inverse of tasks:depend. Deletes exactly the (blockedBy → tid) pair; does not touch task status. Auth: attested caller only, both ends in the caller's workspace",
1003
1367
  request: z.object({
1004
1368
  tid: z.string(),
@@ -1033,6 +1397,7 @@ export const RECEIVERS = {
1033
1397
  }),
1034
1398
  "tasks:link": receiver({
1035
1399
  receiver: "tasks:link",
1400
+ surfaces: { mcp: true },
1036
1401
  summary: "Close the handoff: record that a claimed task was built via /do <slug> — append a provenance line to the task's notes (the id back-ref + doc stems) and move it to done. Auth: attested caller only",
1037
1402
  // The slug IS the handle here — the resolver requires it and resolves the origin tid
1038
1403
  // from the `slug:` tag, treating an explicit tid as an optional override for direct
@@ -1057,18 +1422,31 @@ export const RECEIVERS = {
1057
1422
  effect: "ask", idempotent: true,
1058
1423
  }),
1059
1424
  // ── agents (TRADE / lifecycle) ──
1425
+ // THE PUBLIC DOOR. An arriving agent joins here with no human in the loop and
1426
+ // no prior credential, and gets a SCOPED rung: it may list capabilities and be
1427
+ // paid; it may not spend the compute float until one x402 challenge is
1428
+ // satisfied, and it may never touch another workspace.
1429
+ //
1430
+ // `uid` is OPTIONAL and is never authority. An anonymous caller's identity is
1431
+ // MINTED by the handler and its `uid` is ignored outright; an attested caller
1432
+ // may only name an identity it already controls (callerControlsWorkspace).
1433
+ // That is the whole IDOR rule: the subject is derived, never declared.
1060
1434
  "agents:register": receiver({
1061
1435
  receiver: "agents:register",
1062
- summary: "Register an agent as a sellable actor with capabilities",
1436
+ surfaces: { mcp: { name: "register" } },
1437
+ summary: "Register as a sellable actor with capabilities. Public: call it with no credential and it mints a scoped identity (returns actorId + apiKey, compute float locked until one x402 payment).",
1063
1438
  request: z.object({
1064
- uid: z.string(),
1439
+ /** Omit when anonymous — the handler mints one. Naming another agent's uid is refused. */
1440
+ uid: z.string().optional(),
1441
+ /** Display handle for a fresh anonymous registration. Never authority. */
1442
+ name: z.string().optional(),
1065
1443
  kind: z.string().optional(),
1066
1444
  capabilities: z.array(z.object({ skill: z.string(), price: z.number().optional() })).optional(),
1067
1445
  wallet: z.string().optional(),
1068
1446
  chain: z.string().optional(),
1069
1447
  }),
1070
1448
  response: RegisterResponseSchema,
1071
- effect: "ask", idempotent: true,
1449
+ effect: "ask", auth: "public", idempotent: true,
1072
1450
  }),
1073
1451
  "agents:commend": receiver({
1074
1452
  receiver: "agents:commend",
@@ -1102,26 +1480,35 @@ export const RECEIVERS = {
1102
1480
  // ── pay (TRANSACT — onchain settlement) ──
1103
1481
  "pay:weight": receiver({
1104
1482
  receiver: "pay:weight",
1483
+ surfaces: { mcp: { name: "pay" } },
1105
1484
  summary: "Pay for a task by weighting the path between two actors",
1106
- request: z.object({ from: z.string(), to: z.string(), task: z.string(), amount: z.number() }),
1485
+ request: z.object({
1486
+ // DERIVED, never sent: the handler sets `from = ctx.ownerSlug` and never reads
1487
+ // data.from. Declaring it required made the binder reject every valid call
1488
+ // before the handler could derive it — and before its own auth check ran.
1489
+ from: z.string().optional(),
1490
+ to: z.string(), task: z.string(), amount: z.number(),
1491
+ }),
1107
1492
  response: PayResponseSchema,
1108
1493
  effect: "ask", cost: "variable", settles: "onchain", reversible: false, simulatable: true,
1109
1494
  }),
1110
1495
  "tasks:stake": receiver({
1111
1496
  receiver: "tasks:stake",
1112
- summary: "Stake SUI against a task on-chain to raise its priority weight. 0.1 SUI = +1 weight unit. Irreversible conviction signal, not a deposit.",
1497
+ summary: "Record a visitor-signed stake_on_tags burn on Sui testnet. Caller signs in their wallet (0.1 SUI per weight); the worker verifies the digest and marks the path. No server key. Irreversible. Not a SUI deposit against a Task object.",
1113
1498
  request: z.object({
1114
- taskObjectId: z.string().regex(/^0x[a-fA-F0-9]{64}$/, "Sui object ID"),
1115
- units: z.number().int().positive().default(1),
1499
+ tags: z.array(z.string().min(1)).min(1).max(8),
1500
+ addWeight: z.number().int().optional(),
1501
+ digest: z.string().min(20),
1116
1502
  }),
1117
1503
  response: z.object({
1118
1504
  ok: z.boolean(),
1119
1505
  digest: z.string().optional(),
1120
- weightBefore: z.number().optional(),
1121
- weightAfter: z.number().optional(),
1122
- suiSpent: z.number().optional(),
1506
+ newWeight: z.number().optional(),
1507
+ tags: z.array(z.string()).optional(),
1508
+ error: z.string().optional(),
1123
1509
  }),
1124
- effect: "ask", cost: "variable", settles: "onchain", reversible: false, simulatable: false,
1510
+ effect: "ask", auth: "required", cost: "variable", settles: "onchain", reversible: false, simulatable: false,
1511
+ examples: [{ tags: ["engineering", "ontology", "marketing"], addWeight: 20, digest: "VisitorSignedDigestFromSuiTestnetTx" }],
1125
1512
  }),
1126
1513
  // ── market (A2A negotiation, C2) ──
1127
1514
  "market:offer": receiver({
@@ -1266,7 +1653,11 @@ export const RECEIVERS = {
1266
1653
  receiver: "market:bounty",
1267
1654
  summary: "Post a bounty for a skill, backed by an escrow",
1268
1655
  request: z.object({
1269
- skillId: z.string(), sellerUid: z.string(), posterUid: z.string(), price: z.number(),
1656
+ skillId: z.string(), sellerUid: z.string(),
1657
+ // DERIVED, never sent: the handler sets `posterUid = ctx.ownerSlug`
1658
+ // (resolvers/market.ts). Required here, it rejected every valid post.
1659
+ posterUid: z.string().optional(),
1660
+ price: z.number(),
1270
1661
  tags: z.array(z.string()).optional(),
1271
1662
  content: z.record(z.string(), z.unknown()).optional(),
1272
1663
  rubric: z.object({
@@ -1301,6 +1692,32 @@ export const RECEIVERS = {
1301
1692
  ]),
1302
1693
  effect: "ask", cost: "free", reversible: false, idempotent: false,
1303
1694
  }),
1695
+ "market:search": receiver({
1696
+ receiver: "market:search",
1697
+ summary: "Search the whole marketplace catalog — agents, skills, plugins, live listings and open bounties — and get back named, card-shaped results",
1698
+ request: z.object({
1699
+ q: z.string().describe("What the shopper is looking for, in their own words"),
1700
+ kind: z.enum(["agent", "skill", "plugin", "listing", "bounty"]).optional(),
1701
+ limit: z.number().int().positive().optional(),
1702
+ }),
1703
+ response: z.object({
1704
+ ok: z.boolean(),
1705
+ total: z.number(),
1706
+ matched: z.number(),
1707
+ results: z.array(z.object({
1708
+ kind: z.string(), ref: z.string(), title: z.string(), blurb: z.string(),
1709
+ tags: z.array(z.string()),
1710
+ // null means "carries no price" — never render it as 0. `priceLabel` is
1711
+ // the honest string for a card ("from 25 credits", "$49", or null).
1712
+ price: z.number().nullable(), unit: z.string().nullable(),
1713
+ sellers: z.number(), priceLabel: z.string().nullable(), url: z.string(),
1714
+ })),
1715
+ }),
1716
+ // Browse is public on /marketplace and public here — the concierge answers
1717
+ // "who sells X" for a signed-out visitor, which is the whole point.
1718
+ auth: "open",
1719
+ effect: "ask", cost: "free", idempotent: true,
1720
+ }),
1304
1721
  "market:list": receiver({
1305
1722
  receiver: "market:list",
1306
1723
  summary: "List the capability market",
@@ -1313,7 +1730,13 @@ export const RECEIVERS = {
1313
1730
  receiver: "capabilities:publish",
1314
1731
  summary: "Publish a capability (skill listing) to the market",
1315
1732
  request: z.object({
1316
- skillId: z.string(), name: z.string(), price: z.number(),
1733
+ skillId: z.string(), name: z.string(),
1734
+ // OPTIONAL — `market.ts` reads it as `Math.max(0, num(...))`, so an
1735
+ // omitted price is a free listing, not a refusal. Declaring it required
1736
+ // meant the edge rejected a call the resolver was written to accept, the
1737
+ // same drift that took `subscriptions:register` down once bind-receiver
1738
+ // began enforcing declared schemas.
1739
+ price: z.number().optional(),
1317
1740
  mode: z.string().optional(), visibility: z.string().optional(), scope: z.string().optional(), entitlement: z.string().optional(),
1318
1741
  tags: z.array(z.string()).optional(),
1319
1742
  rubricThresholds: z.object({
@@ -1341,6 +1764,7 @@ export const RECEIVERS = {
1341
1764
  }),
1342
1765
  "stats:current": receiver({
1343
1766
  receiver: "stats:current",
1767
+ surfaces: { mcp: { name: "stats" } },
1344
1768
  summary: "Current world stats — units, skills, highways, revenue, signals",
1345
1769
  request: z.object({}),
1346
1770
  response: StatsSchema,
@@ -1371,6 +1795,7 @@ export const RECEIVERS = {
1371
1795
  // ════════════════════════════════════════════════════════════════════════
1372
1796
  "meta:catalog": receiver({
1373
1797
  receiver: "meta:catalog",
1798
+ surfaces: { mcp: true },
1374
1799
  summary: "List every receiver the caller can use (with cost/reversibility/settlement), or a goal's recipe",
1375
1800
  request: z.object({ goal: z.enum(["spine", "build", "trade", "transact"]).optional() }),
1376
1801
  response: z.union([
@@ -1385,6 +1810,7 @@ export const RECEIVERS = {
1385
1810
  }),
1386
1811
  "meta:schema": receiver({
1387
1812
  receiver: "meta:schema",
1813
+ surfaces: { mcp: true },
1388
1814
  summary: "JSON Schema for one receiver's request + response — read it before you call",
1389
1815
  request: z.object({ receiver: z.string() }),
1390
1816
  response: z.object({
@@ -1395,6 +1821,7 @@ export const RECEIVERS = {
1395
1821
  }),
1396
1822
  "meta:recall": receiver({
1397
1823
  receiver: "meta:recall",
1824
+ surfaces: { mcp: true },
1398
1825
  summary: "Recall the caller's hypotheses (memory), optionally filtered by a search term",
1399
1826
  request: z.object({ match: z.string().optional(), limit: z.number().optional() }),
1400
1827
  response: z.object({
@@ -1422,6 +1849,7 @@ export const RECEIVERS = {
1422
1849
  }),
1423
1850
  "meta:types": receiver({
1424
1851
  receiver: "meta:types",
1852
+ surfaces: { mcp: true },
1425
1853
  summary: "Read the resource-type manifest for the caller's workspace, plus the built-in industry templates",
1426
1854
  request: z.object({}),
1427
1855
  response: z.object({
@@ -1542,6 +1970,13 @@ export const RECEIVERS = {
1542
1970
  }),
1543
1971
  "notify": receiver({
1544
1972
  receiver: "notify",
1973
+ // NOT surfaces.mcp — the curated `message` tool in packages/mcp/src/tools/
1974
+ // messaging.ts already fronts this receiver and carries shaping the registry
1975
+ // cannot infer: it defaults `kind: "message"` and names the argument
1976
+ // `content` in a description written for a sender, not a schema. Flagging it
1977
+ // produced two tools called `message`, of which the curated one won
1978
+ // registration and the derived one was dead weight. Curated survives here;
1979
+ // the registry does not claim a surface something else already serves.
1545
1980
  summary: "Notify any actor (human or agent) by uid — channels routes the door (Telegram/Discord/peer inbox), mirrors into the web inbox, nudges an open client live, and sends a web push if the recipient subscribed. One verb, both actor kinds. An optional action renders as one tap-through link.",
1546
1981
  request: z.object({
1547
1982
  receiver: z.string(),
@@ -1971,14 +2406,30 @@ export const RECEIVERS = {
1971
2406
  images: z.array(z.string()).optional(),
1972
2407
  product_type: z.string().optional(), // default 'one_time'
1973
2408
  collection: z.string().optional(),
2409
+ // Same stripped-at-the-edge trap as the two artifact fields below, and it
2410
+ // cost more: resolvers/commerce.ts has read `data.status` since it shipped,
2411
+ // but the key was never declared here, so zod dropped it and EVERY product
2412
+ // created through this receiver came back 'draft'. Proven behaviourally
2413
+ // 2026-08-23 — POST with status:"active" returned 200, the row read 'draft'.
2414
+ // An agent could list a product and had no way to make it sellable.
2415
+ status: z.enum(["draft", "active", "archived"]).optional(),
2416
+ // The ARTIFACT listing door. Declared here because validateReceiver returns
2417
+ // zod's PARSED output, so an undeclared key is stripped at the HTTP edge and
2418
+ // never reaches the handler. `delivery_r2_key` is accepted ONLY as a
2419
+ // `{kind}:{ref}` install descriptor — the plain-R2-key write door stays shut.
2420
+ product_kind: z.literal("artifact").optional(),
2421
+ delivery_r2_key: z.string().optional(),
1974
2422
  }),
1975
2423
  response: z.object({ pid: z.string().optional(), ppid: z.string().optional(), name: z.string().optional(), workspace: z.string().optional(), error: z.string().optional() }),
1976
2424
  effect: "ask", cost: "free", reversible: true, idempotent: false, auth: "member",
1977
2425
  }),
1978
2426
  "products:update": receiver({
1979
2427
  receiver: "products:update",
1980
- summary: "Update a product's name, description, images, or collection",
1981
- request: z.object({ slug: z.string(), pid: z.string(), name: z.string().optional(), description: z.string().optional(), images: z.array(z.string()).optional(), collection: z.string().optional() }),
2428
+ summary: "Update a product's name, description, images, collection, or status",
2429
+ // `status` carries the same history as on products:create the resolver has
2430
+ // always read it, the declaration never listed it, so zod stripped it and a
2431
+ // draft product could not be published through this receiver either.
2432
+ request: z.object({ slug: z.string(), pid: z.string(), name: z.string().optional(), description: z.string().optional(), images: z.array(z.string()).optional(), collection: z.string().optional(), status: z.enum(["draft", "active", "archived"]).optional(), product_kind: z.literal("artifact").optional(), delivery_r2_key: z.string().optional() }),
1982
2433
  response: z.object({ pid: z.string().optional(), workspace: z.string().optional(), error: z.string().optional() }),
1983
2434
  effect: "ask", cost: "free", reversible: false, idempotent: true, auth: "member",
1984
2435
  }),
@@ -2003,6 +2454,429 @@ export const RECEIVERS = {
2003
2454
  response: z.object({ pid: z.string().optional(), workspace: z.string().optional(), status: z.string().optional(), error: z.string().optional() }),
2004
2455
  effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
2005
2456
  }),
2457
+ // ── variants (product options: size/colour) — handlers in resolvers/commerce.ts ─
2458
+ // stock IS NULL means UNTRACKED, never zero: a service or digital product is
2459
+ // never blocked by an inventory check it did not opt into. On update, an ABSENT
2460
+ // stock field leaves the value unchanged; an explicit null sets untracked.
2461
+ "variants:list": receiver({
2462
+ receiver: "variants:list",
2463
+ summary: "List a product's active variants with SKU, price, weight and stock (public)",
2464
+ request: z.object({ slug: z.string(), pid: z.string() }),
2465
+ response: z.object({ variants: z.array(z.record(z.string(), z.unknown())).optional(), error: z.string().optional() }),
2466
+ effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "public",
2467
+ }),
2468
+ "variants:create": receiver({
2469
+ receiver: "variants:create",
2470
+ summary: "Add a variant to a product — its own SKU, price, weight and stock",
2471
+ request: z.object({
2472
+ slug: z.string(),
2473
+ pid: z.string(),
2474
+ title: z.string(), // "Blue / M"
2475
+ unit_amount: z.number().int(), // cents
2476
+ sku: z.string().optional(),
2477
+ options: z.record(z.string(), z.string()).optional(),
2478
+ currency: z.string().optional(), // default 'usd'
2479
+ weight_grams: z.number().int().optional(),
2480
+ stock: z.number().int().nullable().optional(), // null/absent = untracked
2481
+ position: z.number().int().optional(),
2482
+ }),
2483
+ response: z.object({ vid: z.string().optional(), pid: z.string().optional(), workspace: z.string().optional(), error: z.string().optional() }),
2484
+ effect: "ask", cost: "free", reversible: true, idempotent: false, auth: "member",
2485
+ }),
2486
+ "variants:update": receiver({
2487
+ receiver: "variants:update",
2488
+ summary: "Update a variant — omit stock to leave it unchanged, send null to make it untracked",
2489
+ request: z.object({
2490
+ slug: z.string(),
2491
+ vid: z.string(),
2492
+ title: z.string().optional(),
2493
+ unit_amount: z.number().int().optional(),
2494
+ sku: z.string().optional(),
2495
+ options: z.record(z.string(), z.string()).optional(),
2496
+ currency: z.string().optional(),
2497
+ weight_grams: z.number().int().optional(),
2498
+ stock: z.number().int().nullable().optional(), // absent is not null: absent leaves it unchanged
2499
+ position: z.number().int().optional(),
2500
+ active: z.boolean().optional(),
2501
+ }),
2502
+ response: z.object({ vid: z.string().optional(), workspace: z.string().optional(), error: z.string().optional() }),
2503
+ effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
2504
+ }),
2505
+ "variants:archive": receiver({
2506
+ receiver: "variants:archive",
2507
+ summary: "Archive a variant (reversible via variants:update { active: true })",
2508
+ request: z.object({ slug: z.string(), vid: z.string() }),
2509
+ response: z.object({ vid: z.string().optional(), workspace: z.string().optional(), active: z.number().optional(), error: z.string().optional() }),
2510
+ effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
2511
+ }),
2512
+ // ── cart (server-truth basket) — handlers land in resolvers/cart.ts (C3) ───────
2513
+ // Registered here, not by C3: signals-parity counts RECEIVERS against
2514
+ // text/signals-catalog.md and is a live accept: on another kept promise.
2515
+ // `cid` is a server-minted bearer capability (never client-chosen), which is
2516
+ // what makes an anonymous cart safe at auth: "public". No request carries a
2517
+ // unit_amount — every line is re-priced server-side from D1 by ppid/vid.
2518
+ "cart:get": receiver({
2519
+ receiver: "cart:get",
2520
+ summary: "Read a cart and its lines by server-minted cart id",
2521
+ request: z.object({ slug: z.string(), cid: z.string() }),
2522
+ response: z.object({ cart: z.record(z.string(), z.unknown()).nullable().optional(), items: z.array(z.record(z.string(), z.unknown())).optional(), error: z.string().optional() }),
2523
+ effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "public",
2524
+ }),
2525
+ "cart:add": receiver({
2526
+ receiver: "cart:add",
2527
+ summary: "Add a product (or variant) line to a cart, minting the cart when cid is absent — price is read server-side, never sent",
2528
+ request: z.object({
2529
+ slug: z.string(),
2530
+ pid: z.string(),
2531
+ cid: z.string().optional(), // absent mints a new cart
2532
+ ppid: z.string().optional(),
2533
+ vid: z.string().optional(),
2534
+ quantity: z.number().int().positive().optional(), // default 1
2535
+ }),
2536
+ response: z.object({ cid: z.string().optional(), ciid: z.string().optional(), items: z.array(z.record(z.string(), z.unknown())).optional(), error: z.string().optional() }),
2537
+ effect: "ask", cost: "free", reversible: true, idempotent: false, auth: "public",
2538
+ }),
2539
+ "cart:update": receiver({
2540
+ receiver: "cart:update",
2541
+ summary: "Set a cart line's quantity (0 removes the line)",
2542
+ request: z.object({ slug: z.string(), cid: z.string(), ciid: z.string(), quantity: z.number().int().min(0) }),
2543
+ response: z.object({ cid: z.string().optional(), items: z.array(z.record(z.string(), z.unknown())).optional(), error: z.string().optional() }),
2544
+ effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "public",
2545
+ }),
2546
+ "cart:remove": receiver({
2547
+ receiver: "cart:remove",
2548
+ summary: "Remove one line from a cart",
2549
+ request: z.object({ slug: z.string(), cid: z.string(), ciid: z.string() }),
2550
+ response: z.object({ cid: z.string().optional(), items: z.array(z.record(z.string(), z.unknown())).optional(), error: z.string().optional() }),
2551
+ effect: "ask", cost: "free", reversible: false, idempotent: true, auth: "public",
2552
+ }),
2553
+ "cart:clear": receiver({
2554
+ receiver: "cart:clear",
2555
+ summary: "Empty a cart, keeping the cart itself",
2556
+ request: z.object({ slug: z.string(), cid: z.string() }),
2557
+ response: z.object({ cid: z.string().optional(), cleared: z.number().optional(), error: z.string().optional() }),
2558
+ effect: "ask", cost: "free", reversible: false, idempotent: true, auth: "public",
2559
+ }),
2560
+ // ── orders (the one operator write door) — handler lands in resolvers (C5) ─────
2561
+ // data-bindings.ts:62 marks `order` create/update/archive as null by design:
2562
+ // orders are webhook-minted and otherwise read-only. This is the deliberate
2563
+ // exception. `status` is z.string(), not an enum — the D1 column is bare TEXT
2564
+ // (0118_orders.sql:15) and C5's resolver owns the allowlist.
2565
+ "orders:status": receiver({
2566
+ receiver: "orders:status",
2567
+ summary: "Set an order's status — the one operator write on a webhook-minted record",
2568
+ request: z.object({ slug: z.string(), oid: z.string(), status: z.string() }),
2569
+ response: z.object({ oid: z.string().optional(), workspace: z.string().optional(), status: z.string().optional(), error: z.string().optional() }),
2570
+ effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
2571
+ }),
2572
+ // ── order:claim — the buyer's delivery door (handler: resolvers/commerce.ts) ──
2573
+ // Declared so the route's envelope guard fires: an undeclared receiver skips
2574
+ // the `envelope_missing` check, and a flat body silently validates as {}.
2575
+ // `auth: "public"` because an anonymous buyer claims with the order's own
2576
+ // session_id as bearer proof; the handler holds the real attestation (session
2577
+ // match OR ctx-attested workspace control), never a body-supplied identity.
2578
+ "order:claim": receiver({
2579
+ receiver: "order:claim",
2580
+ summary: "Claim a paid order's delivery — returns a scoped download URL for the digital good",
2581
+ request: z.object({
2582
+ oid: z.string(),
2583
+ workspace: z.string().optional(),
2584
+ session_id: z.string().optional(),
2585
+ }),
2586
+ response: z.object({
2587
+ ok: z.boolean().optional(),
2588
+ url: z.string().optional(),
2589
+ name: z.string().optional(),
2590
+ error: z.string().optional(),
2591
+ }),
2592
+ effect: "ask", cost: "free", reversible: true, idempotent: true, settles: "none", auth: "public",
2593
+ examples: [{ oid: "ord_1", session_id: "cs_test_1" }],
2594
+ }),
2595
+ // ── order:install — the buyer's ARTIFACT door (handler: resolvers/install.ts) ──
2596
+ // The sibling of order:claim, and deliberately NOT the same receiver. order:claim
2597
+ // is `auth: "public"` because it hands back a FILE: knowing (oid, session_id) is
2598
+ // the whole proof, which is right for a crypto buyer who never signs in. Install
2599
+ // inverts the direction of authority — a SELLER's product causes a WRITE into a
2600
+ // BUYER's workspace — so it is the IDOR shape ~80 workspace routes were already
2601
+ // fixed for, and it gets a tenant-class label (`manage_things`) so bind-receiver's
2602
+ // AUTH_POLICY floor is ENFORCED before the handler runs. The handler then walks
2603
+ // the destination with callerControlsWorkspace(ctx.ownerSlug, …); `workspace` in
2604
+ // the body NAMES a target, it never authorizes one. order:claim is untouched.
2605
+ "order:install": receiver({
2606
+ receiver: "order:install",
2607
+ summary: "Install a paid artifact order into a workspace the caller controls — routes to skill:save / world:publish-agent / workflow:create / view:create by the delivery descriptor's kind",
2608
+ request: z.object({
2609
+ oid: z.string(),
2610
+ workspace: z.string().optional(), // destination; defaults to the attested caller
2611
+ session_id: z.string().optional(), // the order's own receipt, as in order:claim
2612
+ }),
2613
+ response: z.object({
2614
+ ok: z.boolean().optional(),
2615
+ kind: z.enum(["skill", "agent", "workflow", "view", "playbook"]).optional(),
2616
+ ref: z.string().optional(),
2617
+ workspace: z.string().optional(),
2618
+ installed: z.record(z.string(), z.unknown()).optional(),
2619
+ error: z.string().optional(),
2620
+ }),
2621
+ effect: "ask", cost: "free", reversible: false, idempotent: false, settles: "none", auth: "manage_things",
2622
+ examples: [{ oid: "ord_1", session_id: "cs_test_1" }, { oid: "ord_1", workspace: "elitemoversca" }],
2623
+ }),
2624
+ // ── wallet:create — register RECEIVING ADDRESSES only (handler: resolvers/wallet.ts) ──
2625
+ // Custody invariant: `addresses` carries public addresses and nothing else. No
2626
+ // field here accepts a private key or a mnemonic, and none is emitted back.
2627
+ // Omitting `addresses` provisions an address-less row (the pre-existing
2628
+ // idempotent path); supplying it merges first-write-wins per chain.
2629
+ "wallet:create": receiver({
2630
+ receiver: "wallet:create",
2631
+ summary: "Provision a wallet row for an actor and register its public receiving addresses (first-write-wins per chain)",
2632
+ request: z.object({
2633
+ workspace: z.string().optional(),
2634
+ actor: z.string().optional(),
2635
+ kind: z.enum(["human", "agent"]).optional(),
2636
+ addresses: z.object({
2637
+ sui: z.string().nullable().optional(),
2638
+ evm: z.string().nullable().optional(),
2639
+ solana: z.string().nullable().optional(),
2640
+ btc: z.string().nullable().optional(),
2641
+ }).optional(),
2642
+ }),
2643
+ response: z.object({
2644
+ ok: z.boolean().optional(),
2645
+ actor: z.string().optional(),
2646
+ workspace: z.string().optional(),
2647
+ kind: z.string().optional(),
2648
+ addresses: z.object({
2649
+ sui: z.string().nullable().optional(),
2650
+ evm: z.string().nullable().optional(),
2651
+ solana: z.string().nullable().optional(),
2652
+ btc: z.string().nullable().optional(),
2653
+ }).optional(),
2654
+ error: z.string().optional(),
2655
+ field: z.string().optional(),
2656
+ }),
2657
+ effect: "ask", cost: "free", reversible: false, idempotent: true, settles: "none", auth: "member",
2658
+ examples: [{ workspace: "acme", kind: "human", addresses: { sui: "0xabc", evm: "0xdef" } }],
2659
+ }),
2660
+ // ── wallet:rehearse — READ a TESTNET transaction back (handler: resolvers/wallet.ts) ──
2661
+ // The rehearsal step of the /u/one funnel: a visitor sends play money on Sui
2662
+ // testnet from their own derived address, and this reads the digest back so the
2663
+ // chat can say what actually happened on chain.
2664
+ //
2665
+ // IT GRANTS NOTHING, and that is the whole contract — `credits` is a
2666
+ // z.literal(0), so the response schema itself forbids a grant. The reason is
2667
+ // not squeamishness: `currentBalance` (web/src/lib/credits.ts) sums
2668
+ // `credit_grants` with NO `test_mode` filter, so a credit granted for testnet
2669
+ // money would be real, fully spendable credit minted out of play money.
2670
+ //
2671
+ // `network` is a z.literal("testnet") on BOTH sides. There is deliberately no
2672
+ // parameter by which a caller could point this at mainnet — the resolver
2673
+ // hardcodes the testnet RPC. A mainnet read belongs to the settling rail
2674
+ // (pay's payment_link_claim), which moves money and therefore takes attestation.
2675
+ //
2676
+ // auth "public" because it neither writes nor grants: a chain read with a rate
2677
+ // limit, placed in front of anonymous workspace-root traffic. The resolver
2678
+ // limits per ATTESTED `ctx.visitorHash` — the shape `movers:funnel-provision`
2679
+ // already uses. A caller may name an address; it can never name a spend.
2680
+ "wallet:rehearse": receiver({
2681
+ receiver: "wallet:rehearse",
2682
+ summary: "Read a TESTNET Sui transaction back and report it. Grants nothing, settles nothing, never writes a credit_grants row.",
2683
+ request: z.object({
2684
+ chain: z.literal("sui"),
2685
+ network: z.literal("testnet"),
2686
+ digest: z.string().min(1).describe("The transaction digest the sender broadcast"),
2687
+ address: z.string().optional().describe("The sender address — PUBLIC only, never a key"),
2688
+ }),
2689
+ response: z.object({
2690
+ verified: z.boolean(),
2691
+ network: z.literal("testnet"),
2692
+ credits: z.literal(0).describe("Always 0. Testnet money buys nothing."),
2693
+ amount: z.string().nullable().optional(),
2694
+ sender: z.string().nullable().optional(),
2695
+ explorerUrl: z.string().nullable().optional(),
2696
+ error: z.string().optional(),
2697
+ }),
2698
+ effect: "ask", cost: "free", reversible: true, idempotent: true, settles: "none", auth: "public",
2699
+ examples: [{ chain: "sui", network: "testnet", digest: "8xKvQ1", address: "0xabc" }],
2700
+ }),
2701
+ // ── The wallet estate, declared ────────────────────────────────────────────
2702
+ //
2703
+ // wallet:get / :send / :transactions / :invoice have run in production since
2704
+ // wallets-workspace shipped (handlers: resolvers/wallet.ts:643) with NO entry
2705
+ // here at all. Undeclared, they were invisible to `meta:catalog`, absent from
2706
+ // MCP, and passed through the /api/ask edge with no schema gate whatsoever.
2707
+ //
2708
+ // DECLARING A LIVE RECEIVER IS NOT FREE. The ask route dispatches
2709
+ // `validation.payload` — zod's PARSED output (pages/api/ask/[...receiver].ts:358)
2710
+ // — so a field a real caller sends and a schema here omits is SILENTLY
2711
+ // STRIPPED before the handler reads it. Every field below was read back off
2712
+ // the resolver and off its callers (packages/cli/src/wallet.ts,
2713
+ // template/site/src/pages/wallet.astro). Add a field to a handler ⇒ add it
2714
+ // here in the same diff, or the handler stops seeing it.
2715
+ //
2716
+ // `surfaces: { mcp: true }` is what actually creates the MCP tool.
2717
+ // `mcpToolsFromRegistry()` is default-closed (`if (!mcp) continue`,
2718
+ // packages/mcp/src/tools/from-registry.ts:79), so the declaration alone would
2719
+ // have left MCP with zero wallet tools — the exact gap this cycle closes.
2720
+ "wallet:get": receiver({
2721
+ receiver: "wallet:get",
2722
+ surfaces: { mcp: true },
2723
+ summary: "Read one workspace's wallet estate — credits, spend ceiling, wallet rows with payTo, live chain balances, delegated wallets",
2724
+ request: z.object({
2725
+ workspace: z.string().optional().describe("Defaults to the attested caller's workspace"),
2726
+ actor: z.string().optional().describe("Actor whose spend ceiling is read; defaults to the workspace"),
2727
+ }),
2728
+ response: z.object({
2729
+ workspace: z.string().optional(),
2730
+ actor: z.string().optional(),
2731
+ credits: z.number().optional(),
2732
+ wallets: z.array(z.record(z.string(), z.unknown())).optional(),
2733
+ spendCeiling: z.object({
2734
+ limitCredits: z.number(), spentCredits: z.number(), remainingCredits: z.number(),
2735
+ }).nullable().optional(),
2736
+ balances: z.array(z.object({
2737
+ actor: z.string(), chain: z.string(), address: z.string(), balance: z.string().nullable(),
2738
+ })).optional(),
2739
+ addresses: z.array(z.object({ actor: z.string(), chain: z.string(), address: z.string() })).optional(),
2740
+ delegated: z.array(z.record(z.string(), z.unknown())).optional(),
2741
+ error: z.string().optional(),
2742
+ }),
2743
+ effect: "ask", cost: "free", reversible: true, idempotent: true, settles: "none", auth: "member",
2744
+ examples: [{}, { workspace: "acme" }, { workspace: "acme", actor: "agent:scout" }],
2745
+ }),
2746
+ "wallet:transactions": receiver({
2747
+ receiver: "wallet:transactions",
2748
+ surfaces: { mcp: true },
2749
+ summary: "The unified settlement feed for one workspace — every rail, money in and out, newest first",
2750
+ request: z.object({
2751
+ workspace: z.string().optional().describe("Defaults to the attested caller's workspace"),
2752
+ limit: z.number().optional().describe("Rows to return; the handler caps at 500"),
2753
+ }),
2754
+ response: z.object({
2755
+ transactions: z.array(z.object({
2756
+ ts: z.number(), rail: z.string(), direction: z.enum(["in", "out"]), counterparty: z.string(),
2757
+ amountCents: z.number(), currency: z.string(), ref: z.string(), status: z.string(),
2758
+ })).optional(),
2759
+ error: z.string().optional(),
2760
+ }),
2761
+ effect: "ask", cost: "free", reversible: true, idempotent: true, settles: "none", auth: "member",
2762
+ examples: [{ limit: 20 }, { workspace: "acme", limit: 100 }],
2763
+ }),
2764
+ // The caller signs and broadcasts the transfer CLIENT-SIDE and passes the
2765
+ // resulting `paymentTx`; this composes pay.one.ie's create → quote → claim and
2766
+ // never holds a key. It VERIFIES that an on-chain transfer settled, so it
2767
+ // declares `settles: "onchain"` and `reversible: false`. A catalog that said
2768
+ // otherwise would put a lie where the omission used to be.
2769
+ "wallet:send": receiver({
2770
+ receiver: "wallet:send",
2771
+ surfaces: { mcp: true },
2772
+ summary: "Settle a crypto transfer the caller already signed and broadcast — mints, prices and claims a pay.one.ie link. Never signs; no private key crosses this boundary",
2773
+ request: z.object({
2774
+ to: z.string().describe("Recipient address — public only"),
2775
+ chain: z.string().describe("SOL | ETH | BASE | ARB | OPT | BTC | SUI"),
2776
+ amount: z.number().describe("Must be > 0"),
2777
+ paymentTx: z.string().describe("The tx hash from signing and broadcasting client-side"),
2778
+ workspace: z.string().optional().describe("Source workspace; falls back to `from`, then the attested caller"),
2779
+ from: z.string().optional().describe("Alias for `workspace` — the source the authority walk runs against"),
2780
+ unit: z.enum(["usd", "token"]).optional(),
2781
+ currency: z.enum(["NATIVE", "USDC"]).optional(),
2782
+ userAddress: z.string().optional().describe("Sender address; defaults to `to`"),
2783
+ memo: z.string().optional(),
2784
+ product: z.string().optional().describe("Falls back to `memo`, then a generated transfer label"),
2785
+ }),
2786
+ response: z.object({
2787
+ ok: z.boolean(),
2788
+ from: z.string().optional(),
2789
+ to: z.string().optional(),
2790
+ chain: z.string().optional(),
2791
+ amount: z.number().optional(),
2792
+ link: z.string().optional(),
2793
+ quoteId: z.string().optional(),
2794
+ paymentTx: z.string().optional(),
2795
+ treasury: z.string().optional(),
2796
+ receipt: z.object({ id: z.string(), url: z.string() }).optional(),
2797
+ claimed: z.boolean().optional(),
2798
+ stage: z.string().optional().describe("Which pay call failed: create | quote | claim"),
2799
+ error: z.string().optional(),
2800
+ }),
2801
+ effect: "ask", cost: "variable", reversible: false, idempotent: false, settles: "onchain", auth: "manage_workspace",
2802
+ examples: [{ to: "0xabc", chain: "SUI", amount: 5, paymentTx: "8xKvQ1" }],
2803
+ }),
2804
+ // A pending, attributed `settlements` row is written before a cent moves —
2805
+ // INSERT OR IGNORE keyed on the pay link id, so a retry with the same link is a
2806
+ // no-op. An unpayable actor is a clean refusal: no link minted, no row written.
2807
+ "wallet:invoice": receiver({
2808
+ receiver: "wallet:invoice",
2809
+ surfaces: { mcp: true },
2810
+ summary: "Hand a payable actor a working payment link, with the money attributed to it before a cent moves",
2811
+ request: z.object({
2812
+ actor: z.string().describe("The actor being paid — attributed here even when custody is via-treasury"),
2813
+ amountCents: z.number().describe("Must be > 0"),
2814
+ workspace: z.string().optional().describe("Defaults to the attested caller's workspace"),
2815
+ currency: z.string().optional().describe("Defaults to usd"),
2816
+ }),
2817
+ response: z.object({
2818
+ url: z.string().optional(),
2819
+ linkId: z.string().optional(),
2820
+ payTo: z.object({
2821
+ // An OPEN string, never an enum. 'self' | 'via-treasury' | 'none' today;
2822
+ // wallet-authority's ScopedWallet adds 'scoped' (payee = an object id,
2823
+ // no chain), and that must not need a registry change to be
2824
+ // representable. An UNKNOWN value is refused BY NAME at payee resolution
2825
+ // (`unsupported_custody:<value>`, resolvers/wallet.ts) — never a crash,
2826
+ // never a silent fallback to 'self' or to the SUI rail.
2827
+ custody: z.string(),
2828
+ chain: z.string().optional(),
2829
+ address: z.string().optional(),
2830
+ attributeTo: z.string().optional(),
2831
+ }).optional(),
2832
+ error: z.string().optional(),
2833
+ }),
2834
+ effect: "ask", cost: "free", reversible: true, idempotent: true, settles: "none", auth: "manage_workspace",
2835
+ examples: [
2836
+ { actor: "agent:scout", amountCents: 2500 },
2837
+ { workspace: "acme", actor: "agent:scout", amountCents: 2500, currency: "usd" },
2838
+ ],
2839
+ }),
2840
+ // wallet:portfolio — the authority walk projected onto wallet rows. One call
2841
+ // answers "what does my whole estate hold", which no client could ask before:
2842
+ // every wallet read was scoped to one workspace at a time.
2843
+ //
2844
+ // Deliberately NO chain balances. `wallet:get` fans out one RPC per address,
2845
+ // which is right for one workspace and quadratic across an estate. Portfolio
2846
+ // NAMES the estate; `wallet:get` prices one workspace in it.
2847
+ "wallet:portfolio": receiver({
2848
+ receiver: "wallet:portfolio",
2849
+ surfaces: { mcp: true },
2850
+ summary: "Every wallet row in every workspace the caller controls — one call, no per-address chain reads",
2851
+ request: z.object({
2852
+ limit: z.number().optional().describe("Max child workspaces to include; capped at 100"),
2853
+ }),
2854
+ response: z.object({
2855
+ caller: z.string().optional(),
2856
+ workspaces: z.array(z.object({
2857
+ workspace: z.string(),
2858
+ wallets: z.array(z.record(z.string(), z.unknown())),
2859
+ })).optional(),
2860
+ count: z.number().optional(),
2861
+ error: z.string().optional(),
2862
+ }),
2863
+ effect: "ask", cost: "free", reversible: true, idempotent: true, settles: "none", auth: "member",
2864
+ examples: [{}, { limit: 25 }],
2865
+ }),
2866
+ // The door that lets a workspace open its own shop. Until this existed, the
2867
+ // only writers of `owners.storefront_enabled` lived under the Stripe Connect
2868
+ // OAuth callbacks, so selling anything required a human with a browser and a
2869
+ // business — including on the crypto rail, which needs no Stripe account at
2870
+ // all (deriveRails gives a treasury-only seller the crypto rail already).
2871
+ // Authorized by the owners-tree walk, so a parent can open a client's shop.
2872
+ "storefront:enable": receiver({
2873
+ receiver: "storefront:enable",
2874
+ summary: "Open (or close) a workspace's storefront — the gate every checkout mint reads",
2875
+ request: z.object({ slug: z.string().optional(), enabled: z.boolean().optional() }),
2876
+ response: z.object({ ok: z.boolean().optional(), slug: z.string().optional(), enabled: z.boolean().optional(), error: z.string().optional() }),
2877
+ effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
2878
+ examples: [{ slug: "acme" }, { slug: "acme", enabled: false }],
2879
+ }),
2006
2880
  "storefront:stats": receiver({
2007
2881
  receiver: "storefront:stats",
2008
2882
  summary: "Storefront funnel stats — views, checkouts, purchases, revenue, per-product breakdown",
@@ -2020,6 +2894,7 @@ export const RECEIVERS = {
2020
2894
  // ── view:* — saved views (custom-views C1). A view is a workspace-scoped Thing (world_things type 'view'). ──
2021
2895
  "view:create": receiver({
2022
2896
  receiver: "view:create",
2897
+ surfaces: { mcp: { name: "create_view" } },
2023
2898
  summary: "Save a named view — dimension × tags × kind × config — as a workspace Thing",
2024
2899
  request: z.object({ slug: z.string(), name: z.string(), kind: z.string().optional(), query: z.object({ dimension: z.string().optional(), type: z.string().optional(), tags: z.array(z.string()).optional(), stage: z.string().optional() }), config: z.record(z.string(), z.unknown()).optional(), pinned: z.boolean().optional() }),
2025
2900
  response: z.object({ ok: z.boolean(), id: z.string().optional(), name: z.string().optional(), error: z.string().optional() }),
@@ -2027,6 +2902,7 @@ export const RECEIVERS = {
2027
2902
  }),
2028
2903
  "view:list": receiver({
2029
2904
  receiver: "view:list",
2905
+ surfaces: { mcp: { name: "list_views" } },
2030
2906
  summary: "List a workspace's saved views (cold flag derived at read time)",
2031
2907
  request: z.object({ slug: z.string() }),
2032
2908
  response: z.object({ views: z.array(z.object({ id: z.string(), name: z.string(), kind: z.string().optional(), pinned: z.boolean(), cold: z.boolean() })) }),
@@ -2056,6 +2932,7 @@ export const RECEIVERS = {
2056
2932
  // ── pages + workspace settings — handlers in resolvers/pages.ts ────────────────
2057
2933
  "pages:create": receiver({
2058
2934
  receiver: "pages:create",
2935
+ surfaces: { mcp: true },
2059
2936
  summary: "Create a workspace page (draft) from a title + sections",
2060
2937
  request: z.object({ slug: z.string(), title: z.string(), sections: z.array(z.object({ component: z.string(), props: z.record(z.string(), z.unknown()) })), pageSlug: z.string().optional() }),
2061
2938
  response: z.object({ ok: z.boolean(), slug: z.string().optional(), title: z.string().optional(), url: z.string().optional(), status: z.string().optional(), error: z.string().optional() }),
@@ -2070,6 +2947,7 @@ export const RECEIVERS = {
2070
2947
  }),
2071
2948
  "pages:list": receiver({
2072
2949
  receiver: "pages:list",
2950
+ surfaces: { mcp: true },
2073
2951
  summary: "List a workspace's pages with status",
2074
2952
  request: z.object({ slug: z.string() }),
2075
2953
  response: z.object({ pages: z.array(z.object({ slug: z.string(), title: z.string(), status: z.string() })) }),
@@ -2180,10 +3058,24 @@ export const RECEIVERS = {
2180
3058
  response: z.object({ ok: z.boolean(), slug: z.string().optional(), message: z.string().optional(), error: z.string().optional() }),
2181
3059
  effect: "ask", cost: "free", reversible: true, idempotent: false, auth: "member",
2182
3060
  }),
3061
+ "settings:set-home-page": receiver({
3062
+ receiver: "settings:set-home-page",
3063
+ summary: "Name which published page a workspace serves at its root",
3064
+ request: z.object({ slug: z.string(), page: z.string() }),
3065
+ response: z.object({ ok: z.boolean(), page: z.string().optional(), error: z.string().optional() }),
3066
+ effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
3067
+ }),
2183
3068
  "domain:set": receiver({
2184
3069
  receiver: "domain:set",
2185
3070
  summary: "Bind a custom domain to a workspace — returns the verification TXT/CNAME records to add",
2186
- request: z.object({ slug: z.string(), actorId: z.string(), host: z.string() }),
3071
+ request: z.object({
3072
+ slug: z.string(),
3073
+ // DERIVED, never sent: "the body used to carry `actorId`" — it was removed
3074
+ // by the IDOR fix in resolvers/pages.ts, which authorizes from the ATTESTED
3075
+ // caller only. Requiring it here demanded a field the fix stopped trusting.
3076
+ actorId: z.string().optional(),
3077
+ host: z.string(),
3078
+ }),
2187
3079
  response: z.object({ ok: z.boolean(), intent: z.string().optional(), host: z.string().optional(), verifyToken: z.string().optional(), txtRecord: z.string().optional(), cnameTarget: z.string().optional(), reason: z.string().optional() }),
2188
3080
  effect: "ask", cost: "free", reversible: true, idempotent: false, auth: "verify_domain",
2189
3081
  }),
@@ -2244,6 +3136,159 @@ export const RECEIVERS = {
2244
3136
  response: z.object({ ok: z.boolean(), skillRef: z.string().optional() }),
2245
3137
  effect: "ask", cost: "free", reversible: true, idempotent: false, auth: "member",
2246
3138
  }),
3139
+ // ── company — the COMPANY WORKFLOW's crawl steps, handlers in resolvers/company.ts ──
3140
+ //
3141
+ // The crawl ladder underneath these is plain fetch -> Cloudflare Browser Rendering
3142
+ // (only when the page is a JS shell). `via` names the rung that answered and
3143
+ // `degraded` names why a lower one did, so a caller is never told a partial read
3144
+ // was a full one. None of them writes; foundation:save is the single write.
3145
+ "company:render": receiver({
3146
+ receiver: "company:render",
3147
+ summary: "Read ONE page of a company's site and extract what it says about the company — plain fetch first, Cloudflare Browser Rendering only for a JS shell. Returns a Foundation patch plus provenance-fenced page text; never fabricates a field",
3148
+ request: z.object({ url: z.string(), waitFor: z.number().int().optional() }),
3149
+ response: z.object({
3150
+ ok: z.boolean(),
3151
+ url: z.string().optional(),
3152
+ finalUrl: z.string().optional(),
3153
+ domain: z.string().optional(),
3154
+ via: z.enum(["fetch", "cloudflare"]).optional(),
3155
+ degraded: z.string().optional(),
3156
+ patch: z.unknown().optional(),
3157
+ evidence: z.string().optional(),
3158
+ error: z.string().optional(),
3159
+ }),
3160
+ effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "public",
3161
+ }),
3162
+ "company:map": receiver({
3163
+ receiver: "company:map",
3164
+ summary: "Discover a site's structure from its homepage anchors and classify them (pricing/about/product/blog/contact/legal). Depth 1 — this is NOT a crawl and starts no crawl job",
3165
+ request: z.object({ url: z.string(), limit: z.number().int().min(1).max(50).optional() }),
3166
+ response: z.object({
3167
+ ok: z.boolean(),
3168
+ url: z.string().optional(),
3169
+ via: z.enum(["fetch", "cloudflare"]).optional(),
3170
+ degraded: z.string().optional(),
3171
+ links: z.unknown().optional(),
3172
+ priority: z.array(z.string()).optional(),
3173
+ count: z.number().optional(),
3174
+ error: z.string().optional(),
3175
+ }),
3176
+ effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "public",
3177
+ }),
3178
+ "company:seo": receiver({
3179
+ receiver: "company:seo",
3180
+ summary: "Read the SEO surface for a domain (keywords, backlinks, AI visibility) via DataForSEO and return it as a Foundation patch. SPENDS REAL CREDIT, so it skips itself with {ok:true,patch:{},skipped:'no_workspace'} when there is no attested workspace",
3181
+ request: z.object({ domain: z.string(), slug: z.string().optional() }),
3182
+ response: z.object({
3183
+ ok: z.boolean(),
3184
+ patch: z.unknown().optional(),
3185
+ domain: z.string().optional(),
3186
+ skipped: z.string().optional(),
3187
+ error: z.string().optional(),
3188
+ }),
3189
+ effect: "ask", cost: "variable", reversible: true, idempotent: false, auth: "member",
3190
+ }),
3191
+ // ── foundation — handlers in resolvers/foundation.ts ──────────────────────────
3192
+ "foundation:deep": receiver({
3193
+ receiver: "foundation:deep",
3194
+ summary: "Run the 10-skill FOUNDATION sequence — each elevate-foundation-* SKILL.md body is loaded from the workspace's skill catalog and executed, and every field it produces is merged at inferred/scrape provenance so it can never outrank a human-confirmed one",
3195
+ request: z.object({
3196
+ slug: z.string().optional(),
3197
+ source: z.enum(["inferred", "scrape"]).optional(),
3198
+ }),
3199
+ response: z.object({
3200
+ ok: z.boolean(),
3201
+ slug: z.string().optional(),
3202
+ error: z.string().optional(),
3203
+ skillsRun: z.number().optional(),
3204
+ // EMITTED, not written: a field already at chat/document rank is emitted and
3205
+ // then refused by the merge guard. Naming it "written" would overstate it.
3206
+ fieldsEmitted: z.number().optional(),
3207
+ // Per-skill receipt: what each skill actually did. `outcome` distinguishes
3208
+ // "emitted N fields" from "returned nothing" from "the answer did not parse".
3209
+ skills: z.array(z.object({
3210
+ skill: z.string(),
3211
+ outcome: z.enum(["ok", "empty", "parse_failed", "llm_error"]),
3212
+ bodyFound: z.boolean(),
3213
+ fieldsEmitted: z.number(),
3214
+ fields: z.array(z.string()).optional(),
3215
+ dropped: z.array(z.string()).optional(),
3216
+ status: z.number().optional(),
3217
+ })).optional(),
3218
+ }),
3219
+ effect: "ask", cost: "variable", reversible: false, idempotent: false, auth: "member",
3220
+ }),
3221
+ "foundation:start": receiver({
3222
+ receiver: "foundation:start",
3223
+ summary: "Fill the Foundation's spine fields with one bounded LLM call over the workspace's own context. Refuses to overwrite a human-confirmed field; writes at chat/0.75",
3224
+ request: z.object({ slug: z.string().optional() }),
3225
+ response: z.object({ ok: z.boolean(), slug: z.string().optional(), fields: z.number().optional(), error: z.string().optional() }),
3226
+ effect: "ask", cost: "variable", reversible: false, idempotent: false, auth: "member",
3227
+ }),
3228
+ "foundation:read": receiver({
3229
+ receiver: "foundation:read",
3230
+ summary: "Read the workspace's Foundation as stored (null when nothing has been written yet)",
3231
+ request: z.object({ slug: z.string().optional() }),
3232
+ response: z.object({ ok: z.boolean(), slug: z.string().optional(), foundation: z.unknown().optional(), error: z.string().optional() }),
3233
+ effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
3234
+ }),
3235
+ "foundation:derive": receiver({
3236
+ receiver: "foundation:derive",
3237
+ summary: "Turn the stored Foundation into DRAFT products and DRAFT ELEVATE playbook chapters. Insert-only (never overwrites a human edit), no price is ever guessed, and confidence is inherited from the weakest source fact",
3238
+ request: z.object({}),
3239
+ response: z.object({
3240
+ ok: z.boolean(),
3241
+ slug: z.string().optional(),
3242
+ products: z.unknown().optional(),
3243
+ playbook: z.unknown().optional(),
3244
+ error: z.string().optional(),
3245
+ hint: z.string().optional(),
3246
+ }),
3247
+ effect: "ask", cost: "free", reversible: false, idempotent: false, auth: "member",
3248
+ }),
3249
+ "foundation:extract": receiver({
3250
+ receiver: "foundation:extract",
3251
+ summary: "Fold every rendered page into ONE provenance-tracked Foundation patch, and report the spine paths still unanswered. Pure — no network, no LLM, no cost, no write. Its fencedEvidence is already wrapped by the provenance fence, so a downstream prompt cannot interpolate crawled text unfenced",
3252
+ request: z.object({ home: z.unknown().optional(), pages: z.unknown().optional(), siteMap: z.unknown().optional() }),
3253
+ response: z.object({
3254
+ ok: z.boolean(),
3255
+ patch: z.unknown().optional(),
3256
+ found: z.unknown().optional(),
3257
+ gaps: z.array(z.string()).optional(),
3258
+ pagesRead: z.number().optional(),
3259
+ pagesFailed: z.number().optional(),
3260
+ degraded: z.string().optional(),
3261
+ fencedEvidence: z.string().optional(),
3262
+ }),
3263
+ effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "public",
3264
+ }),
3265
+ "foundation:save": receiver({
3266
+ receiver: "foundation:save",
3267
+ summary: "The single write of the company workflow. With an attested workspace it merges into workspace_settings.foundation provenance-aware; without one it holds the profile as an opaque 1h KV draft to be promoted at signup — an anonymous caller never writes a workspace",
3268
+ request: z.object({
3269
+ patch: z.unknown().optional(),
3270
+ fill: z.unknown().optional(),
3271
+ seo: z.unknown().optional(),
3272
+ draftToken: z.string().optional(),
3273
+ }),
3274
+ response: z.object({
3275
+ ok: z.boolean(),
3276
+ stored: z.enum(["workspace", "draft", "none"]).optional(),
3277
+ slug: z.string().optional(),
3278
+ draftToken: z.string().optional(),
3279
+ fields: z.number().optional(),
3280
+ gaps: z.array(z.string()).optional(),
3281
+ error: z.string().optional(),
3282
+ }),
3283
+ effect: "ask", cost: "free", reversible: false, idempotent: false, auth: "public",
3284
+ }),
3285
+ "foundation:promote-draft": receiver({
3286
+ receiver: "foundation:promote-draft",
3287
+ summary: "Carry an anonymous onboarding draft into the caller's workspace at signup and delete the KV key. Provenance survives, so a scraped field still loses to the human's later edit",
3288
+ request: z.object({ draftToken: z.string() }),
3289
+ response: z.object({ ok: z.boolean(), slug: z.string().optional(), fields: z.number().optional(), gaps: z.array(z.string()).optional(), error: z.string().optional() }),
3290
+ effect: "ask", cost: "free", reversible: false, idempotent: true, auth: "member",
3291
+ }),
2247
3292
  // ── social + crawl — handlers in resolvers/social.ts ───────────────────────────
2248
3293
  "social:publish": receiver({
2249
3294
  receiver: "social:publish",
@@ -2305,7 +3350,7 @@ export const RECEIVERS = {
2305
3350
  }),
2306
3351
  "billing:topup": receiver({
2307
3352
  receiver: "billing:topup",
2308
- summary: "Top up workspace AI credits — returns a checkout URL for the amount",
3353
+ summary: "Top up workspace AI credits — returns a checkout URL for the amount. Credits are prepaid, NON-WITHDRAWABLE service units: they buy work on ONE (AI replies, tool and skill calls, agent runs, storage, API requests) and there is no payout, withdraw or cash-out rail against a credits balance",
2309
3354
  request: z.object({ slug: z.string(), actorId: z.string(), amount: z.number(), currency: z.string().optional() }),
2310
3355
  response: z.object({ ok: z.boolean(), intent: z.string().optional(), checkoutUrl: z.string().optional(), amount: z.number().optional(), currency: z.string().optional(), reason: z.string().optional() }),
2311
3356
  effect: "ask", cost: "free", reversible: false, idempotent: false, auth: "update_group",
@@ -2456,7 +3501,10 @@ export const RECEIVERS = {
2456
3501
  receiver: "booking:list-bookings",
2457
3502
  summary: "Provider-gated: list a workspace's bookings for a month (customer, time, service, status)",
2458
3503
  request: z.object({
2459
- slug: z.string(), // workspace (informational; gate re-scopes to caller)
3504
+ // Optional: the comment below is the point the handler does
3505
+ // `const slug = ctx.ownerSlug` and never reads data.slug. Required, the
3506
+ // binder rejected every call that correctly omitted it.
3507
+ slug: z.string().optional(), // workspace (informational; gate re-scopes to caller)
2460
3508
  month: z.string().optional(), // 'YYYY-MM'; omitted → current month
2461
3509
  }),
2462
3510
  response: z.object({
@@ -2521,6 +3569,7 @@ export const RECEIVERS = {
2521
3569
  // and commits nothing — the chat-authoring preview path.
2522
3570
  "workflow:list": receiver({
2523
3571
  receiver: "workflow:list",
3572
+ surfaces: { mcp: true },
2524
3573
  summary: "List a workspace's workflows (D1 mirror); pass templates=true for the public SOP catalog",
2525
3574
  request: z.object({
2526
3575
  slug: z.string().optional(),
@@ -2538,6 +3587,7 @@ export const RECEIVERS = {
2538
3587
  }),
2539
3588
  "workflow:get": receiver({
2540
3589
  receiver: "workflow:get",
3590
+ surfaces: { mcp: true },
2541
3591
  summary: "Fetch one workflow's full graph — steps (kind, config, position) and edges (with conditions)",
2542
3592
  request: z.object({ workflowId: z.string() }),
2543
3593
  response: z.object({
@@ -2558,6 +3608,7 @@ export const RECEIVERS = {
2558
3608
  }),
2559
3609
  "workflow:runs": receiver({
2560
3610
  receiver: "workflow:runs",
3611
+ surfaces: { mcp: true },
2561
3612
  summary: "List recent runs of a workflow (D1 workflow_run) for the monitor + history surfaces",
2562
3613
  request: z.object({ workflowId: z.string(), runId: z.string().optional(), limit: z.number().int().min(1).max(200).optional() }),
2563
3614
  response: z.object({
@@ -2574,6 +3625,25 @@ export const RECEIVERS = {
2574
3625
  effect: "ask", cost: "free", idempotent: true, simulatable: false, auth: "view_workflows",
2575
3626
  examples: [{ workflowId: "wf_abc", limit: 20 }, { workflowId: "wf_abc", runId: "run_1" }],
2576
3627
  }),
3628
+ "workflow:step-stats": receiver({
3629
+ receiver: "workflow:step-stats",
3630
+ surfaces: { mcp: true },
3631
+ summary: "Per-step run counts and latency for one workflow (aggregated over D1 workflow_run_event) — what the canvas prints on each step card",
3632
+ request: z.object({ workflowId: z.string(), sinceMs: z.number().int().optional() }),
3633
+ response: z.object({
3634
+ stats: z.array(z.object({
3635
+ stepId: z.string(),
3636
+ runs: z.number().int(),
3637
+ failures: z.number().int(),
3638
+ avgMs: z.number().nullable(),
3639
+ maxMs: z.number().nullable(),
3640
+ lastAt: z.number().nullable(),
3641
+ })),
3642
+ error: z.string().optional(),
3643
+ }),
3644
+ effect: "ask", cost: "free", idempotent: true, simulatable: false, auth: "view_workflows",
3645
+ examples: [{ workflowId: "wf_abc" }],
3646
+ }),
2577
3647
  "workflow:preview-step": receiver({
2578
3648
  receiver: "workflow:preview-step",
2579
3649
  summary: "Resolve one step's receiver + args through the real substitution code, with an optional mock trigger/step payload standing in for run history — never dispatches, reads no run history",
@@ -2593,6 +3663,7 @@ export const RECEIVERS = {
2593
3663
  }),
2594
3664
  "workflow:create": receiver({
2595
3665
  receiver: "workflow:create",
3666
+ surfaces: { mcp: true },
2596
3667
  summary: "Create a blank workflow (group group-type=workflow) — optionally cloned from a template",
2597
3668
  request: z.object({
2598
3669
  slug: z.string().optional(),
@@ -2625,6 +3696,7 @@ export const RECEIVERS = {
2625
3696
  }),
2626
3697
  "workflow:apply-diff": receiver({
2627
3698
  receiver: "workflow:apply-diff",
3699
+ surfaces: { mcp: true },
2628
3700
  summary: "Apply a WorkflowDiff (add/remove/connect/disconnect/update). simulate=true validates the DAG without persisting",
2629
3701
  request: z.object({
2630
3702
  workflowId: z.string(),
@@ -2645,6 +3717,7 @@ export const RECEIVERS = {
2645
3717
  }),
2646
3718
  "workflow:validate": receiver({
2647
3719
  receiver: "workflow:validate",
3720
+ surfaces: { mcp: true },
2648
3721
  summary: "Run the §9 gate on a WorkflowDiff without persisting — kinds/config shapes, exactly-one-trigger, edges reference real steps, chain cycles; advisories ride back as warnings (grammar: code | code:stepId, never block). Every save door calls this.",
2649
3722
  request: z.object({
2650
3723
  workflowId: z.string(),
@@ -2662,6 +3735,7 @@ export const RECEIVERS = {
2662
3735
  }),
2663
3736
  "workflow:run": receiver({
2664
3737
  receiver: "workflow:run",
3738
+ surfaces: { mcp: true },
2665
3739
  summary: "Start a run — spawns the WorkflowRun DO, executes each step, marks path strength on traversed edges",
2666
3740
  request: z.object({
2667
3741
  workflowId: z.string(),
@@ -2778,6 +3852,7 @@ export const RECEIVERS = {
2778
3852
  }),
2779
3853
  "workflow:stop": receiver({
2780
3854
  receiver: "workflow:stop",
3855
+ surfaces: { mcp: true },
2781
3856
  summary: "Park a live run at its current step (status → paused); preserves the resume cursor so human:resolve or a re-run continues it",
2782
3857
  request: z.object({ runId: z.string() }),
2783
3858
  response: z.object({ ok: z.boolean(), status: z.string().optional(), error: z.string().optional() }),
@@ -2786,6 +3861,7 @@ export const RECEIVERS = {
2786
3861
  }),
2787
3862
  "workflow:trigger": receiver({
2788
3863
  receiver: "workflow:trigger",
3864
+ surfaces: { mcp: true },
2789
3865
  summary: "Fire every workspace workflow whose trigger step matches a channel source (e.g. webhook:telegram)",
2790
3866
  request: z.object({
2791
3867
  source: z.string(),
@@ -2806,6 +3882,7 @@ export const RECEIVERS = {
2806
3882
  }),
2807
3883
  "workflow:update": receiver({
2808
3884
  receiver: "workflow:update",
3885
+ surfaces: { mcp: true },
2809
3886
  summary: "Rename a workflow or change its status (draft|active|paused)",
2810
3887
  request: z.object({ workflowId: z.string(), name: z.string().optional(), status: z.enum(["draft", "active", "paused"]).optional() }),
2811
3888
  response: z.object({ ok: z.boolean(), error: z.string().optional() }),
@@ -2820,11 +3897,44 @@ export const RECEIVERS = {
2820
3897
  effect: "ask", cost: "free", reversible: false, idempotent: false, auth: "manage_workflows",
2821
3898
  examples: [{ workflowId: "wf_abc" }, { rate: 0.1 }, {}],
2822
3899
  }),
3900
+ // ── The .tql text form of a workflow (workflows-tql). Export/import as a
3901
+ // single-insert TypeQL document — the graph a canvas draws, as text an
3902
+ // agent can read, diff and hand back. apply-tql never writes D1 itself: it
3903
+ // parses, gates the whole document as an all-add diff, reconciles against
3904
+ // what is persisted, and goes through workflow:apply-diff — the one door.
3905
+ "workflow:tql": receiver({
3906
+ receiver: "workflow:tql",
3907
+ surfaces: { mcp: true },
3908
+ summary: "Emit one workflow as its .tql text form — a single-insert TypeQL document (group header + step things + path edges) that round-trips back through workflow:apply-tql",
3909
+ request: z.object({ workflowId: z.string() }),
3910
+ response: z.object({ ok: z.boolean(), tql: z.string().optional(), version: z.number().optional(), error: z.string().optional() }),
3911
+ effect: "ask", cost: "free", idempotent: true, simulatable: false, auth: "view_workflows",
3912
+ examples: [{ workflowId: "wf_abc" }],
3913
+ }),
3914
+ "workflow:apply-tql": receiver({
3915
+ receiver: "workflow:apply-tql",
3916
+ surfaces: { mcp: true },
3917
+ summary: "Parse a .tql workflow document and apply it to an existing workflow THROUGH workflow:apply-diff — the sole validated persist door, so the import inherits the DAG check, the one-trigger law, the config gate and the authority walk. simulate:true validates and persists nothing",
3918
+ request: z.object({ workflowId: z.string(), tql: z.string(), simulate: z.boolean().optional(), version: z.number().optional() }),
3919
+ response: z.object({
3920
+ ok: z.boolean(),
3921
+ stepCount: z.number().optional(),
3922
+ idMap: z.record(z.string(), z.string()).optional(),
3923
+ warnings: z.array(z.string()).optional(),
3924
+ notes: z.array(z.string()).optional(),
3925
+ error: z.string().optional(),
3926
+ line: z.number().optional(),
3927
+ detail: z.string().optional(),
3928
+ }),
3929
+ effect: "ask", cost: "free", reversible: false, idempotent: false, simulatable: true, auth: "manage_workflows",
3930
+ examples: [{ workflowId: "wf_abc", tql: "insert\n$w isa group, has gid \"group:wf-wf_abc\", has name \"Demo\", has group-type \"team\";\n", simulate: true }],
3931
+ }),
2823
3932
  // ── The autonomy ladder's leaf runners — a workflow skill/agent step compiles
2824
3933
  // to one of these (workflow-executor.ts stepBinding). Both proxy to the
2825
3934
  // channels runtime; web owns the catalog + tenant authority (workflows-lock C2/C3).
2826
3935
  "skills:list": receiver({
2827
3936
  receiver: "skills:list",
3937
+ surfaces: { mcp: true },
2828
3938
  summary: "List the caller's workspace skill catalog (R2) — powers the canvas SkillPicker",
2829
3939
  request: z.object({ name: z.string().optional() }),
2830
3940
  response: z.object({
@@ -2843,6 +3953,7 @@ export const RECEIVERS = {
2843
3953
  }),
2844
3954
  "skill:run": receiver({
2845
3955
  receiver: "skill:run",
3956
+ surfaces: { mcp: true },
2846
3957
  summary: "Run a workspace skill — loads its body from R2, runs one bounded turn via channels, returns { text }",
2847
3958
  request: z.object({ skill: z.string() }).catchall(z.unknown()), // extra keys = the skill's input
2848
3959
  response: z.object({ ok: z.boolean(), text: z.string().optional(), skill: z.string().optional(), error: z.string().optional() }),
@@ -2851,9 +3962,12 @@ export const RECEIVERS = {
2851
3962
  }),
2852
3963
  "agent:run": receiver({
2853
3964
  receiver: "agent:run",
3965
+ surfaces: { mcp: true },
2854
3966
  summary: "Invoke a bound actor (optionally skill-constrained) for one bounded turn via channels; returns { text }",
2855
3967
  request: z.object({
2856
- actorId: z.string(),
3968
+ // Optional by design: an omitted actorId resolves to the workspace CEO
3969
+ // (C9). Required here, the binder rejected the very call that default exists to serve.
3970
+ actorId: z.string().optional(),
2857
3971
  skill: z.string().optional(),
2858
3972
  instructions: z.string().optional(),
2859
3973
  }).catchall(z.unknown()), // extra keys = the agent's input
@@ -2889,10 +4003,11 @@ export const RECEIVERS = {
2889
4003
  examples: [{ workspace: "acme", roomSlug: "algebra-101", name: "Algebra 101", type: "classroom" }],
2890
4004
  // Fan-out pilot (actions-fan-out C2): offered to chat via chatToolsFor — no
2891
4005
  // curated workspace tool needed; the registry derivation is the whole tool.
2892
- surfaces: { chat: true },
4006
+ surfaces: { chat: true, mcp: { name: "create_room" } },
2893
4007
  }),
2894
4008
  "video:delete-room": receiver({
2895
4009
  receiver: "video:delete-room",
4010
+ surfaces: { mcp: { name: "delete_room" } },
2896
4011
  summary: "Disable a video room and its 100ms counterpart; room is no longer joinable",
2897
4012
  request: z.object({ workspace: z.string(), roomSlug: z.string() }),
2898
4013
  response: z.object({ ok: z.boolean(), error: z.string().optional() }),
@@ -2901,6 +4016,7 @@ export const RECEIVERS = {
2901
4016
  }),
2902
4017
  "video:contact-call": receiver({
2903
4018
  receiver: "video:contact-call",
4019
+ surfaces: { mcp: { name: "contact_call" } },
2904
4020
  summary: "Provision a one-to-one video room for a contact; links the room thread to the contact CRM record",
2905
4021
  request: z.object({
2906
4022
  workspace: z.string(),
@@ -2920,6 +4036,7 @@ export const RECEIVERS = {
2920
4036
  }),
2921
4037
  "video:invite": receiver({
2922
4038
  receiver: "video:invite",
4039
+ surfaces: { mcp: { name: "invite_to_call" } },
2923
4040
  summary: "Generate a tracked /go/ join link for an actor into an existing room",
2924
4041
  request: z.object({
2925
4042
  workspace: z.string(),
@@ -2952,10 +4069,11 @@ export const RECEIVERS = {
2952
4069
  }),
2953
4070
  effect: "ask", cost: "free", idempotent: false, auth: "manage_clients",
2954
4071
  examples: [{ workspace: "acme", name: "Product Launch", actorIds: ["actor_1", "actor_2"] }],
2955
- surfaces: { chat: true }, // fan-out: registry-derived chat tool (actions-fan-out C5)
4072
+ surfaces: { chat: true, mcp: { name: "schedule_webinar" } }, // fan-out: chat (actions-fan-out C5) + MCP (agent-native C1)
2956
4073
  }),
2957
4074
  "video:create-session": receiver({
2958
4075
  receiver: "video:create-session",
4076
+ surfaces: { mcp: { name: "create_session" } },
2959
4077
  summary: "Create a video session with host + guest tracked join links; optionally schedules the call",
2960
4078
  request: z.object({
2961
4079
  workspace: z.string(),
@@ -2991,7 +4109,7 @@ export const RECEIVERS = {
2991
4109
  }),
2992
4110
  effect: "ask", cost: "variable", idempotent: false, auth: "manage_clients",
2993
4111
  examples: [{ threadId: "thr_abc", transcript: "Host: Hello... Guest: Hi..." }],
2994
- surfaces: { chat: true }, // fan-out: registry-derived chat tool (actions-fan-out C5)
4112
+ surfaces: { chat: true, mcp: { name: "video_summary" } }, // fan-out: chat (actions-fan-out C5) + MCP (agent-native C1)
2995
4113
  }),
2996
4114
  "video:quick-room": receiver({
2997
4115
  receiver: "video:quick-room",
@@ -3012,7 +4130,43 @@ export const RECEIVERS = {
3012
4130
  effect: "ask", cost: "free", idempotent: false, auth: "manage_clients",
3013
4131
  examples: [{ workspace: "acme", threadId: "thr_xyz", name: "Quick sync" }],
3014
4132
  // Fan-out pilot (actions-fan-out C2).
3015
- surfaces: { chat: true },
4133
+ surfaces: { chat: true, mcp: { name: "quick_call" } },
4134
+ }),
4135
+ // The human-handoff door. A visitor in chat asks for a person; the agent fires
4136
+ // this ONE receiver, which creates (or reuses) the meeting room, drops the
4137
+ // reason into the room's thread, and notifies the workspace wherever it
4138
+ // listens — Telegram/Discord/web inbox/web push, all through `notify`. It is
4139
+ // idempotent per CONVERSATION: the same chat asking twice joins the same room
4140
+ // instead of provisioning a second one.
4141
+ "video:request-meeting": receiver({
4142
+ receiver: "video:request-meeting",
4143
+ summary: "Visitor asks to meet a human: creates/reuses a meeting room for this conversation and notifies the workspace",
4144
+ request: z.object({
4145
+ workspace: z.string(),
4146
+ conversation: z.string().optional(), // chat group/thread id — the idempotency key
4147
+ roomSlug: z.string().optional(), // an EXISTING room to send them to (the standing front desk); never creates
4148
+ visitorName: z.string().optional(),
4149
+ reason: z.string().optional(), // what they want to talk about
4150
+ channel: z.string().optional(), // web | telegram | discord | api
4151
+ }),
4152
+ response: z.object({
4153
+ ok: z.boolean(),
4154
+ roomSlug: z.string().optional(),
4155
+ workspace: z.string().optional(),
4156
+ name: z.string().optional(),
4157
+ threadId: z.string().nullable().optional(),
4158
+ path: z.string().optional(), // /u/<workspace>/meet/<roomSlug>
4159
+ reused: z.boolean().optional(), // an existing room for this conversation
4160
+ notified: z.boolean().optional(),
4161
+ error: z.string().optional(), // 'invalid_request' | 'forbidden' | 'rate_limited' | …
4162
+ }),
4163
+ effect: "ask", cost: "free", idempotent: true, auth: "manage_clients",
4164
+ examples: [{ workspace: "acme", conversation: "conv:abc123", visitorName: "Sarah", reason: "pricing for 20 seats" }],
4165
+ // Deliberately NOT `surfaces: { chat: true }`. The registry-derived tool
4166
+ // (from-registry.ts) would be a second, unshaped door onto the same
4167
+ // receiver — no `public` flag (so no visitor could reach it), no room block
4168
+ // on the way back, and a name the model would pick by accident. The curated
4169
+ // `meet_human` (channels/src/tools/meetings.ts) is the chat surface.
3016
4170
  }),
3017
4171
  "video:join-event": receiver({
3018
4172
  receiver: "video:join-event",
@@ -3038,7 +4192,29 @@ export const RECEIVERS = {
3038
4192
  }),
3039
4193
  effect: "ask", cost: "free", idempotent: false, auth: "manage_clients",
3040
4194
  examples: [{ workspace: "acme", roomSlug: "algebra-101" }],
3041
- surfaces: { chat: true }, // fan-out: registry-derived chat tool (actions-fan-out C5)
4195
+ surfaces: { chat: true, mcp: { name: "start_recording" } }, // fan-out: chat (actions-fan-out C5) + MCP (agent-native C1)
4196
+ }),
4197
+ // video:room-status — the presence read, one lib function behind two doors.
4198
+ // NOT flagged surfaces.mcp on purpose: packages/mcp still hand-writes
4199
+ // `get_room_status` over this receiver, and flagging it here would derive a
4200
+ // second tool of the same name. Flag it in the same change that deletes that file.
4201
+ "video:room-status": receiver({
4202
+ receiver: "video:room-status",
4203
+ summary: "Who is in a room right now — staff names, a count of everyone else, and when the first staff peer joined",
4204
+ request: z.object({ workspace: z.string(), roomSlug: z.string() }),
4205
+ response: z.object({
4206
+ ok: z.boolean(),
4207
+ // `unknown` is a first-class answer: an upstream that could not be read is
4208
+ // never reported as an empty room.
4209
+ state: z.enum(["live", "empty", "unknown"]).optional(),
4210
+ staff: z.array(z.string()).optional(),
4211
+ others: z.number().optional(),
4212
+ since: z.string().nullable().optional(),
4213
+ reason: z.string().optional(),
4214
+ error: z.string().optional(),
4215
+ }),
4216
+ effect: "ask", cost: "free", idempotent: true, auth: "manage_clients",
4217
+ examples: [{ workspace: "acme", roomSlug: "meeting" }],
3042
4218
  }),
3043
4219
  "video:start-stream": receiver({
3044
4220
  receiver: "video:start-stream",
@@ -3055,7 +4231,7 @@ export const RECEIVERS = {
3055
4231
  }),
3056
4232
  effect: "ask", cost: "free", idempotent: false, auth: "manage_clients",
3057
4233
  examples: [{ workspace: "acme", roomSlug: "product-launch" }],
3058
- surfaces: { chat: true }, // fan-out: registry-derived chat tool (actions-fan-out C5)
4234
+ surfaces: { chat: true, mcp: { name: "start_stream" } }, // fan-out: chat (actions-fan-out C5) + MCP (agent-native C1)
3059
4235
  }),
3060
4236
  // connect:channel — token-paste connect for a channel (Connect plan C7). The
3061
4237
  // typed contract over the shipped /api/connect/telegram route: the caller pastes
@@ -3108,6 +4284,7 @@ export const RECEIVERS = {
3108
4284
  // ── Broadcast (newsletter) ────────────────────────────────────────────────
3109
4285
  "broadcast:create": receiver({
3110
4286
  receiver: "broadcast:create",
4287
+ surfaces: { mcp: true },
3111
4288
  summary: "Create a broadcast draft — subject, body_md, audience_tag, channel",
3112
4289
  request: z.object({ workspace: z.string(), subject: z.string().optional(), body_md: z.string().optional(), audience_tag: z.string().optional(), channel: z.enum(["email", "sms", "whatsapp"]).optional() }),
3113
4290
  response: z.object({ broadcastId: z.string() }),
@@ -3115,6 +4292,7 @@ export const RECEIVERS = {
3115
4292
  }),
3116
4293
  "broadcast:update": receiver({
3117
4294
  receiver: "broadcast:update",
4295
+ surfaces: { mcp: { name: "newsletter_update" } },
3118
4296
  summary: "Update a broadcast draft — subject, body, audience, schedule",
3119
4297
  request: z.object({ workspace: z.string(), broadcastId: z.string(), subject: z.string().optional(), body_md: z.string().optional(), audience_tag: z.string().optional(), scheduled_at: z.number().optional() }),
3120
4298
  response: z.object({ ok: z.boolean() }),
@@ -3122,6 +4300,7 @@ export const RECEIVERS = {
3122
4300
  }),
3123
4301
  "broadcast:list": receiver({
3124
4302
  receiver: "broadcast:list",
4303
+ surfaces: { mcp: true },
3125
4304
  summary: "List broadcasts for a workspace with status and sent_at",
3126
4305
  request: z.object({ workspace: z.string(), status: z.enum(["draft", "scheduled", "sending", "sent", "cancelled"]).optional(), limit: z.number().int().max(100).optional() }),
3127
4306
  response: z.object({ broadcasts: z.array(z.object({ id: z.string(), subject: z.string(), status: z.string(), sent_at: z.number().nullable(), audience_tag: z.string().nullable() })) }),
@@ -3129,6 +4308,7 @@ export const RECEIVERS = {
3129
4308
  }),
3130
4309
  "broadcast:get": receiver({
3131
4310
  receiver: "broadcast:get",
4311
+ surfaces: { mcp: true },
3132
4312
  summary: "Get a single broadcast with recipient counts",
3133
4313
  request: z.object({ workspace: z.string(), broadcastId: z.string() }),
3134
4314
  response: z.object({ broadcast: z.record(z.string(), z.unknown()) }),
@@ -3150,6 +4330,7 @@ export const RECEIVERS = {
3150
4330
  }),
3151
4331
  "segment:list": receiver({
3152
4332
  receiver: "segment:list",
4333
+ surfaces: { mcp: true },
3153
4334
  summary: "List audience segments for a workspace",
3154
4335
  request: z.object({ workspace: z.string() }),
3155
4336
  response: z.object({ segments: z.array(z.record(z.string(), z.unknown())) }),
@@ -3157,6 +4338,7 @@ export const RECEIVERS = {
3157
4338
  }),
3158
4339
  "segment:get": receiver({
3159
4340
  receiver: "segment:get",
4341
+ surfaces: { mcp: true },
3160
4342
  summary: "Get one audience segment with its rule definition",
3161
4343
  request: z.object({ workspace: z.string(), id: z.string() }),
3162
4344
  response: z.object({ segment: z.record(z.string(), z.unknown()) }),
@@ -3164,6 +4346,7 @@ export const RECEIVERS = {
3164
4346
  }),
3165
4347
  "segment:preview": receiver({
3166
4348
  receiver: "segment:preview",
4349
+ surfaces: { mcp: true },
3167
4350
  summary: "Preview an audience segment — live count + up to 10 sample addresses (read-only)",
3168
4351
  request: z.object({ workspace: z.string(), definition: z.record(z.string(), z.unknown()), channel: z.enum(["email", "sms", "whatsapp"]).optional() }),
3169
4352
  response: z.object({ count: z.number(), sample: z.array(z.string()) }),
@@ -3185,6 +4368,7 @@ export const RECEIVERS = {
3185
4368
  }),
3186
4369
  "broadcast:send": receiver({
3187
4370
  receiver: "broadcast:send",
4371
+ surfaces: { mcp: true },
3188
4372
  summary: "Seed recipients from audience tag, apply suppression, enqueue the send batch",
3189
4373
  request: z.object({ workspace: z.string(), broadcastId: z.string() }),
3190
4374
  response: z.object({ enqueued: z.number() }),
@@ -3287,18 +4471,21 @@ export const RECEIVERS = {
3287
4471
  // ── Send Link — actor-bound tracked URL (CRM personalisation) ──────────────
3288
4472
  "links:create": receiver({
3289
4473
  receiver: "links:create",
3290
- summary: "Create an actor-bound tracked link. On click `/go/:id` elevates the contact to rung-4 identity, pre-warms their snapshot, and personalises the page + chat. Creation is gated: the caller must hold authority over the target contact's workspace; `createdBy` is stamped from the attested session, never the body.",
4474
+ summary: "Create a tracked link in one of two shapes. ACTOR-BOUND (`actorId` given): the click elevates that contact to rung-4 identity at `/go/:id`, pre-warms their snapshot, and personalises the page + chat; the caller must hold authority over the contact's workspace. CAMPAIGN-ONLY (`campaignId` given, no `actorId`): the shape cold ad traffic needs, since an ad click is anonymous by definition and can never name a contact at mint time — the contact-owner walk is skipped, `actor_id` is stored null, and the click is attributed to the campaign alone. Exactly one of `actorId` / `campaignId` is required; supplying neither is refused. Minting is authed in both shapes; only the CLICK is anonymous. `slug` and `createdBy` are stamped from the attested session, never the body.",
3291
4475
  request: z.object({
3292
- actorId: z.string().describe("Contact (actor) the link is bound to — must belong to the caller's workspace or a descendant"),
4476
+ actorId: z.string().optional().describe("Contact (actor) the link is bound to — must belong to the caller's workspace or a descendant. Omit for cold ad traffic, in which case `campaignId` is REQUIRED so the click is never fully unattributed"),
3293
4477
  destination: z.string().optional().describe("Path the click lands on (must start with '/'); default '/'"),
3294
4478
  context: z.string().optional().describe("Extra context injected into the agent's system prompt for this session"),
3295
4479
  greeting: z.string().optional().describe("Override the chat agent's opening line"),
3296
- campaignId: z.string().optional().describe("Group clicks under a campaign for the learning loop"),
4480
+ campaignId: z.string().optional().describe("Group clicks under a campaign for the learning loop. REQUIRED when `actorId` is absent. Forwarded onto the destination URL as `utm_campaign` so the landing page and the click row report the same campaign; capped at 128 chars"),
3297
4481
  expiresInDays: z.number().optional().describe("Link TTL in days; omitted = never expires"),
3298
4482
  }),
3299
4483
  response: z.object({ id: z.string(), sig: z.string(), url: z.string() }),
3300
4484
  effect: "ask", cost: "free", reversible: false, idempotent: false, auth: "member",
3301
- examples: [{ actorId: "u-123", destination: "/pricing", expiresInDays: 90 }],
4485
+ examples: [
4486
+ { actorId: "u-123", destination: "/pricing", expiresInDays: 90 },
4487
+ { campaignId: "spring-ads", destination: "/movers", expiresInDays: 30 },
4488
+ ],
3302
4489
  }),
3303
4490
  "links:bulk-create": receiver({
3304
4491
  receiver: "links:bulk-create",
@@ -3483,6 +4670,7 @@ export const RECEIVERS = {
3483
4670
  // ── seo — handlers in resolvers/seo.ts (C1 live; C2 async) ─────────────────
3484
4671
  "seo:backlinks-summary": receiver({
3485
4672
  receiver: "seo:backlinks-summary",
4673
+ surfaces: { mcp: { name: "seo_backlinks" } },
3486
4674
  summary: "Pull live backlink summary for a domain via DataForSEO; marks domain→seo:backlinks path",
3487
4675
  request: z.object({ target: z.string(), limit: z.number().int().min(1).max(1000).optional() }),
3488
4676
  response: z.object({ ok: z.boolean(), data: z.unknown().optional(), error: z.string().optional() }),
@@ -3491,6 +4679,7 @@ export const RECEIVERS = {
3491
4679
  }),
3492
4680
  "seo:llm-mentions": receiver({
3493
4681
  receiver: "seo:llm-mentions",
4682
+ surfaces: { mcp: { name: "seo_ai_visibility" } },
3494
4683
  summary: "Pull live AI-visibility (LLM mentions) data for a domain via DataForSEO; marks domain→seo:llm-mentions path",
3495
4684
  request: z.object({ target: z.string() }),
3496
4685
  response: z.object({ ok: z.boolean(), data: z.unknown().optional(), error: z.string().optional() }),
@@ -3499,6 +4688,7 @@ export const RECEIVERS = {
3499
4688
  }),
3500
4689
  "seo:keywords-for-keyword": receiver({
3501
4690
  receiver: "seo:keywords-for-keyword",
4691
+ surfaces: { mcp: { name: "seo_research_keywords" } },
3502
4692
  summary: "Return related keyword suggestions for a seed keyword via DataForSEO Google Ads; marks keyword→seo:keywords path",
3503
4693
  request: z.object({ keyword: z.string(), location_code: z.number().int().optional(), language_code: z.string().optional() }),
3504
4694
  response: z.object({ ok: z.boolean(), data: z.unknown().optional(), error: z.string().optional() }),
@@ -3507,6 +4697,7 @@ export const RECEIVERS = {
3507
4697
  }),
3508
4698
  "seo:serp": receiver({
3509
4699
  receiver: "seo:serp",
4700
+ surfaces: { mcp: true },
3510
4701
  summary: "Return full Google Organic SERP for a keyword via DataForSEO async task (submit→poll); marks keyword→seo:serp path",
3511
4702
  request: z.object({ keyword: z.string(), location_code: z.number().int().optional(), language_code: z.string().optional(), device: z.string().optional() }),
3512
4703
  response: z.object({ ok: z.boolean(), data: z.unknown().optional(), error: z.string().optional() }),
@@ -3515,6 +4706,7 @@ export const RECEIVERS = {
3515
4706
  }),
3516
4707
  "seo:keyword-metrics": receiver({
3517
4708
  receiver: "seo:keyword-metrics",
4709
+ surfaces: { mcp: true },
3518
4710
  summary: "Return monthly search volume, CPC, and competition for a list of keywords via DataForSEO async task",
3519
4711
  request: z.object({ keywords: z.array(z.string()), location_code: z.number().int().optional(), language_code: z.string().optional() }),
3520
4712
  response: z.object({ ok: z.boolean(), data: z.unknown().optional(), error: z.string().optional() }),
@@ -3523,6 +4715,7 @@ export const RECEIVERS = {
3523
4715
  }),
3524
4716
  "seo:gsc-performance": receiver({
3525
4717
  receiver: "seo:gsc-performance",
4718
+ surfaces: { mcp: { name: "seo_gsc" } },
3526
4719
  summary: "Return Google Search Console click/impression/CTR/position data for a site via Composio GSC toolkit",
3527
4720
  request: z.object({ site_url: z.string(), start_date: z.string(), end_date: z.string(), dimensions: z.array(z.string()).optional(), row_limit: z.number().int().optional() }),
3528
4721
  response: z.object({ ok: z.boolean(), data: z.unknown().optional(), error: z.string().optional() }),