@canonmsg/backend-contracts 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1139 @@
1
+ /**
2
+ * JSON Schemas (draft 2020-12) for the Canon agent verb contract.
3
+ *
4
+ * One bundled document, `$id` canon.verbs.v1.json, with a `<verb>_input` and
5
+ * `<verb>_result` definition per verb under `$defs` plus shared shapes. The
6
+ * document is emitted verbatim to `dist/canon-verbs.schema.json` at build
7
+ * time (see scripts/emit-verb-schemas.mjs) so non-TypeScript bindings can
8
+ * consume the byte-identical contract instead of hand-porting it (the Python
9
+ * hermes plugin adopts it in a follow-up PR; external integrators and
10
+ * generated CLIs read it from the npm package).
11
+ *
12
+ * Conventions:
13
+ * - Verb INPUT schemas are strict (`additionalProperties: false`) — a binding
14
+ * must not invent fields.
15
+ * - Verb RESULT schemas are open (`additionalProperties: true`) — servers may
16
+ * add fields; consumers must tolerate them. The STATUS vocabularies are
17
+ * deliberately closed per major contract version: a new status requires a
18
+ * canon.verbs.v2 (or an additive bundle revision), so bindings should fail
19
+ * closed on unknown statuses.
20
+ * - `maxLength` counts UTF-16 code units; where the server enforces UTF-8
21
+ * BYTES the description says so and the maxLength is the byte figure (a
22
+ * permissive approximation — multibyte text within the char count can still
23
+ * exceed the server's byte cap). Bindings MUST therefore run
24
+ * `findVerbByteLimitViolations()` after schema validation (the same checks
25
+ * ship machine-readably as dist/canon-verbs.limits.json).
26
+ * - Server enforcement sites are cited in descriptions; this document
27
+ * describes, functions/src enforces (until the planned /agent/verbs/*
28
+ * endpoints validate against these schemas directly).
29
+ */
30
+ import { CANON_VERBS_SCHEMA_ID, CANON_VERB_NAMES, SELF_CONTEXT_TYPE, VERB_ID_PATTERNS, VERB_LIMITS, } from './verbContract.js';
31
+ const REF = (def) => ({ $ref: `#/$defs/${def}` });
32
+ const selfContextDef = {
33
+ type: 'object',
34
+ description: 'Private note-to-self attached to a cross-conversation send. Visible only to the '
35
+ + 'sending agent (surfaced back as provenance.activeSelfContext); never shown to '
36
+ + 'recipients. Enforced by functions/src/utils/selfContexts.ts.',
37
+ required: ['type', 'context'],
38
+ additionalProperties: false,
39
+ properties: {
40
+ type: { const: SELF_CONTEXT_TYPE },
41
+ context: {
42
+ type: 'string',
43
+ minLength: 1,
44
+ maxLength: VERB_LIMITS.selfContextChars,
45
+ description: 'What future-you needs to know about this transfer.',
46
+ },
47
+ },
48
+ };
49
+ const sessionSelectionDef = {
50
+ description: 'How to pick the conversation when targeting a user whose chats are session-shaped '
51
+ + '(agent targets). Canon default for user-targeted sends: continue_or_create.',
52
+ oneOf: [
53
+ {
54
+ type: 'object',
55
+ required: ['mode'],
56
+ additionalProperties: false,
57
+ properties: { mode: { enum: ['new', 'continue_latest', 'continue_or_create'] } },
58
+ },
59
+ {
60
+ type: 'object',
61
+ required: ['mode', 'conversationId'],
62
+ additionalProperties: false,
63
+ properties: {
64
+ mode: { const: 'specific' },
65
+ conversationId: { type: 'string', pattern: VERB_ID_PATTERNS.runtimeId },
66
+ },
67
+ },
68
+ ],
69
+ };
70
+ const mediaAttachmentDef = {
71
+ type: 'object',
72
+ required: ['kind', 'url'],
73
+ additionalProperties: false,
74
+ properties: {
75
+ kind: { enum: ['image', 'audio', 'video', 'file'] },
76
+ url: {
77
+ type: 'string',
78
+ minLength: 1,
79
+ description: 'Must come from /media/upload (Canon storage) or the GIF picker — server '
80
+ + 'allowlist, fail-closed (functions/src/utils/mediaUploadSafety.ts).',
81
+ },
82
+ mimeType: { type: 'string' },
83
+ fileName: { type: 'string' },
84
+ sizeBytes: { type: 'number', minimum: 0 },
85
+ width: { type: 'number', minimum: 0 },
86
+ height: { type: 'number', minimum: 0 },
87
+ durationMs: { type: 'number', minimum: 0 },
88
+ },
89
+ };
90
+ const turnMetadataDef = {
91
+ type: 'object',
92
+ description: `Free-form metadata envelope; JSON.stringify length <= ${VERB_LIMITS.messageMetadataJsonChars} `
93
+ + 'UTF-16 code units (functions/src/api/sendMessage.ts checks string length, not bytes). '
94
+ + 'Well-known turn-protocol keys below (authoritative type: turnProtocol.ts TurnMetadata, '
95
+ + 'which also carries requestedTurnMode); receipt metadata.type values are server-owned '
96
+ + 'and rejected.',
97
+ additionalProperties: true,
98
+ properties: {
99
+ turnId: { type: ['string', 'null'] },
100
+ turnSemantics: { enum: ['progress', 'turn_complete', 'control'] },
101
+ deliveryIntent: {
102
+ enum: ['queue', 'interrupt', 'interleave', 'stop'],
103
+ description: 'What the recipient runtime should do if the message lands mid-turn. Default queue.',
104
+ },
105
+ replyBehavior: { enum: ['allow_auto_reply', 'suppress_auto_reply'] },
106
+ },
107
+ };
108
+ const messageIdDef = {
109
+ type: 'string',
110
+ pattern: VERB_ID_PATTERNS.runtimeId,
111
+ not: {
112
+ pattern: '^(\\.{1,2}|__.*__)$',
113
+ description: "Reserved forms the server 400s: '.', '..', and __x__ ids.",
114
+ },
115
+ description: `Client idempotency key (<= ${VERB_LIMITS.messageIdChars} chars / `
116
+ + `${VERB_LIMITS.messageIdBytes} UTF-8 bytes; no slashes, control chars, '.', '..', __x__). `
117
+ + 'Same id + identical payload replays idempotently; different payload -> 409 MESSAGE_ID_EXISTS.',
118
+ };
119
+ const messageOptionsDef = {
120
+ type: 'object',
121
+ description: 'Message composition options (mirrors POST /messages/send). Deliberately absent: '
122
+ + 'contact_card contentType (use share_contact) and forwarded/forwardedFrom '
123
+ + '(forwarding stays host/REST-mediated; /messages/forward is not a v1 verb).',
124
+ additionalProperties: false,
125
+ properties: {
126
+ messageId: REF('messageId'),
127
+ contentType: {
128
+ enum: ['text', 'image', 'audio', 'video', 'file'],
129
+ description: 'Derived from attachments when omitted. contact_card is deliberately excluded here — '
130
+ + 'use the share_contact verb.',
131
+ },
132
+ attachments: {
133
+ type: 'array',
134
+ maxItems: VERB_LIMITS.messageAttachments,
135
+ items: REF('mediaAttachment'),
136
+ },
137
+ mentions: {
138
+ type: 'array',
139
+ items: { type: 'string' },
140
+ description: 'Member userIds; every id must be a conversation member (400 otherwise).',
141
+ },
142
+ replyTo: { type: 'string', description: 'Message id being replied to (not existence-validated).' },
143
+ replyToPosition: { type: 'integer' },
144
+ metadata: REF('turnMetadata'),
145
+ },
146
+ };
147
+ const inputChoiceDef = {
148
+ type: 'object',
149
+ required: ['label'],
150
+ additionalProperties: false,
151
+ properties: {
152
+ label: { type: 'string', minLength: 1, maxLength: VERB_LIMITS.inputChoiceLabelChars },
153
+ value: { type: 'string', maxLength: VERB_LIMITS.inputChoiceValueChars },
154
+ description: { type: 'string', maxLength: VERB_LIMITS.inputChoiceDescriptionChars },
155
+ },
156
+ };
157
+ const inputQuestionDef = {
158
+ type: 'object',
159
+ required: ['id', 'question'],
160
+ additionalProperties: false,
161
+ properties: {
162
+ id: { type: 'string', pattern: VERB_ID_PATTERNS.questionId },
163
+ question: { type: 'string', minLength: 1, maxLength: VERB_LIMITS.inputQuestionChars },
164
+ header: { type: 'string', maxLength: VERB_LIMITS.inputQuestionHeaderChars },
165
+ choices: { type: 'array', maxItems: VERB_LIMITS.inputChoices, items: REF('inputChoice') },
166
+ allowOther: { type: 'boolean' },
167
+ isSecret: {
168
+ type: 'boolean',
169
+ description: 'Forces owner-only routing for the whole request.',
170
+ },
171
+ multiSelect: { type: 'boolean' },
172
+ },
173
+ };
174
+ const approvalDetailDef = {
175
+ type: 'object',
176
+ required: ['label', 'value'],
177
+ additionalProperties: false,
178
+ properties: {
179
+ label: { type: 'string', minLength: 1, maxLength: VERB_LIMITS.approvalDetailLabelChars },
180
+ value: { type: 'string', minLength: 1, maxLength: VERB_LIMITS.approvalDetailValueChars },
181
+ monospace: { type: 'boolean' },
182
+ },
183
+ };
184
+ const unifiedDiffDef = {
185
+ type: 'object',
186
+ description: `Unified diff for file-change approvals. Server sanitizes: sensitive paths suppressed, `
187
+ + `secret tokens redacted, per-file clip ${VERB_LIMITS.diffFileBytes} bytes, total `
188
+ + `${VERB_LIMITS.diffTotalBytes} bytes (functions/src/api/interactionApproval.ts; primitives in `
189
+ + 'backend-contracts diffRedaction.ts).',
190
+ required: ['files'],
191
+ additionalProperties: false,
192
+ properties: {
193
+ files: {
194
+ type: 'array',
195
+ maxItems: VERB_LIMITS.diffFiles,
196
+ items: {
197
+ type: 'object',
198
+ required: ['path', 'status'],
199
+ additionalProperties: false,
200
+ properties: {
201
+ path: { type: 'string', minLength: 1, maxLength: VERB_LIMITS.diffPathChars },
202
+ status: { enum: ['modified', 'created', 'deleted', 'renamed'] },
203
+ oldPath: { type: 'string', maxLength: VERB_LIMITS.diffPathChars },
204
+ additions: { type: 'integer', minimum: 0 },
205
+ deletions: { type: 'integer', minimum: 0 },
206
+ diff: { type: 'string' },
207
+ suppressed: { type: 'boolean' },
208
+ },
209
+ },
210
+ },
211
+ truncated: { type: 'boolean' },
212
+ },
213
+ };
214
+ const sessionRuleDef = {
215
+ type: 'object',
216
+ description: 'Standing approval rule. Owner-only: the server forces allowSessionRule=false for '
217
+ + 'non-owner responders.',
218
+ required: ['type'],
219
+ additionalProperties: false,
220
+ properties: {
221
+ type: { enum: ['approve-all', 'approve-tool', 'deny-tool'] },
222
+ toolPattern: { type: 'string', pattern: VERB_ID_PATTERNS.sessionRuleToolPattern },
223
+ expiresAt: { type: ['string', 'null'], description: 'ISO timestamp or null.' },
224
+ },
225
+ if: { properties: { type: { enum: ['approve-tool', 'deny-tool'] } }, required: ['type'] },
226
+ then: { required: ['toolPattern'], properties: { toolPattern: true } },
227
+ };
228
+ const runtimeCardDef = {
229
+ type: 'object',
230
+ description: 'ENVELOPE-ONLY validation of a canon.card.v1 document: block structures are NOT '
231
+ + 'checked here. Tool bindings MUST compose the full document schema via '
232
+ + 'getVerbInputSchema(verb, { cardSchema: RUNTIME_CARD_JSON_SCHEMA_V1 }) so models '
233
+ + 'cannot produce envelope-valid cards the platform rejects. Full document contract: '
234
+ + '@canonmsg/rich-cards RUNTIME_CARD_JSON_SCHEMA_V1 ($id '
235
+ + 'https://canonmsg.com/schemas/canon.card.v1.json) — '
236
+ + `authoring caps title 120 / fallbackText 500 / blocks 24. Server envelope acceptance `
237
+ + `(interactionCard.ts): <= ${VERB_LIMITS.cardEnvelopeBytes} bytes serialized, title <= `
238
+ + `${VERB_LIMITS.cardServerTitleChars}, fallbackText <= ${VERB_LIMITS.cardServerFallbackTextChars}, `
239
+ + `blocks <= ${VERB_LIMITS.cardServerBlocks}; no secret-bearing top-level keys.`,
240
+ required: ['schema', 'title', 'fallbackText', 'blocks'],
241
+ additionalProperties: true,
242
+ properties: {
243
+ schema: { const: 'canon.card.v1' },
244
+ cardId: { type: 'string', pattern: VERB_ID_PATTERNS.cardId },
245
+ title: { type: 'string', minLength: 1, maxLength: VERB_LIMITS.cardServerTitleChars },
246
+ fallbackText: { type: 'string', minLength: 1, maxLength: VERB_LIMITS.cardServerFallbackTextChars },
247
+ blocks: { type: 'array', minItems: 1, maxItems: VERB_LIMITS.cardServerBlocks },
248
+ },
249
+ };
250
+ const nativeDef = {
251
+ type: 'object',
252
+ description: `Runtime correlation handles (<= ${VERB_LIMITS.nativeKeys} keys, string values <= `
253
+ + `${VERB_LIMITS.nativeValueChars} chars or a nested handles map <= ${VERB_LIMITS.nativeHandles}; `
254
+ + 'functions/src/api/interactionKinds.ts normalizeNative).',
255
+ additionalProperties: true,
256
+ };
257
+ const timeoutFields = (maxMs, ceilingNote) => ({
258
+ timeoutMs: {
259
+ type: 'integer',
260
+ minimum: VERB_LIMITS.minTimeoutMs,
261
+ maximum: maxMs,
262
+ description: `Relative deadline in ms (server ceiling ${ceilingNote}). expiresAt wins when both are given.`,
263
+ },
264
+ expiresAt: {
265
+ type: 'integer',
266
+ minimum: 0,
267
+ description: 'Absolute epoch-ms deadline. The REST interaction creators REQUIRE a deadline '
268
+ + '(and /runtime-input/request also requires kind): bindings must fill defaults '
269
+ + 'and convert timeoutMs into expiresAt before calling the endpoint.',
270
+ },
271
+ });
272
+ // ---------------------------------------------------------------------------
273
+ // Verb schemas
274
+ // ---------------------------------------------------------------------------
275
+ const send_to_input = {
276
+ type: 'object',
277
+ description: 'Message another conversation or user (admission-aware), optionally carrying a '
278
+ + 'private self-context. Exactly one of targetConversationId / targetUserId / '
279
+ + 'canonContactId. canonContactId is NOT a wire field: bindings resolve it via POST '
280
+ + '/admission/resolve to a targetUserId first. Projections: selfContext sends -> POST '
281
+ + '/messages/send-contextual (which requires sourceConversationId + selfContext); '
282
+ + 'known-conversation sends without selfContext -> POST /messages/send; plain user '
283
+ + 'sends -> admission resolve + create + send (the core reachOut composite). User '
284
+ + 'targets resolve admission first: open targets get the message, approval-required '
285
+ + 'targets get a contact request (send deferred and auto-fulfilled on approval), '
286
+ + "owner-only targets surface as status 'unavailable' with reason owner-only.",
287
+ additionalProperties: false,
288
+ properties: {
289
+ targetConversationId: { type: 'string', pattern: VERB_ID_PATTERNS.runtimeId },
290
+ targetUserId: { type: 'string', minLength: 1 },
291
+ canonContactId: { type: 'string', minLength: 1 },
292
+ text: {
293
+ type: 'string',
294
+ maxLength: VERB_LIMITS.messageTextBytes,
295
+ description: `<= ${VERB_LIMITS.messageTextBytes} UTF-8 bytes. Required unless messageOptions.attachments carries the content.`,
296
+ },
297
+ sourceConversationId: {
298
+ type: 'string',
299
+ pattern: VERB_ID_PATTERNS.runtimeId,
300
+ description: 'Required when selfContext is present; the agent must be a member.',
301
+ },
302
+ selfContext: REF('selfContext'),
303
+ requestMessage: {
304
+ type: 'string',
305
+ maxLength: VERB_LIMITS.contactRequestNoteChars,
306
+ description: `Contact-request note when admission requires approval; defaults to text; senders `
307
+ + `truncate to ${VERB_LIMITS.contactRequestNoteChars} chars.`,
308
+ },
309
+ sessionSelection: REF('sessionSelection'),
310
+ sessionConfig: {
311
+ type: ['object', 'null'],
312
+ description: 'Coding-agent session setup (model/permissionMode/effort/workspaceId/executionMode); '
313
+ + 'only applied to agent targets, stripped for humans.',
314
+ },
315
+ messageOptions: REF('messageOptions'),
316
+ },
317
+ oneOf: [
318
+ { required: ['targetConversationId'], properties: { targetConversationId: true } },
319
+ { required: ['targetUserId'], properties: { targetUserId: true } },
320
+ { required: ['canonContactId'], properties: { canonContactId: true } },
321
+ ],
322
+ dependentRequired: { selfContext: ['sourceConversationId'] },
323
+ };
324
+ const send_to_result = {
325
+ description: 'Canonical outcome vocabulary. Legacy binding drift documented in CANON_VERB_BINDINGS '
326
+ + "(hermes 'opened' -> messaged without messageId; hermes 'denied' -> blocked).",
327
+ oneOf: [
328
+ {
329
+ type: 'object',
330
+ required: ['status', 'conversationId'],
331
+ additionalProperties: true,
332
+ properties: {
333
+ status: { const: 'messaged' },
334
+ conversationId: { type: 'string' },
335
+ messageId: { type: 'string' },
336
+ selfContextId: { type: 'string' },
337
+ created: { type: 'boolean' },
338
+ reused: { type: 'boolean' },
339
+ sessionSelection: { type: 'string' },
340
+ },
341
+ },
342
+ {
343
+ type: 'object',
344
+ required: ['status', 'requestId'],
345
+ additionalProperties: true,
346
+ properties: {
347
+ status: { enum: ['requested', 'pending'] },
348
+ requestId: { type: ['string', 'null'] },
349
+ deferredIntentId: {
350
+ type: ['string', 'null'],
351
+ description: 'Present when the send is deferred until the contact request resolves.',
352
+ },
353
+ },
354
+ },
355
+ {
356
+ type: 'object',
357
+ required: ['status', 'reason'],
358
+ additionalProperties: true,
359
+ properties: {
360
+ status: { enum: ['setup_required', 'no_session', 'blocked', 'unavailable'] },
361
+ reason: { type: 'string' },
362
+ },
363
+ },
364
+ ],
365
+ };
366
+ const request_input_input = {
367
+ type: 'object',
368
+ description: 'Ask a human a structured question mid-turn (HITL input card). sudo/secret kinds, '
369
+ + 'sensitive:true, or any isSecret question force owner-only routing server-side; '
370
+ + 'bindings must not let the model redirect the responder for those. Ceiling '
371
+ + `${VERB_LIMITS.maxTimeoutMs / 60000} minutes.`,
372
+ additionalProperties: false,
373
+ properties: {
374
+ conversationId: {
375
+ type: 'string',
376
+ pattern: VERB_ID_PATTERNS.runtimeId,
377
+ description: 'Bindings default to the active conversation.',
378
+ },
379
+ inputId: {
380
+ type: 'string',
381
+ pattern: VERB_ID_PATTERNS.runtimeId,
382
+ description: 'Durable single-use id (reuse -> 409); generated when omitted.',
383
+ },
384
+ kind: { enum: ['clarify', 'sudo', 'secret'], default: 'clarify' },
385
+ title: { type: 'string', maxLength: VERB_LIMITS.inputTitleChars },
386
+ prompt: { type: 'string', maxLength: VERB_LIMITS.inputPromptChars },
387
+ choices: { type: 'array', maxItems: VERB_LIMITS.inputChoices, items: REF('inputChoice') },
388
+ questions: { type: 'array', maxItems: VERB_LIMITS.inputQuestions, items: REF('inputQuestion') },
389
+ secretName: { type: 'string', maxLength: VERB_LIMITS.inputSecretNameChars },
390
+ sensitive: { type: 'boolean' },
391
+ responseUserId: {
392
+ type: 'string',
393
+ pattern: VERB_ID_PATTERNS.runtimeId,
394
+ description: 'Must be a human conversation member. Trust-critical bindings (hermes) derive this '
395
+ + 'from turn provenance and ignore model-supplied values.',
396
+ },
397
+ native: REF('native'),
398
+ turnId: { type: 'string', maxLength: VERB_LIMITS.turnIdChars },
399
+ ...timeoutFields(VERB_LIMITS.maxTimeoutMs, '30 minutes'),
400
+ },
401
+ };
402
+ const request_input_result = {
403
+ oneOf: [
404
+ {
405
+ type: 'object',
406
+ required: ['status', 'inputId', 'value'],
407
+ additionalProperties: true,
408
+ properties: {
409
+ status: { const: 'submitted' },
410
+ inputId: { type: 'string' },
411
+ value: { type: 'string', description: "Free-text answer ('' when structured answers were used)." },
412
+ answers: {
413
+ type: 'object',
414
+ description: 'Structured answers keyed by question id.',
415
+ additionalProperties: {
416
+ type: 'object',
417
+ required: ['answers'],
418
+ properties: { answers: { type: 'array', items: { type: 'string' } } },
419
+ },
420
+ },
421
+ },
422
+ },
423
+ {
424
+ type: 'object',
425
+ required: ['status', 'inputId'],
426
+ additionalProperties: true,
427
+ properties: {
428
+ status: { enum: ['cancelled', 'timeout'] },
429
+ inputId: { type: 'string' },
430
+ },
431
+ },
432
+ ],
433
+ };
434
+ const request_approval_input = {
435
+ type: 'object',
436
+ description: "Ask a human to allow/deny an action. mode 'blocking' (default) waits inside the verb "
437
+ + "call; mode 'detached' returns pending immediately — fetch the decision later with "
438
+ + `check_approval. Approval deadline ceiling is ${VERB_LIMITS.maxApprovalTimeoutMs / 3600000} hours `
439
+ + '(other kinds: 30 minutes). Timeouts fail closed to deny.',
440
+ required: ['toolName', 'toolSummary'],
441
+ additionalProperties: false,
442
+ properties: {
443
+ conversationId: { type: 'string', pattern: VERB_ID_PATTERNS.runtimeId },
444
+ approvalId: {
445
+ type: 'string',
446
+ pattern: VERB_ID_PATTERNS.runtimeId,
447
+ description: 'Durable single-use id; server-generated when omitted.',
448
+ },
449
+ toolName: { type: 'string', minLength: 1, maxLength: VERB_LIMITS.toolNameChars },
450
+ toolSummary: {
451
+ type: 'string',
452
+ minLength: 1,
453
+ maxLength: VERB_LIMITS.toolSummaryChars,
454
+ description: 'Pre-redact secrets — this renders to humans.',
455
+ },
456
+ mode: { enum: ['blocking', 'detached'], default: 'blocking' },
457
+ riskLevel: {
458
+ enum: ['normal', 'destructive'],
459
+ description: 'Server accepts only these values; others are silently dropped, not rejected.',
460
+ },
461
+ risk: {
462
+ enum: ['low', 'normal', 'high', 'destructive'],
463
+ description: 'Advisory; server stores any string <= 64 chars today.',
464
+ },
465
+ category: {
466
+ enum: ['command', 'file', 'network', 'browser', 'mcp', 'plugin', 'canon', 'tool'],
467
+ description: 'Advisory; server stores any string <= 64 chars today.',
468
+ },
469
+ details: { type: 'array', maxItems: VERB_LIMITS.approvalDetails, items: REF('approvalDetail') },
470
+ diff: REF('unifiedDiff'),
471
+ native: REF('native'),
472
+ runtimeId: { type: 'string', maxLength: VERB_LIMITS.turnIdChars },
473
+ turnId: { type: 'string', maxLength: VERB_LIMITS.turnIdChars },
474
+ responseUserId: { type: 'string', pattern: VERB_ID_PATTERNS.runtimeId },
475
+ allowSessionRule: {
476
+ type: 'boolean',
477
+ description: 'Server forces false when the responder is not the agent owner.',
478
+ },
479
+ ...timeoutFields(VERB_LIMITS.maxApprovalTimeoutMs, '72 hours'),
480
+ },
481
+ };
482
+ const request_approval_result = {
483
+ oneOf: [
484
+ {
485
+ type: 'object',
486
+ required: ['status', 'approvalId'],
487
+ additionalProperties: true,
488
+ properties: {
489
+ status: { const: 'allow' },
490
+ approvalId: { type: 'string' },
491
+ sessionRule: REF('sessionRule'),
492
+ respondedBy: { type: 'string', description: 'Canon-authenticated responder userId.' },
493
+ },
494
+ },
495
+ {
496
+ type: 'object',
497
+ required: ['status', 'approvalId'],
498
+ additionalProperties: true,
499
+ properties: {
500
+ status: { const: 'deny' },
501
+ approvalId: { type: 'string' },
502
+ sessionRule: REF('sessionRule'),
503
+ respondedBy: { type: 'string' },
504
+ },
505
+ },
506
+ {
507
+ type: 'object',
508
+ required: ['status', 'approvalId'],
509
+ additionalProperties: true,
510
+ properties: { status: { const: 'timeout' }, approvalId: { type: 'string' } },
511
+ },
512
+ {
513
+ type: 'object',
514
+ description: 'Detached acceptance — the decision arrives via check_approval.',
515
+ required: ['status', 'approvalId', 'expiresAt'],
516
+ additionalProperties: true,
517
+ properties: {
518
+ status: { const: 'pending' },
519
+ approvalId: { type: 'string' },
520
+ conversationId: { type: 'string' },
521
+ expiresAt: { type: 'integer' },
522
+ responseUserId: { type: 'string' },
523
+ },
524
+ },
525
+ ],
526
+ };
527
+ const check_approval_input = {
528
+ type: 'object',
529
+ description: 'Fetch the decision of a detached approval. Consume is idempotent within the replay '
530
+ + 'window: a resolved decision replays for up to 72h after first consume, then the id '
531
+ + 'becomes unknown. Bindings must bind the check to the conversation that created the '
532
+ + 'approval.',
533
+ required: ['approvalId'],
534
+ additionalProperties: false,
535
+ properties: {
536
+ approvalId: { type: 'string', pattern: VERB_ID_PATTERNS.runtimeId },
537
+ conversationId: { type: 'string', pattern: VERB_ID_PATTERNS.runtimeId },
538
+ },
539
+ };
540
+ const check_approval_result = {
541
+ description: "'unknown' is NOT a denial — only 'resolved' carries a decision. Canonical statuses map "
542
+ + 'from the /runtime-approval/consume wire as: allow/deny -> resolved.decision, timeout -> '
543
+ + 'expired, pending -> pending, HTTP 404 RUNTIME_APPROVAL_NOT_FOUND -> unknown.',
544
+ oneOf: [
545
+ {
546
+ type: 'object',
547
+ required: ['status', 'approvalId', 'decision'],
548
+ additionalProperties: true,
549
+ properties: {
550
+ status: { const: 'resolved' },
551
+ approvalId: { type: 'string' },
552
+ decision: { enum: ['allow', 'deny'] },
553
+ respondedBy: { type: 'string' },
554
+ conversationId: { type: 'string' },
555
+ },
556
+ },
557
+ {
558
+ type: 'object',
559
+ required: ['status', 'approvalId'],
560
+ additionalProperties: true,
561
+ properties: {
562
+ status: { enum: ['pending', 'expired', 'unknown'] },
563
+ approvalId: { type: 'string' },
564
+ expiresAt: { type: 'integer' },
565
+ conversationId: { type: 'string' },
566
+ },
567
+ },
568
+ ],
569
+ };
570
+ const send_card_input = {
571
+ type: 'object',
572
+ description: 'Display a card with no actions — fire-and-forget, no pending state, no deadline.',
573
+ required: ['card'],
574
+ additionalProperties: false,
575
+ properties: {
576
+ conversationId: { type: 'string', pattern: VERB_ID_PATTERNS.runtimeId },
577
+ card: REF('runtimeCard'),
578
+ cardId: { type: 'string', pattern: VERB_ID_PATTERNS.cardId },
579
+ native: REF('native'),
580
+ runtimeId: { type: 'string', maxLength: VERB_LIMITS.turnIdChars },
581
+ turnId: { type: 'string', maxLength: VERB_LIMITS.turnIdChars },
582
+ },
583
+ };
584
+ const send_card_result = {
585
+ type: 'object',
586
+ required: ['status', 'cardId'],
587
+ additionalProperties: true,
588
+ properties: {
589
+ status: { const: 'displayed' },
590
+ cardId: { type: 'string' },
591
+ conversationId: { type: 'string' },
592
+ responseUserId: { type: 'string' },
593
+ },
594
+ };
595
+ const request_card_input = {
596
+ type: 'object',
597
+ description: 'Show an interactive card (>= 1 actions block required) and wait for the response. '
598
+ + `Deadline ceiling ${VERB_LIMITS.maxTimeoutMs / 60000} minutes.`,
599
+ required: ['card'],
600
+ additionalProperties: false,
601
+ properties: {
602
+ conversationId: { type: 'string', pattern: VERB_ID_PATTERNS.runtimeId },
603
+ card: REF('runtimeCard'),
604
+ cardId: { type: 'string', pattern: VERB_ID_PATTERNS.cardId },
605
+ responseUserId: {
606
+ type: 'string',
607
+ pattern: VERB_ID_PATTERNS.runtimeId,
608
+ description: 'Must be a human conversation member; defaults to the turn-triggering human '
609
+ + '(trust-critical bindings ignore model-supplied values).',
610
+ },
611
+ native: REF('native'),
612
+ runtimeId: { type: 'string', maxLength: VERB_LIMITS.turnIdChars },
613
+ turnId: { type: 'string', maxLength: VERB_LIMITS.turnIdChars },
614
+ ...timeoutFields(VERB_LIMITS.maxTimeoutMs, '30 minutes'),
615
+ },
616
+ };
617
+ const request_card_result = {
618
+ oneOf: [
619
+ {
620
+ type: 'object',
621
+ required: ['status', 'cardId'],
622
+ additionalProperties: true,
623
+ properties: {
624
+ status: { const: 'submitted' },
625
+ cardId: { type: 'string' },
626
+ actionId: { type: 'string' },
627
+ values: {
628
+ type: 'object',
629
+ description: `Validated server-side against the card's actionFields `
630
+ + `(validateRuntimeCardFieldValues); <= ${VERB_LIMITS.cardValuesBytes} bytes, `
631
+ + `depth <= ${VERB_LIMITS.cardValuesDepth}.`,
632
+ },
633
+ respondedBy: {
634
+ type: 'string',
635
+ description: 'Canon-authenticated responder (server-verified vs responseUserId).',
636
+ },
637
+ },
638
+ },
639
+ {
640
+ type: 'object',
641
+ required: ['status', 'cardId'],
642
+ additionalProperties: true,
643
+ properties: {
644
+ status: { const: 'cancelled' },
645
+ cardId: { type: 'string' },
646
+ respondedBy: { type: 'string' },
647
+ },
648
+ },
649
+ {
650
+ type: 'object',
651
+ required: ['status', 'cardId'],
652
+ additionalProperties: true,
653
+ properties: { status: { const: 'timeout' }, cardId: { type: 'string' } },
654
+ },
655
+ ],
656
+ };
657
+ const share_contact_input = {
658
+ type: 'object',
659
+ description: "Share a contact card into a conversation (POST /messages/send, contentType "
660
+ + "'contact_card'). The shared user must be in the sending agent's contacts (403 "
661
+ + 'otherwise; the owner shortcut applies only to human senders sharing their own '
662
+ + 'agents). The server snapshots the card.',
663
+ required: ['conversationId', 'contactUserId'],
664
+ additionalProperties: false,
665
+ properties: {
666
+ conversationId: { type: 'string', pattern: VERB_ID_PATTERNS.runtimeId },
667
+ contactUserId: { type: 'string', minLength: 1 },
668
+ text: { type: 'string', maxLength: VERB_LIMITS.messageTextBytes },
669
+ messageId: REF('messageId'),
670
+ },
671
+ };
672
+ const share_contact_result = {
673
+ type: 'object',
674
+ required: ['status', 'messageId'],
675
+ additionalProperties: true,
676
+ properties: {
677
+ status: { const: 'shared' },
678
+ messageId: { type: 'string' },
679
+ },
680
+ };
681
+ const react_input = {
682
+ type: 'object',
683
+ description: 'Toggle an emoji reaction on a message (server-side atomic toggle; '
684
+ + 'functions/src/api/reactToMessage.ts).',
685
+ required: ['conversationId', 'messageId', 'emoji'],
686
+ additionalProperties: false,
687
+ properties: {
688
+ conversationId: { type: 'string', pattern: VERB_ID_PATTERNS.runtimeId },
689
+ messageId: { type: 'string', minLength: 1 },
690
+ emoji: {
691
+ type: 'string',
692
+ minLength: 1,
693
+ maxLength: VERB_LIMITS.reactionEmojiChars,
694
+ description: 'Reaction key. Content-adjacent (D5) — rides the wire body.',
695
+ },
696
+ },
697
+ };
698
+ const react_result = {
699
+ type: 'object',
700
+ required: ['status', 'action'],
701
+ additionalProperties: true,
702
+ properties: {
703
+ status: { const: 'reacted' },
704
+ action: { enum: ['added', 'removed'] },
705
+ reactions: { type: 'object' },
706
+ },
707
+ };
708
+ const forward_input = {
709
+ type: 'object',
710
+ description: 'Forward an existing message into another conversation. Under E2EE this '
711
+ + 'becomes client-side re-encrypt; only routing ids + an optional caption '
712
+ + 'cross the wire.',
713
+ required: ['sourceConversationId', 'targetConversationId', 'messageId'],
714
+ additionalProperties: false,
715
+ properties: {
716
+ sourceConversationId: { type: 'string', pattern: VERB_ID_PATTERNS.runtimeId },
717
+ targetConversationId: { type: 'string', pattern: VERB_ID_PATTERNS.runtimeId },
718
+ messageId: { type: 'string', minLength: 1 },
719
+ text: {
720
+ type: 'string',
721
+ maxLength: VERB_LIMITS.messageTextBytes,
722
+ description: `Optional caption; <= ${VERB_LIMITS.messageTextBytes} UTF-8 bytes.`,
723
+ },
724
+ },
725
+ };
726
+ const forward_result = {
727
+ type: 'object',
728
+ required: ['status', 'messageId', 'targetConversationId'],
729
+ additionalProperties: true,
730
+ properties: {
731
+ status: { const: 'forwarded' },
732
+ messageId: { type: 'string' },
733
+ targetConversationId: { type: 'string' },
734
+ },
735
+ };
736
+ const create_group_input = {
737
+ type: 'object',
738
+ description: "Create a group conversation. Each target's groupJoinPolicy is enforced "
739
+ + 'server-side with staged admission: directly-addable members join at '
740
+ + 'creation, approval-required members become pending group_invite '
741
+ + 'requests, policy-denied members are skipped (see the result). At '
742
+ + 'least one member must be directly addable — a creator-only group is '
743
+ + 'rejected with error code CREATE_GROUP_NO_ADDABLE_MEMBERS. v1 scoping: '
744
+ + 'coding-agent members that require explicit session setup are skipped '
745
+ + '(reason setup-required), and a creator that itself requires setup '
746
+ + 'cannot create groups (403 CREATE_GROUP_CREATOR_SETUP_REQUIRED); only '
747
+ + 'the owner can set such agents up, from the app. Under MLS, membership '
748
+ + 'changes are Add/Remove proposals + Commit — a group operation is a '
749
+ + 'cryptographic state change, not a codec swap.',
750
+ required: ['name', 'memberIds'],
751
+ additionalProperties: false,
752
+ properties: {
753
+ name: { type: 'string', minLength: 1, maxLength: VERB_LIMITS.groupNameChars },
754
+ memberIds: {
755
+ type: 'array',
756
+ minItems: 1,
757
+ maxItems: VERB_LIMITS.groupMembers - 1,
758
+ items: { type: 'string', minLength: 1 },
759
+ description: 'Other members; the caller is added automatically.',
760
+ },
761
+ },
762
+ };
763
+ const create_group_result = {
764
+ type: 'object',
765
+ description: 'Staged admission: the group exists with the directly-addable members; '
766
+ + '`pending` lists approval-required members whose group_invite awaits '
767
+ + 'their approver; `skipped` lists members whose policy denied the add '
768
+ + '(reasons include owner-only, blocked, not-found, setup-required).',
769
+ required: ['status', 'conversationId'],
770
+ additionalProperties: true,
771
+ properties: {
772
+ status: { const: 'created' },
773
+ conversationId: { type: 'string' },
774
+ pending: {
775
+ type: 'array',
776
+ items: {
777
+ type: 'object',
778
+ required: ['userId', 'requestId'],
779
+ additionalProperties: true,
780
+ properties: {
781
+ userId: { type: 'string' },
782
+ requestId: { type: 'string' },
783
+ },
784
+ },
785
+ },
786
+ skipped: {
787
+ type: 'array',
788
+ items: {
789
+ type: 'object',
790
+ required: ['userId', 'reason'],
791
+ additionalProperties: true,
792
+ properties: {
793
+ userId: { type: 'string' },
794
+ reason: { type: 'string' },
795
+ },
796
+ },
797
+ },
798
+ },
799
+ };
800
+ const add_member_input = {
801
+ type: 'object',
802
+ description: 'v1 scoping: a coding-agent target that requires explicit session setup '
803
+ + 'is rejected (only its owner can set it up, from the app). Under MLS '
804
+ + 'an add is an Add proposal + Commit (new epoch), not a server-side '
805
+ + 'membership write — the verb semantics are stable, the mechanism is '
806
+ + 'not.',
807
+ required: ['conversationId', 'userId'],
808
+ additionalProperties: false,
809
+ properties: {
810
+ conversationId: { type: 'string', pattern: VERB_ID_PATTERNS.runtimeId },
811
+ userId: { type: 'string', minLength: 1 },
812
+ },
813
+ };
814
+ const add_member_result = {
815
+ description: 'approval-required targets yield a pending group_invite contact request '
816
+ + 'routed to their approver.',
817
+ oneOf: [
818
+ {
819
+ type: 'object',
820
+ required: ['status'],
821
+ additionalProperties: true,
822
+ properties: { status: { const: 'added' } },
823
+ },
824
+ {
825
+ type: 'object',
826
+ required: ['status', 'requestId'],
827
+ additionalProperties: true,
828
+ properties: { status: { const: 'pending' }, requestId: { type: 'string' } },
829
+ },
830
+ ],
831
+ };
832
+ const remove_member_input = {
833
+ type: 'object',
834
+ description: 'Requester must be a group owner/admin (server-enforced). Under MLS a '
835
+ + 'removal is a Remove proposal + Commit that rotates the group secret '
836
+ + 'away from the removed leaf.',
837
+ required: ['conversationId', 'userId'],
838
+ additionalProperties: false,
839
+ properties: {
840
+ conversationId: { type: 'string', pattern: VERB_ID_PATTERNS.runtimeId },
841
+ userId: { type: 'string', minLength: 1 },
842
+ },
843
+ };
844
+ const remove_member_result = {
845
+ type: 'object',
846
+ required: ['status'],
847
+ additionalProperties: true,
848
+ properties: { status: { const: 'removed' } },
849
+ };
850
+ const leave_conversation_input = {
851
+ type: 'object',
852
+ required: ['conversationId'],
853
+ additionalProperties: false,
854
+ properties: {
855
+ conversationId: { type: 'string', pattern: VERB_ID_PATTERNS.runtimeId },
856
+ },
857
+ };
858
+ const leave_conversation_result = {
859
+ type: 'object',
860
+ required: ['status'],
861
+ additionalProperties: true,
862
+ properties: { status: { const: 'left' } },
863
+ };
864
+ const list_contacts_input = {
865
+ type: 'object',
866
+ additionalProperties: false,
867
+ properties: {},
868
+ };
869
+ const list_contacts_result = {
870
+ type: 'object',
871
+ required: ['contacts'],
872
+ additionalProperties: true,
873
+ properties: {
874
+ contacts: {
875
+ type: 'array',
876
+ items: {
877
+ type: 'object',
878
+ required: ['id', 'source', 'addedAt', 'displayNameOverride'],
879
+ additionalProperties: true,
880
+ properties: {
881
+ id: { type: 'string', description: "The contact's userId." },
882
+ source: {
883
+ type: 'string',
884
+ description: 'Vocabulary: direct_add | phone_book | contact_request | link | qr | group | '
885
+ + 'open_inbound_message | unknown (server passes strings through).',
886
+ },
887
+ addedAt: { type: ['string', 'null'] },
888
+ displayNameOverride: { type: ['string', 'null'] },
889
+ },
890
+ },
891
+ },
892
+ },
893
+ };
894
+ const list_contact_requests_input = {
895
+ type: 'object',
896
+ additionalProperties: false,
897
+ properties: {},
898
+ };
899
+ const list_contact_requests_result = {
900
+ type: 'object',
901
+ description: 'Pending inbound requests, newest first, capped at 100. Items are '
902
+ + 'SerializedContactRequest (backend-contracts contactRequest.ts). Read-only awareness: '
903
+ + "approval routes to the owner; agents cannot approve/reject.",
904
+ required: ['requests'],
905
+ additionalProperties: true,
906
+ properties: {
907
+ requests: {
908
+ type: 'array',
909
+ items: {
910
+ type: 'object',
911
+ required: ['id', 'requesterId', 'targetId', 'status', 'kind'],
912
+ additionalProperties: true,
913
+ properties: {
914
+ id: { type: 'string' },
915
+ requesterId: { type: 'string' },
916
+ requesterName: { type: 'string' },
917
+ targetId: { type: 'string' },
918
+ status: { enum: ['pending', 'approved', 'rejected', 'expired'] },
919
+ kind: { enum: ['dm', 'group_invite'] },
920
+ message: { type: ['string', 'null'] },
921
+ createdAt: { type: ['string', 'null'] },
922
+ expiresAt: { type: ['string', 'null'] },
923
+ },
924
+ },
925
+ },
926
+ },
927
+ };
928
+ const list_conversations_input = {
929
+ type: 'object',
930
+ additionalProperties: false,
931
+ properties: {
932
+ limit: {
933
+ type: 'integer',
934
+ minimum: 1,
935
+ description: 'Optional client-side cap applied by the binding; the REST endpoint has no '
936
+ + 'pagination and returns all visible memberships.',
937
+ },
938
+ },
939
+ };
940
+ const list_conversations_result = {
941
+ type: 'object',
942
+ required: ['conversations'],
943
+ additionalProperties: true,
944
+ properties: {
945
+ conversations: {
946
+ type: 'array',
947
+ items: {
948
+ type: 'object',
949
+ // topic/lastMessage/createdAt are ??-null'd by the serializer (always
950
+ // present); name is a raw passthrough and may be absent on direct chats.
951
+ required: ['id', 'type', 'memberIds', 'isAgentChat', 'topic', 'lastMessage', 'createdAt'],
952
+ additionalProperties: true,
953
+ properties: {
954
+ id: { type: 'string' },
955
+ type: { enum: ['direct', 'group'] },
956
+ name: { type: ['string', 'null'] },
957
+ topic: { type: ['string', 'null'] },
958
+ memberIds: { type: 'array', items: { type: 'string' } },
959
+ isAgentChat: { type: 'boolean' },
960
+ hasUnread: { type: 'boolean' },
961
+ lastMessage: {
962
+ type: ['object', 'null'],
963
+ additionalProperties: true,
964
+ properties: {
965
+ text: { type: ['string', 'null'] },
966
+ messageId: { type: 'string' },
967
+ senderId: { type: 'string' },
968
+ senderType: { type: 'string' },
969
+ contentType: { type: 'string' },
970
+ timestamp: { type: ['string', 'null'] },
971
+ },
972
+ },
973
+ createdAt: { type: ['string', 'null'] },
974
+ },
975
+ },
976
+ },
977
+ },
978
+ };
979
+ // ---------------------------------------------------------------------------
980
+ // Bundle
981
+ // ---------------------------------------------------------------------------
982
+ export const CANON_VERBS_JSON_SCHEMA = {
983
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
984
+ $id: CANON_VERBS_SCHEMA_ID,
985
+ title: 'Canon Agent Verbs v1',
986
+ description: 'Canonical contract for every deliberate agent action against Canon. Each verb has a '
987
+ + '<verb>_input and <verb>_result definition. Inputs are strict; results are open for '
988
+ + 'forward compatibility. Replies, streaming, typing, and read receipts are '
989
+ + 'deliberately NOT verbs — they stay host-mediated.',
990
+ $defs: {
991
+ messageId: messageIdDef,
992
+ selfContext: selfContextDef,
993
+ sessionSelection: sessionSelectionDef,
994
+ mediaAttachment: mediaAttachmentDef,
995
+ turnMetadata: turnMetadataDef,
996
+ messageOptions: messageOptionsDef,
997
+ inputChoice: inputChoiceDef,
998
+ inputQuestion: inputQuestionDef,
999
+ approvalDetail: approvalDetailDef,
1000
+ unifiedDiff: unifiedDiffDef,
1001
+ sessionRule: sessionRuleDef,
1002
+ runtimeCard: runtimeCardDef,
1003
+ native: nativeDef,
1004
+ send_to_input,
1005
+ send_to_result,
1006
+ request_input_input,
1007
+ request_input_result,
1008
+ request_approval_input,
1009
+ request_approval_result,
1010
+ check_approval_input,
1011
+ check_approval_result,
1012
+ send_card_input,
1013
+ send_card_result,
1014
+ request_card_input,
1015
+ request_card_result,
1016
+ share_contact_input,
1017
+ share_contact_result,
1018
+ react_input,
1019
+ react_result,
1020
+ forward_input,
1021
+ forward_result,
1022
+ create_group_input,
1023
+ create_group_result,
1024
+ add_member_input,
1025
+ add_member_result,
1026
+ remove_member_input,
1027
+ remove_member_result,
1028
+ leave_conversation_input,
1029
+ leave_conversation_result,
1030
+ list_contacts_input,
1031
+ list_contacts_result,
1032
+ list_contact_requests_input,
1033
+ list_contact_requests_result,
1034
+ list_conversations_input,
1035
+ list_conversations_result,
1036
+ },
1037
+ };
1038
+ export const CANON_VERB_SCHEMA_REFS = Object.fromEntries(CANON_VERB_NAMES.map((name) => [
1039
+ name,
1040
+ {
1041
+ input: `${CANON_VERBS_SCHEMA_ID}#/$defs/${name}_input`,
1042
+ result: `${CANON_VERBS_SCHEMA_ID}#/$defs/${name}_result`,
1043
+ },
1044
+ ]));
1045
+ function collectDefRefs(node, into) {
1046
+ if (Array.isArray(node)) {
1047
+ for (const item of node)
1048
+ collectDefRefs(item, into);
1049
+ return;
1050
+ }
1051
+ if (node && typeof node === 'object') {
1052
+ for (const [key, value] of Object.entries(node)) {
1053
+ if (key === '$ref' && typeof value === 'string' && value.startsWith('#/$defs/')) {
1054
+ into.add(value.slice('#/$defs/'.length));
1055
+ }
1056
+ else {
1057
+ collectDefRefs(value, into);
1058
+ }
1059
+ }
1060
+ }
1061
+ }
1062
+ const ALL_DEFS = CANON_VERBS_JSON_SCHEMA.$defs;
1063
+ /**
1064
+ * Make an extracted subschema self-contained: `#/$defs/...` refs resolve
1065
+ * against the EMBEDDING document's root, so the transitively referenced
1066
+ * shared definitions are attached to the extracted schema itself. Verb
1067
+ * subschemas embedded into tool definitions (MCP inputSchema, hermes tool
1068
+ * schemas, codex dynamicTools) stay valid standalone.
1069
+ */
1070
+ function selfContained(def) {
1071
+ const needed = new Set();
1072
+ collectDefRefs(def, needed);
1073
+ let previousSize = -1;
1074
+ while (previousSize !== needed.size) {
1075
+ previousSize = needed.size;
1076
+ for (const name of [...needed])
1077
+ collectDefRefs(ALL_DEFS[name], needed);
1078
+ }
1079
+ if (needed.size === 0)
1080
+ return { ...def };
1081
+ const $defs = {};
1082
+ for (const name of [...needed].sort())
1083
+ $defs[name] = ALL_DEFS[name];
1084
+ return { ...def, $defs };
1085
+ }
1086
+ const CARD_DEF_PREFIX = 'card__';
1087
+ function rebaseCardRefs(node, cardSchemaId) {
1088
+ if (Array.isArray(node))
1089
+ return node.map((item) => rebaseCardRefs(item, cardSchemaId));
1090
+ if (node && typeof node === 'object') {
1091
+ const out = {};
1092
+ for (const [key, value] of Object.entries(node)) {
1093
+ if (key === '$ref' && typeof value === 'string') {
1094
+ let ref = value;
1095
+ if (cardSchemaId && ref.startsWith(`${cardSchemaId}#`))
1096
+ ref = ref.slice(cardSchemaId.length);
1097
+ out[key] = ref.startsWith('#/$defs/')
1098
+ ? `#/$defs/${CARD_DEF_PREFIX}${ref.slice('#/$defs/'.length)}`
1099
+ : value;
1100
+ }
1101
+ else {
1102
+ out[key] = rebaseCardRefs(value, cardSchemaId);
1103
+ }
1104
+ }
1105
+ return out;
1106
+ }
1107
+ return node;
1108
+ }
1109
+ function composeCardSchema(verbDefs, cardSchema) {
1110
+ const { $schema: _dialect, $id, $defs: cardDefs, ...cardRoot } = cardSchema;
1111
+ const cardSchemaId = typeof $id === 'string' ? $id : undefined;
1112
+ const merged = {
1113
+ ...verbDefs,
1114
+ runtimeCard: rebaseCardRefs(cardRoot, cardSchemaId),
1115
+ };
1116
+ if (cardDefs && typeof cardDefs === 'object') {
1117
+ for (const [name, def] of Object.entries(cardDefs)) {
1118
+ const key = `${CARD_DEF_PREFIX}${name}`;
1119
+ if (key in verbDefs) {
1120
+ throw new Error(`card schema definition collides with a verb definition: ${key}`);
1121
+ }
1122
+ merged[key] = rebaseCardRefs(def, cardSchemaId);
1123
+ }
1124
+ }
1125
+ return merged;
1126
+ }
1127
+ /** Self-contained input schema for a verb (for embedding into tool definitions). */
1128
+ export function getVerbInputSchema(verb, options) {
1129
+ const schema = selfContained(ALL_DEFS[`${verb}_input`]);
1130
+ const defs = schema.$defs;
1131
+ if (options?.cardSchema && defs && 'runtimeCard' in defs) {
1132
+ schema.$defs = composeCardSchema(defs, options.cardSchema);
1133
+ }
1134
+ return schema;
1135
+ }
1136
+ /** Self-contained result schema for a verb. */
1137
+ export function getVerbResultSchema(verb) {
1138
+ return selfContained(ALL_DEFS[`${verb}_result`]);
1139
+ }