{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://canonmsg.com/schemas/canon.verbs.v1.json",
  "title": "Canon Agent Verbs v1",
  "description": "Canonical contract for every deliberate agent action against Canon. Each verb has a <verb>_input and <verb>_result definition. Inputs are strict; results are open for forward compatibility. Replies, streaming, typing, and read receipts are deliberately NOT verbs — they stay host-mediated.",
  "$defs": {
    "messageId": {
      "type": "string",
      "pattern": "^[A-Za-z0-9_.:-]{1,160}$",
      "not": {
        "pattern": "^(\\.{1,2}|__.*__)$",
        "description": "Reserved forms the server 400s: '.', '..', and __x__ ids."
      },
      "description": "Client idempotency key (<= 160 chars / 256 UTF-8 bytes; no slashes, control chars, '.', '..', __x__). Same id + identical payload replays idempotently; different payload -> 409 MESSAGE_ID_EXISTS."
    },
    "selfContext": {
      "type": "object",
      "description": "Private note-to-self attached to a cross-conversation send. Visible only to the sending agent (surfaced back as provenance.activeSelfContext); never shown to recipients. Enforced by functions/src/utils/selfContexts.ts.",
      "required": [
        "type",
        "context"
      ],
      "additionalProperties": false,
      "properties": {
        "type": {
          "const": "cross_session"
        },
        "context": {
          "type": "string",
          "minLength": 1,
          "maxLength": 1000,
          "description": "What future-you needs to know about this transfer."
        }
      }
    },
    "sessionSelection": {
      "description": "How to pick the conversation when targeting a user whose chats are session-shaped (agent targets). Canon default for user-targeted sends: continue_or_create.",
      "oneOf": [
        {
          "type": "object",
          "required": [
            "mode"
          ],
          "additionalProperties": false,
          "properties": {
            "mode": {
              "enum": [
                "new",
                "continue_latest",
                "continue_or_create"
              ]
            }
          }
        },
        {
          "type": "object",
          "required": [
            "mode",
            "conversationId"
          ],
          "additionalProperties": false,
          "properties": {
            "mode": {
              "const": "specific"
            },
            "conversationId": {
              "type": "string",
              "pattern": "^[A-Za-z0-9_.:-]{1,160}$"
            }
          }
        }
      ]
    },
    "mediaAttachment": {
      "type": "object",
      "required": [
        "kind",
        "url"
      ],
      "additionalProperties": false,
      "properties": {
        "kind": {
          "enum": [
            "image",
            "audio",
            "video",
            "file"
          ]
        },
        "url": {
          "type": "string",
          "minLength": 1,
          "description": "Must come from a canonical Canon upload/finalization response or the GIF picker — server allowlist, fail-closed (functions/src/utils/mediaUploadSafety.ts)."
        },
        "uploadId": {
          "type": "string",
          "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$",
          "description": "Server-issued resumable identity. Preserve it from finalization so the durable message transaction can validate and retain initially temporary media."
        },
        "mimeType": {
          "type": "string"
        },
        "fileName": {
          "type": "string"
        },
        "sizeBytes": {
          "type": "number",
          "minimum": 0
        },
        "width": {
          "type": "number",
          "minimum": 0
        },
        "height": {
          "type": "number",
          "minimum": 0
        },
        "durationMs": {
          "type": "number",
          "minimum": 0
        },
        "thumbnailUrl": {
          "type": "string",
          "minLength": 1
        },
        "processingStatus": {
          "enum": [
            "processing",
            "ready",
            "failed"
          ]
        },
        "processingErrorCode": {
          "enum": [
            "invalid_video",
            "video_limits_exceeded",
            "video_processing_failed"
          ]
        }
      }
    },
    "turnMetadata": {
      "type": "object",
      "description": "Free-form metadata envelope; JSON.stringify length <= 4096 UTF-16 code units (functions/src/api/sendMessage.ts checks string length, not bytes). Well-known turn-protocol keys below (authoritative type: turnProtocol.ts TurnMetadata, which also carries requestedTurnMode); receipt metadata.type values are server-owned and rejected.",
      "additionalProperties": true,
      "properties": {
        "turnId": {
          "type": [
            "string",
            "null"
          ]
        },
        "turnSemantics": {
          "enum": [
            "progress",
            "turn_complete",
            "control"
          ]
        },
        "deliveryIntent": {
          "enum": [
            "queue",
            "interrupt",
            "interleave",
            "stop"
          ],
          "description": "What the recipient runtime should do if the message lands mid-turn. Default queue."
        },
        "replyBehavior": {
          "enum": [
            "allow_auto_reply",
            "suppress_auto_reply"
          ]
        }
      }
    },
    "messageOptions": {
      "type": "object",
      "description": "Message composition options (mirrors POST /messages/send). Deliberately absent: contact_card contentType (use share_contact) and forwarded/forwardedFrom (forwarding has its own verb — use forward, not send_to).",
      "additionalProperties": false,
      "properties": {
        "messageId": {
          "$ref": "#/$defs/messageId"
        },
        "contentType": {
          "enum": [
            "text",
            "image",
            "audio",
            "video",
            "file"
          ],
          "description": "Derived from attachments when omitted. contact_card is deliberately excluded here — use the share_contact verb."
        },
        "attachments": {
          "type": "array",
          "maxItems": 10,
          "items": {
            "$ref": "#/$defs/mediaAttachment"
          }
        },
        "mentions": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Member userIds; every id must be a conversation member (400 otherwise)."
        },
        "replyTo": {
          "type": "string",
          "description": "Message id being replied to (not existence-validated)."
        },
        "replyToPosition": {
          "type": "integer"
        },
        "metadata": {
          "$ref": "#/$defs/turnMetadata"
        }
      }
    },
    "inputChoice": {
      "type": "object",
      "required": [
        "label"
      ],
      "additionalProperties": false,
      "properties": {
        "label": {
          "type": "string",
          "minLength": 1,
          "maxLength": 120
        },
        "value": {
          "type": "string",
          "maxLength": 200
        },
        "description": {
          "type": "string",
          "maxLength": 300
        }
      }
    },
    "inputQuestion": {
      "type": "object",
      "required": [
        "id",
        "question"
      ],
      "additionalProperties": false,
      "properties": {
        "id": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,120}$"
        },
        "question": {
          "type": "string",
          "minLength": 1,
          "maxLength": 1000
        },
        "header": {
          "type": "string",
          "maxLength": 120
        },
        "choices": {
          "type": "array",
          "maxItems": 12,
          "items": {
            "$ref": "#/$defs/inputChoice"
          }
        },
        "allowOther": {
          "type": "boolean"
        },
        "isSecret": {
          "type": "boolean",
          "description": "Forces owner-only routing for the whole request."
        },
        "multiSelect": {
          "type": "boolean"
        }
      }
    },
    "approvalDetail": {
      "type": "object",
      "required": [
        "label",
        "value"
      ],
      "additionalProperties": false,
      "properties": {
        "label": {
          "type": "string",
          "minLength": 1,
          "maxLength": 80
        },
        "value": {
          "type": "string",
          "minLength": 1,
          "maxLength": 500
        },
        "monospace": {
          "type": "boolean"
        }
      }
    },
    "unifiedDiff": {
      "type": "object",
      "description": "Unified diff for file-change approvals. Server sanitizes: sensitive paths suppressed, secret tokens redacted, per-file clip 24576 bytes, total 98304 bytes (functions/src/api/interactionApproval.ts; primitives in backend-contracts diffRedaction.ts).",
      "required": [
        "files"
      ],
      "additionalProperties": false,
      "properties": {
        "files": {
          "type": "array",
          "maxItems": 100,
          "items": {
            "type": "object",
            "required": [
              "path",
              "status"
            ],
            "additionalProperties": false,
            "properties": {
              "path": {
                "type": "string",
                "minLength": 1,
                "maxLength": 1024
              },
              "status": {
                "enum": [
                  "modified",
                  "created",
                  "deleted",
                  "renamed"
                ]
              },
              "oldPath": {
                "type": "string",
                "maxLength": 1024
              },
              "additions": {
                "type": "integer",
                "minimum": 0
              },
              "deletions": {
                "type": "integer",
                "minimum": 0
              },
              "diff": {
                "type": "string"
              },
              "suppressed": {
                "type": "boolean"
              }
            }
          }
        },
        "truncated": {
          "type": "boolean"
        }
      }
    },
    "sessionRule": {
      "type": "object",
      "description": "Standing approval rule. Owner-only: the server forces allowSessionRule=false for non-owner responders.",
      "required": [
        "type"
      ],
      "additionalProperties": false,
      "properties": {
        "type": {
          "enum": [
            "approve-all",
            "approve-tool",
            "deny-tool"
          ]
        },
        "toolPattern": {
          "type": "string",
          "pattern": "^[\\w.*:-]{1,128}$"
        },
        "expiresAt": {
          "type": [
            "string",
            "null"
          ],
          "description": "ISO timestamp or null."
        }
      },
      "if": {
        "properties": {
          "type": {
            "enum": [
              "approve-tool",
              "deny-tool"
            ]
          }
        },
        "required": [
          "type"
        ]
      },
      "then": {
        "required": [
          "toolPattern"
        ],
        "properties": {
          "toolPattern": true
        }
      }
    },
    "runtimeCard": {
      "type": "object",
      "description": "ENVELOPE-ONLY validation of a canon.card.v1 document: block structures are NOT checked here. Tool bindings MUST compose the full document schema via getVerbInputSchema(verb, { cardSchema: RUNTIME_CARD_JSON_SCHEMA_V1 }) so models cannot produce envelope-valid cards the platform rejects. Full document contract: @canonmsg/rich-cards RUNTIME_CARD_JSON_SCHEMA_V1 ($id https://canonmsg.com/schemas/canon.card.v1.json) — authoring caps title 120 / fallbackText 500 / blocks 24. Server envelope acceptance (interactionCard.ts): <= 32768 bytes serialized, title <= 200, fallbackText <= 2000, blocks <= 64; no secret-bearing top-level keys.",
      "required": [
        "schema",
        "title",
        "fallbackText",
        "blocks"
      ],
      "additionalProperties": true,
      "properties": {
        "schema": {
          "const": "canon.card.v1"
        },
        "cardId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,80}$"
        },
        "title": {
          "type": "string",
          "minLength": 1,
          "maxLength": 200
        },
        "fallbackText": {
          "type": "string",
          "minLength": 1,
          "maxLength": 2000
        },
        "blocks": {
          "type": "array",
          "minItems": 1,
          "maxItems": 64
        }
      }
    },
    "native": {
      "type": "object",
      "description": "Content-free runtime correlation ids. Commands, paths, prompts, summaries, and model labels are not permitted in this public envelope.",
      "maxProperties": 24,
      "additionalProperties": false,
      "properties": {
        "runtime": {
          "type": "string",
          "pattern": "^[A-Za-z0-9@_][A-Za-z0-9_.:@+\\-]{0,255}$",
          "maxLength": 256
        },
        "method": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_][A-Za-z0-9_./:\\-]{0,255}$",
          "maxLength": 256
        },
        "requestId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9@_][A-Za-z0-9_.:@+\\-]{0,255}$",
          "maxLength": 256
        },
        "provider": {
          "type": "string",
          "pattern": "^[A-Za-z0-9@_][A-Za-z0-9_.:@+\\-]{0,255}$",
          "maxLength": 256
        },
        "origin": {
          "type": "string",
          "pattern": "^[A-Za-z0-9@_][A-Za-z0-9_.:@+\\-]{0,255}$",
          "maxLength": 256
        },
        "surface": {
          "type": "string",
          "pattern": "^[A-Za-z0-9@_][A-Za-z0-9_.:@+\\-]{0,255}$",
          "maxLength": 256
        },
        "threadId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9@_][A-Za-z0-9_.:@+\\-]{0,255}$",
          "maxLength": 256
        },
        "turnId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9@_][A-Za-z0-9_.:@+\\-]{0,255}$",
          "maxLength": 256
        },
        "runId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9@_][A-Za-z0-9_.:@+\\-]{0,255}$",
          "maxLength": 256
        },
        "itemId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9@_][A-Za-z0-9_.:@+\\-]{0,255}$",
          "maxLength": 256
        },
        "toolCallId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9@_][A-Za-z0-9_.:@+\\-]{0,255}$",
          "maxLength": 256
        },
        "approvalId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9@_][A-Za-z0-9_.:@+\\-]{0,255}$",
          "maxLength": 256
        },
        "pluginId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9@_][A-Za-z0-9_.:@+\\-]{0,255}$",
          "maxLength": 256
        },
        "sessionKey": {
          "type": "string",
          "pattern": "^[A-Za-z0-9@_][A-Za-z0-9_.:@+\\-]{0,255}$",
          "maxLength": 256
        },
        "nodeId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9@_][A-Za-z0-9_.:@+\\-]{0,255}$",
          "maxLength": 256
        },
        "handles": {
          "type": "object",
          "maxProperties": 16,
          "propertyNames": {
            "pattern": "^[A-Za-z0-9_.:-]{1,80}$"
          },
          "additionalProperties": {
            "type": "string",
            "pattern": "^[A-Za-z0-9@_][A-Za-z0-9_.:@+\\-]{0,255}$",
            "maxLength": 256
          }
        }
      }
    },
    "send_to_input": {
      "type": "object",
      "description": "Message another conversation or user (admission-aware), optionally carrying a private self-context. Exactly one of targetConversationId / targetUserId / canonContactId. canonContactId is NOT a wire field: bindings resolve it via POST /admission/resolve to a targetUserId first. Projections: selfContext sends -> POST /messages/send-contextual (which requires sourceConversationId + selfContext); known-conversation sends without selfContext -> POST /messages/send; plain user sends -> admission resolve + create + send (the core reachOut composite). User targets resolve admission first: open targets get the message, approval-required targets get a contact request (send deferred and auto-fulfilled on approval), closed targets surface as status 'unavailable' with reason closed.",
      "additionalProperties": false,
      "properties": {
        "targetConversationId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,160}$"
        },
        "targetUserId": {
          "type": "string",
          "minLength": 1
        },
        "canonContactId": {
          "type": "string",
          "minLength": 1
        },
        "text": {
          "type": "string",
          "maxLength": 4096,
          "description": "<= 4096 UTF-8 bytes. Required unless messageOptions.attachments carries the content."
        },
        "sourceConversationId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,160}$",
          "description": "Required when selfContext is present; the agent must be a member."
        },
        "selfContext": {
          "$ref": "#/$defs/selfContext"
        },
        "requestMessage": {
          "type": "string",
          "maxLength": 500,
          "description": "Contact-request note when admission requires approval; defaults to text; senders truncate to 500 chars."
        },
        "sessionSelection": {
          "$ref": "#/$defs/sessionSelection"
        },
        "messageOptions": {
          "$ref": "#/$defs/messageOptions"
        }
      },
      "oneOf": [
        {
          "required": [
            "targetConversationId"
          ],
          "properties": {
            "targetConversationId": true
          }
        },
        {
          "required": [
            "targetUserId"
          ],
          "properties": {
            "targetUserId": true
          }
        },
        {
          "required": [
            "canonContactId"
          ],
          "properties": {
            "canonContactId": true
          }
        }
      ],
      "dependentRequired": {
        "selfContext": [
          "sourceConversationId"
        ]
      }
    },
    "send_to_result": {
      "description": "Canonical outcome vocabulary. Legacy binding drift documented in docs/design/verb-bindings.md (hermes 'opened' -> messaged without messageId; hermes 'denied' -> blocked).",
      "oneOf": [
        {
          "type": "object",
          "required": [
            "status",
            "conversationId"
          ],
          "additionalProperties": true,
          "properties": {
            "status": {
              "const": "messaged"
            },
            "conversationId": {
              "type": "string"
            },
            "messageId": {
              "type": "string"
            },
            "selfContextId": {
              "type": "string"
            },
            "created": {
              "type": "boolean"
            },
            "reused": {
              "type": "boolean"
            },
            "sessionSelection": {
              "type": "string"
            }
          }
        },
        {
          "type": "object",
          "required": [
            "status",
            "requestId"
          ],
          "additionalProperties": true,
          "properties": {
            "status": {
              "enum": [
                "requested",
                "pending"
              ]
            },
            "requestId": {
              "type": [
                "string",
                "null"
              ]
            },
            "deferredIntentId": {
              "type": [
                "string",
                "null"
              ],
              "description": "Present when the send is deferred until the contact request resolves."
            }
          }
        },
        {
          "type": "object",
          "required": [
            "status",
            "reason"
          ],
          "additionalProperties": true,
          "properties": {
            "status": {
              "enum": [
                "setup_required",
                "no_session",
                "blocked",
                "unavailable"
              ]
            },
            "reason": {
              "type": "string"
            }
          }
        }
      ]
    },
    "request_input_input": {
      "type": "object",
      "description": "Ask a human a structured question mid-turn (HITL input card). sudo/secret kinds, sensitive:true, or any isSecret question force owner-only routing server-side; bindings must not let the model redirect the responder for those. Ceiling 30 minutes.",
      "additionalProperties": false,
      "properties": {
        "conversationId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,160}$",
          "description": "Bindings default to the active conversation."
        },
        "inputId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,160}$",
          "description": "Durable single-use id (reuse -> 409); generated when omitted."
        },
        "kind": {
          "enum": [
            "clarify",
            "sudo",
            "secret"
          ],
          "default": "clarify"
        },
        "title": {
          "type": "string",
          "maxLength": 160
        },
        "prompt": {
          "type": "string",
          "maxLength": 4000
        },
        "choices": {
          "type": "array",
          "maxItems": 12,
          "items": {
            "$ref": "#/$defs/inputChoice"
          }
        },
        "questions": {
          "type": "array",
          "maxItems": 12,
          "items": {
            "$ref": "#/$defs/inputQuestion"
          }
        },
        "secretName": {
          "type": "string",
          "maxLength": 160
        },
        "sensitive": {
          "type": "boolean"
        },
        "responseUserId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,160}$",
          "description": "Must be a human conversation member. Trust-critical bindings (hermes) derive this from turn provenance and ignore model-supplied values."
        },
        "native": {
          "$ref": "#/$defs/native"
        },
        "turnId": {
          "type": "string",
          "maxLength": 128
        },
        "timeoutMs": {
          "type": "integer",
          "minimum": 1000,
          "maximum": 1800000,
          "description": "Relative deadline in ms (server ceiling 30 minutes). expiresAt wins when both are given."
        },
        "expiresAt": {
          "type": "integer",
          "minimum": 0,
          "description": "Absolute epoch-ms deadline. The REST interaction creators REQUIRE a deadline (input interactions also require input subtype kind): bindings must fill defaults and convert timeoutMs into expiresAt before calling the endpoint."
        }
      }
    },
    "request_input_result": {
      "oneOf": [
        {
          "type": "object",
          "required": [
            "status",
            "inputId",
            "value"
          ],
          "additionalProperties": true,
          "properties": {
            "status": {
              "const": "submitted"
            },
            "inputId": {
              "type": "string"
            },
            "value": {
              "type": "string",
              "description": "Free-text answer ('' when structured answers were used)."
            },
            "answers": {
              "type": "object",
              "description": "Structured answers keyed by question id.",
              "additionalProperties": {
                "type": "object",
                "required": [
                  "answers"
                ],
                "properties": {
                  "answers": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        {
          "type": "object",
          "required": [
            "status",
            "inputId"
          ],
          "additionalProperties": true,
          "properties": {
            "status": {
              "enum": [
                "cancelled",
                "timeout"
              ]
            },
            "inputId": {
              "type": "string"
            }
          }
        }
      ]
    },
    "request_approval_input": {
      "type": "object",
      "description": "Ask a human to allow/deny an action. mode 'blocking' (default) waits inside the verb call; mode 'detached' returns pending immediately — fetch the decision later with check_approval. Approval deadline ceiling is 72 hours (other kinds: 30 minutes). Timeouts fail closed to deny.",
      "required": [
        "toolName",
        "toolSummary"
      ],
      "additionalProperties": false,
      "properties": {
        "conversationId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,160}$"
        },
        "approvalId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,160}$",
          "description": "Durable single-use id; server-generated when omitted."
        },
        "toolName": {
          "type": "string",
          "minLength": 1,
          "maxLength": 128
        },
        "toolSummary": {
          "type": "string",
          "minLength": 1,
          "maxLength": 1000,
          "description": "Pre-redact secrets — this renders to humans."
        },
        "mode": {
          "enum": [
            "blocking",
            "detached"
          ],
          "default": "blocking"
        },
        "riskLevel": {
          "enum": [
            "normal",
            "destructive"
          ],
          "description": "Server accepts only these values; others are silently dropped, not rejected."
        },
        "risk": {
          "enum": [
            "low",
            "normal",
            "high",
            "destructive"
          ],
          "description": "Advisory; server stores any string <= 64 chars today."
        },
        "category": {
          "enum": [
            "command",
            "file",
            "network",
            "browser",
            "mcp",
            "plugin",
            "canon",
            "tool"
          ],
          "description": "Advisory; server stores any string <= 64 chars today."
        },
        "details": {
          "type": "array",
          "maxItems": 8,
          "items": {
            "$ref": "#/$defs/approvalDetail"
          }
        },
        "diff": {
          "$ref": "#/$defs/unifiedDiff"
        },
        "native": {
          "$ref": "#/$defs/native"
        },
        "runtimeId": {
          "type": "string",
          "maxLength": 128
        },
        "turnId": {
          "type": "string",
          "maxLength": 128
        },
        "responseUserId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,160}$"
        },
        "allowSessionRule": {
          "type": "boolean",
          "description": "Server forces false when the responder is not the agent owner."
        },
        "timeoutMs": {
          "type": "integer",
          "minimum": 1000,
          "maximum": 259200000,
          "description": "Relative deadline in ms (server ceiling 72 hours). expiresAt wins when both are given."
        },
        "expiresAt": {
          "type": "integer",
          "minimum": 0,
          "description": "Absolute epoch-ms deadline. The REST interaction creators REQUIRE a deadline (input interactions also require input subtype kind): bindings must fill defaults and convert timeoutMs into expiresAt before calling the endpoint."
        }
      }
    },
    "request_approval_result": {
      "oneOf": [
        {
          "type": "object",
          "required": [
            "status",
            "approvalId"
          ],
          "additionalProperties": true,
          "properties": {
            "status": {
              "const": "allow"
            },
            "approvalId": {
              "type": "string"
            },
            "sessionRule": {
              "$ref": "#/$defs/sessionRule"
            },
            "respondedBy": {
              "type": "string",
              "description": "Canon-authenticated responder userId."
            }
          }
        },
        {
          "type": "object",
          "required": [
            "status",
            "approvalId"
          ],
          "additionalProperties": true,
          "properties": {
            "status": {
              "const": "deny"
            },
            "approvalId": {
              "type": "string"
            },
            "sessionRule": {
              "$ref": "#/$defs/sessionRule"
            },
            "respondedBy": {
              "type": "string"
            }
          }
        },
        {
          "type": "object",
          "required": [
            "status",
            "approvalId"
          ],
          "additionalProperties": true,
          "properties": {
            "status": {
              "const": "timeout"
            },
            "approvalId": {
              "type": "string"
            }
          }
        },
        {
          "type": "object",
          "description": "Detached acceptance — the decision arrives via check_approval.",
          "required": [
            "status",
            "approvalId",
            "expiresAt"
          ],
          "additionalProperties": true,
          "properties": {
            "status": {
              "const": "pending"
            },
            "approvalId": {
              "type": "string"
            },
            "conversationId": {
              "type": "string"
            },
            "expiresAt": {
              "type": "integer"
            },
            "responseUserId": {
              "type": "string"
            }
          }
        }
      ]
    },
    "check_approval_input": {
      "type": "object",
      "description": "Fetch the decision of a detached approval. Consume is idempotent within the replay window: a resolved decision replays for up to 72h after first consume, then the id becomes unknown. Bindings must bind the check to the conversation that created the approval.",
      "required": [
        "approvalId"
      ],
      "additionalProperties": false,
      "properties": {
        "approvalId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,160}$"
        },
        "conversationId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,160}$"
        }
      }
    },
    "check_approval_result": {
      "description": "'unknown' is NOT a denial — only 'resolved' carries a decision. Canonical statuses map from the approval interaction consume result as: allow/deny -> resolved.decision, timeout -> expired, pending -> pending, HTTP 404 RUNTIME_APPROVAL_NOT_FOUND -> unknown.",
      "oneOf": [
        {
          "type": "object",
          "required": [
            "status",
            "approvalId",
            "decision"
          ],
          "additionalProperties": true,
          "properties": {
            "status": {
              "const": "resolved"
            },
            "approvalId": {
              "type": "string"
            },
            "decision": {
              "enum": [
                "allow",
                "deny"
              ]
            },
            "respondedBy": {
              "type": "string"
            },
            "conversationId": {
              "type": "string"
            }
          }
        },
        {
          "type": "object",
          "required": [
            "status",
            "approvalId"
          ],
          "additionalProperties": true,
          "properties": {
            "status": {
              "enum": [
                "pending",
                "expired",
                "unknown"
              ]
            },
            "approvalId": {
              "type": "string"
            },
            "expiresAt": {
              "type": "integer"
            },
            "conversationId": {
              "type": "string"
            }
          }
        }
      ]
    },
    "send_card_input": {
      "type": "object",
      "description": "Display a card with no actions — fire-and-forget, no pending state, no deadline.",
      "required": [
        "card"
      ],
      "additionalProperties": false,
      "properties": {
        "conversationId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,160}$"
        },
        "card": {
          "$ref": "#/$defs/runtimeCard"
        },
        "cardId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,80}$"
        },
        "native": {
          "$ref": "#/$defs/native"
        },
        "runtimeId": {
          "type": "string",
          "maxLength": 128
        },
        "turnId": {
          "type": "string",
          "maxLength": 128
        }
      }
    },
    "send_card_result": {
      "type": "object",
      "required": [
        "status",
        "cardId"
      ],
      "additionalProperties": true,
      "properties": {
        "status": {
          "const": "displayed"
        },
        "cardId": {
          "type": "string"
        },
        "conversationId": {
          "type": "string"
        },
        "responseUserId": {
          "type": "string"
        }
      }
    },
    "request_card_input": {
      "type": "object",
      "description": "Show an interactive card (>= 1 actions block required) and wait for the response. Deadline ceiling 30 minutes.",
      "required": [
        "card"
      ],
      "additionalProperties": false,
      "properties": {
        "conversationId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,160}$"
        },
        "card": {
          "$ref": "#/$defs/runtimeCard"
        },
        "cardId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,80}$"
        },
        "responseUserId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,160}$",
          "description": "Must be a human conversation member; defaults to the turn-triggering human (trust-critical bindings ignore model-supplied values)."
        },
        "native": {
          "$ref": "#/$defs/native"
        },
        "runtimeId": {
          "type": "string",
          "maxLength": 128
        },
        "turnId": {
          "type": "string",
          "maxLength": 128
        },
        "timeoutMs": {
          "type": "integer",
          "minimum": 1000,
          "maximum": 1800000,
          "description": "Relative deadline in ms (server ceiling 30 minutes). expiresAt wins when both are given."
        },
        "expiresAt": {
          "type": "integer",
          "minimum": 0,
          "description": "Absolute epoch-ms deadline. The REST interaction creators REQUIRE a deadline (input interactions also require input subtype kind): bindings must fill defaults and convert timeoutMs into expiresAt before calling the endpoint."
        }
      }
    },
    "request_card_result": {
      "oneOf": [
        {
          "type": "object",
          "required": [
            "status",
            "cardId"
          ],
          "additionalProperties": true,
          "properties": {
            "status": {
              "const": "submitted"
            },
            "cardId": {
              "type": "string"
            },
            "actionId": {
              "type": "string"
            },
            "values": {
              "type": "object",
              "description": "Validated server-side against the card's actionFields (validateRuntimeCardFieldValues); <= 8192 bytes, depth <= 8."
            },
            "respondedBy": {
              "type": "string",
              "description": "Canon-authenticated responder (server-verified vs responseUserId)."
            }
          }
        },
        {
          "type": "object",
          "required": [
            "status",
            "cardId"
          ],
          "additionalProperties": true,
          "properties": {
            "status": {
              "const": "cancelled"
            },
            "cardId": {
              "type": "string"
            },
            "respondedBy": {
              "type": "string"
            }
          }
        },
        {
          "type": "object",
          "required": [
            "status",
            "cardId"
          ],
          "additionalProperties": true,
          "properties": {
            "status": {
              "const": "timeout"
            },
            "cardId": {
              "type": "string"
            }
          }
        }
      ]
    },
    "share_contact_input": {
      "type": "object",
      "description": "Share a contact card into a conversation (POST /messages/send, contentType 'contact_card'). The shared user must be in the sending agent's contacts (403 otherwise; the owner shortcut applies only to human senders sharing their own agents). The server snapshots the card.",
      "required": [
        "conversationId",
        "contactUserId"
      ],
      "additionalProperties": false,
      "properties": {
        "conversationId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,160}$"
        },
        "contactUserId": {
          "type": "string",
          "minLength": 1
        },
        "text": {
          "type": "string",
          "maxLength": 4096
        },
        "messageId": {
          "$ref": "#/$defs/messageId"
        }
      }
    },
    "share_contact_result": {
      "type": "object",
      "required": [
        "status",
        "messageId"
      ],
      "additionalProperties": true,
      "properties": {
        "status": {
          "const": "shared"
        },
        "messageId": {
          "type": "string"
        }
      }
    },
    "react_input": {
      "type": "object",
      "description": "Toggle an emoji reaction on a message (server-side atomic toggle; functions/src/api/reactToMessage.ts).",
      "required": [
        "conversationId",
        "messageId",
        "emoji"
      ],
      "additionalProperties": false,
      "properties": {
        "conversationId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,160}$"
        },
        "messageId": {
          "type": "string",
          "minLength": 1
        },
        "emoji": {
          "type": "string",
          "minLength": 1,
          "maxLength": 64,
          "description": "Reaction key. Content-adjacent (D5) — rides the wire body."
        }
      }
    },
    "react_result": {
      "type": "object",
      "required": [
        "status",
        "action"
      ],
      "additionalProperties": true,
      "properties": {
        "status": {
          "const": "reacted"
        },
        "action": {
          "enum": [
            "added",
            "removed"
          ]
        },
        "reactions": {
          "type": "object"
        }
      }
    },
    "forward_input": {
      "type": "object",
      "description": "Forward an existing message into another conversation. Under E2EE this becomes client-side re-encrypt; only routing ids + an optional caption cross the wire.",
      "required": [
        "sourceConversationId",
        "targetConversationId",
        "messageId"
      ],
      "additionalProperties": false,
      "properties": {
        "sourceConversationId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,160}$"
        },
        "targetConversationId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,160}$"
        },
        "messageId": {
          "type": "string",
          "minLength": 1
        },
        "text": {
          "type": "string",
          "maxLength": 4096,
          "description": "Optional caption; <= 4096 UTF-8 bytes."
        }
      }
    },
    "forward_result": {
      "type": "object",
      "required": [
        "status",
        "messageId",
        "targetConversationId"
      ],
      "additionalProperties": true,
      "properties": {
        "status": {
          "const": "forwarded"
        },
        "messageId": {
          "type": "string"
        },
        "targetConversationId": {
          "type": "string"
        }
      }
    },
    "create_group_input": {
      "type": "object",
      "description": "Create a group conversation. Each target's groupJoinPolicy is enforced server-side with staged admission: directly-addable members join at creation, approval-required members become pending group_invite requests; hard-denied members are skipped (see the result). A creator-only group is valid when at least one requested member has a pending invite. Under MLS, membership changes are Add/Remove proposals + Commit — a group operation is a cryptographic state change, not a codec swap.",
      "required": [
        "memberIds"
      ],
      "additionalProperties": false,
      "properties": {
        "name": {
          "type": "string",
          "minLength": 1,
          "maxLength": 100
        },
        "memberIds": {
          "type": "array",
          "minItems": 1,
          "maxItems": 49,
          "items": {
            "type": "string",
            "minLength": 1
          },
          "description": "Other members; the caller is added automatically."
        }
      }
    },
    "create_group_result": {
      "type": "object",
      "description": "Staged admission: the group exists with the directly-added members; `pending` lists group_invite requests awaiting policy approval; `skipped` lists hard-denied targets.",
      "required": [
        "status",
        "conversationId",
        "added",
        "pending",
        "skipped"
      ],
      "additionalProperties": true,
      "properties": {
        "status": {
          "const": "created"
        },
        "conversationId": {
          "type": "string"
        },
        "added": {
          "type": "array",
          "items": {
            "type": "string"
          }
        },
        "pending": {
          "type": "array",
          "items": {
            "type": "object",
            "required": [
              "userId",
              "requestId"
            ],
            "additionalProperties": true,
            "properties": {
              "userId": {
                "type": "string"
              },
              "requestId": {
                "type": "string"
              }
            }
          }
        },
        "skipped": {
          "type": "array",
          "items": {
            "type": "object",
            "required": [
              "userId",
              "reason"
            ],
            "additionalProperties": true,
            "properties": {
              "userId": {
                "type": "string"
              },
              "reason": {
                "type": "string"
              }
            }
          }
        }
      }
    },
    "add_member_input": {
      "type": "object",
      "description": "A target needing policy approval yields one pending group_invite. Under MLS an add is an Add proposal + Commit (new epoch), not a server-side membership write — the verb semantics are stable, the mechanism is not.",
      "required": [
        "conversationId",
        "userId"
      ],
      "additionalProperties": false,
      "properties": {
        "conversationId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,160}$"
        },
        "userId": {
          "type": "string",
          "minLength": 1
        }
      }
    },
    "add_member_result": {
      "description": "Targets needing policy approval yield an exact pending group invite.",
      "oneOf": [
        {
          "type": "object",
          "required": [
            "status"
          ],
          "additionalProperties": true,
          "properties": {
            "status": {
              "const": "added"
            }
          }
        },
        {
          "type": "object",
          "required": [
            "status",
            "requestId"
          ],
          "additionalProperties": true,
          "properties": {
            "status": {
              "const": "pending"
            },
            "requestId": {
              "type": "string"
            }
          }
        }
      ]
    },
    "remove_member_input": {
      "type": "object",
      "description": "Requester must be a group owner/admin (server-enforced). Under MLS a removal is a Remove proposal + Commit that rotates the group secret away from the removed leaf.",
      "required": [
        "conversationId",
        "userId"
      ],
      "additionalProperties": false,
      "properties": {
        "conversationId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,160}$"
        },
        "userId": {
          "type": "string",
          "minLength": 1
        }
      }
    },
    "remove_member_result": {
      "type": "object",
      "required": [
        "status"
      ],
      "additionalProperties": true,
      "properties": {
        "status": {
          "const": "removed"
        }
      }
    },
    "leave_conversation_input": {
      "type": "object",
      "required": [
        "conversationId"
      ],
      "additionalProperties": false,
      "properties": {
        "conversationId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,160}$"
        }
      }
    },
    "leave_conversation_result": {
      "type": "object",
      "required": [
        "status"
      ],
      "additionalProperties": true,
      "properties": {
        "status": {
          "const": "left"
        }
      }
    },
    "list_contacts_input": {
      "type": "object",
      "additionalProperties": false,
      "properties": {}
    },
    "list_contacts_result": {
      "type": "object",
      "required": [
        "contacts"
      ],
      "additionalProperties": true,
      "properties": {
        "contacts": {
          "type": "array",
          "items": {
            "type": "object",
            "required": [
              "id",
              "source",
              "addedAt",
              "displayNameOverride"
            ],
            "additionalProperties": true,
            "properties": {
              "id": {
                "type": "string",
                "description": "The contact's userId."
              },
              "source": {
                "type": "string",
                "description": "Vocabulary: direct_add | phone_book | contact_request | link | qr | group | open_inbound_message | unknown (server passes strings through)."
              },
              "addedAt": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "displayNameOverride": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          }
        }
      }
    },
    "list_conversations_input": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "limit": {
          "type": "integer",
          "minimum": 1,
          "description": "Optional client-side cap applied by the binding; the REST endpoint has no pagination and returns all visible memberships."
        }
      }
    },
    "list_conversations_result": {
      "type": "object",
      "required": [
        "conversations"
      ],
      "additionalProperties": true,
      "properties": {
        "conversations": {
          "type": "array",
          "items": {
            "type": "object",
            "required": [
              "id",
              "type",
              "memberIds",
              "isAgentChat",
              "topic",
              "lastMessage",
              "createdAt"
            ],
            "additionalProperties": true,
            "properties": {
              "id": {
                "type": "string"
              },
              "type": {
                "enum": [
                  "direct",
                  "group"
                ]
              },
              "name": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "topic": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "memberIds": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "isAgentChat": {
                "type": "boolean"
              },
              "hasUnread": {
                "type": "boolean"
              },
              "lastMessage": {
                "type": [
                  "object",
                  "null"
                ],
                "additionalProperties": true,
                "properties": {
                  "text": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "messageId": {
                    "type": "string"
                  },
                  "senderId": {
                    "type": "string"
                  },
                  "senderType": {
                    "type": "string"
                  },
                  "contentType": {
                    "type": "string"
                  },
                  "timestamp": {
                    "type": [
                      "string",
                      "null"
                    ]
                  }
                }
              },
              "createdAt": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          }
        }
      }
    },
    "no_reply_input": {
      "type": "object",
      "description": "End this turn without posting anything to the conversation. Nothing is rendered and no other member or agent is triggered.",
      "additionalProperties": false,
      "properties": {
        "conversationId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,160}$"
        },
        "messageId": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.:-]{1,160}$",
          "description": "The triggering message this silence answers, so the record cannot be misread against a later message."
        },
        "reason": {
          "type": "string",
          "maxLength": 500,
          "description": "Never rendered; logged only."
        }
      }
    },
    "no_reply_result": {
      "type": "object",
      "required": [
        "status"
      ],
      "additionalProperties": true,
      "properties": {
        "status": {
          "const": "acknowledged"
        },
        "conversationId": {
          "type": "string"
        },
        "note": {
          "type": "string"
        }
      }
    }
  }
}
