mentionable.dev

Normalized Message — Mentionable v0.1

Status: v0.1 (implemented)

This document defines the single in-memory shape that every Mentionable adapter produces on the way in and consumes on the way out. It is the contract between the adapter layer (ActivityPub / A2A / Email) and the agent runtime.

If this shape is wrong, the project is wrong. Every other document in this spec is downstream of getting this right.


1. Goals

2. Non-goals

3. The shape

type NormalizedMessage = {
  id: string
  thread_id: string
  in_reply_to?: string

  sender: Sender
  recipient: string

  parts: Part[]

  history?: HistoricalMessage[] // see §5.4

  recipient_capabilities: RecipientCapabilities // see §3.3

  received_via: 'activitypub' | 'a2a' | 'email'
  received_at: string // ISO 8601, UTC
  raw: unknown

  policy_resolution?: PolicyResolution // see §3.4

  received_trace?: NormalizedResponse // see §10
}

type HistoricalMessage = {
  id?: string // adapter-known stable id when available
  role: 'user' | 'assistant'
  sender: Sender
  parts: Part[]
  timestamp: string // ISO 8601, UTC
}

type Sender = {
  address: string // canonical: @user@domain or @agent@domain
  display_name?: string
  profile?: SenderProfile
  auth_method:
    | 'ap-http-signature'
    | 'ap-object-integrity-proof'
    | 'a2a-jwt'
    | 'a2a-oauth'
    | 'email-dkim'
    | 'email-dmarc'
    | 'none'
  verified: boolean
  key_id?: string // AP actor key URL, JWT kid, DKIM selector, etc.
  identities?: IdentityEvidence[] // see identity-evidence-v0.1.md
}

type SenderProfile = {
  display_name?: string
  username?: string
  url?: string // human-viewable account/profile/actor URL or deep link
  avatar?: {
    url: string
    mime_type?: string
    width?: number
    height?: number
  }
  locale?: string // BCP 47
  timezone?: string // IANA timezone
  provider?: string // e.g. slack, activitypub, oauth
  provider_subject?: string // e.g. slack:T123/U456
  fetched_at?: string // ISO 8601, UTC
  extensions?: Record<string, unknown> // namespaced, whitelisted provider facts
}

type Part =
  | { kind: 'text'; mime: 'text/plain' | 'text/markdown' | 'text/html'; content: string }
  | { kind: 'file'; mime: string; name?: string; bytes_ref: BytesRef; size_bytes?: number }
  | { kind: 'link'; url: string; title?: string; description?: string }
  | { kind: 'artifact'; mime: string; name?: string; bytes_ref: BytesRef; artifact_type?: string }
  | {
      kind: 'tool_call'
      id: string // unique within the response (typically the LLM-assigned tool_use id)
      name: string // tool name as exposed to the LLM
      args: unknown // input arguments (JSON-serializable)
      result?: unknown // output payload when the call succeeded (JSON-serializable)
      error?: { message: string } // present when the call failed; mutually exclusive with `result`
      duration_ms?: number // observed call duration
      started_at?: string // ISO 8601 timestamp when the call started
    }

type BytesRef =
  | { kind: 'inline'; data_base64: string }
  | { kind: 'url'; url: string; expires_at?: string }
  | { kind: 'content_addressed'; algo: 'sha256'; digest: string; url?: string }

3.1 Field contracts

id — Globally unique within the receiving node. Adapters MUST generate stable ids (UUIDv7 recommended) so that retries and deduplication work. This is the Mentionable id, not the protocol id; the protocol id lives in raw.

thread_id — A stable string that groups messages belonging to one conversation. Derivation is protocol-specific (see §5). Two messages with the same thread_id SHOULD be treated by the runtime as part of the same conversation regardless of arrival protocol. Cross-protocol threading is out of scope for v0.1: if the same logical conversation arrives via AP and email, the runtime sees two thread_ids.

in_reply_to — The id of the parent message if this one is a direct reply, or the protocol-native id (AP activity IRI, email Message-ID, A2A message id) when the parent is not in local history. Adapters SHOULD prefer the Mentionable id when known. If only the protocol-native id is available, it is carried as-is.

sender.address — Always the @user@domain canonical form. Email addresses become @local@domain. AP actor IRIs are resolved back to their WebFinger acct: form. If this cannot be done confidently, auth_method is none and verified is false.

sender.verified — True only when the adapter has cryptographically verified the claim to the legacy sender.address field. AP requires HTTP Signatures or Object Integrity Proofs to pass. Email requires a DKIM signature from the sender’s domain with a passing body hash. A2A requires a validated JWT or OAuth token whose subject matches sender.address. Anything weaker — SPF pass, unsigned AP object — is verified: false.

sender.identities — Optional extensible identity evidence array. This is the preferred surface for platform-native and delegated identity: Slack workspace members, OAuth subjects, SIWE wallets, agent self-signatures, and forwarded upstream principals. See identity-evidence-v0.1.md. sender.auth_method / sender.verified remain for compatibility and MUST NOT be treated as the only identity signal once sender.identities is present.

sender.profile — Optional presentation/context facts about the sender: display label, username, profile URL, avatar URL, locale, timezone, and provider-specific whitelisted facts. sender.profile is for LLM attribution and UI rendering only. It MUST NOT be used for authorization, account linking, payment, delegation, or rate-limit keying unless a separate policy explicitly links it to verified sender.identities. Transport modules and Connectors SHOULD prefer the common fields above and place provider-specific facts under extensions.<provider>, not by dumping raw provider objects. When trusted IdentityEvidence.claims.profile is present, a receiver MAY project it into sender.profile.

recipient — The single agent address this delivery is for, in @agent@domain form. If the underlying message was addressed to multiple recipients, the adapter produces one NormalizedMessage per recipient served by this node. Cross-recipient fanout is the transport module’s job, not the runtime’s.

parts — Ordered. Order is semantic: it is the order an agent SHOULD render or process them. Adapters MUST preserve source order. Empty parts is allowed (e.g. an email with only a subject; the subject becomes a single text part).

history — Optional. Prior turns of the same conversation, chronologically sorted oldest-first, conceptually append-only across the lifetime of the thread. Adapters populate this per §5.4. Each entry is a structurally-thinner sibling of NormalizedMessage — see the HistoricalMessage shape above. The role discriminator is 'assistant' when the prior turn was authored by the recipient agent (i.e. sender.address === recipient) and 'user' otherwise. Agents MAY ignore history (single-turn behavior preserved); when an agent does consume it, the transport module’s HistoryPolicy has already trimmed the array to the configured budget — see agent-interface.md §10 for the responsibility split and the HistoryPolicy interface.

received_via — The inbound protocol. Agents SHOULD NOT branch on this field for business logic; it is primarily for observability and for the outbound adapter to choose a default reply channel.

received_at — When the adapter finished parsing and validating. Not the protocol-level timestamp (Date: header, AP published, A2A timestamp), which lives in raw.

raw — The parsed native message. Its shape depends on received_via. Agents that do not need protocol specifics MUST ignore this field; the runtime is not allowed to require it. See §6.

recipient_capabilities — A structured description of what the inbound platform offers an agent that wants to bring siblings into the conversation. Required, non-optional. Adapters MUST populate this from a static per-protocol value (see §3.3 and §5). Agents (and prompt-building code wrapping LLMs) read this field to decide whether to “type @handle and trust the platform” vs. “open a direct A2A call to the sibling and synthesize one cohesive reply.” The shape is small and additive: today it carries only mention_relay; future capabilities (attachments, reactions, threading model) extend the same wrapper.

3.2 Parts — design notes

A flat ordered list beats a tree. Nesting (AP Collection, email multipart/related) tempts adapters to over-model structure that the agent will flatten anyway.

Text parts keep their mime type so the agent knows whether it is safe to treat as plain, markdown, or HTML. Email multipart alternatives collapse to a single text part by picking the best alternative (text/plain or text/markdown preferred; text/html only when plaintext is absent or empty). The unchosen alternatives are not carried in parts; they survive in raw.

File vs artifact. file is “the sender attached this.” artifact is “the sender’s agent produced this as output.” Inbound adapters produce file for email attachments and AP attachment, and artifact for A2A artifact parts. The distinction matters for outbound rendering: A2A artifacts have semantics (versioning, task association) that emails and Notes do not.

tool_call is “the agent’s LLM invoked a tool while producing this response.” A single part carries the full call lifecycle (arguments, then result-or-error) so that the part list does not need separate “start” and “end” entries; instead, streaming responses emit the same part twice — first with result/error absent (status 'partial'), then again with the call resolved (status 'partial' or, if it is the final frame, 'ok'). Adapters identify the lifecycle by id, so re-emitted parts MUST keep the same id. error and result are mutually exclusive: once a call completes, the part carries one but not both. args and result MUST be JSON-serializable so any adapter can persist them; agents that need to ship binary tool output SHOULD pair the tool_call part with a sibling file or artifact part. The part is intended for tool calls the agent wants the recipient to see (web search hits, computed analyses, lookups). Internal control-flow tool calls the agent would prefer to keep private MUST NOT be emitted as tool_call parts.

The default outbound serialization for adapters that cannot render structured tool calls (Email, plaintext-only AP) is a one-line text block:

🔧 <name>(<args summary>) → <result summary>      [success]
🔧 <name>(<args summary>) → ❌ <error.message>     [failure]
🔧 <name>(<args summary>) → …                     [in-flight; partial frame only]

The <args summary> and <result summary> are JSON, truncated to a single line and an adapter-defined byte budget (default 200 bytes per side, replaced by when over). @mentionable/core ships a helper, serializeToolCallToText, that produces this exact line; adapters MUST use it (or an explicitly-noted equivalent) so the on-the-wire shape stays consistent. Adapters that have a richer surface MAY render the part natively and skip the text fallback. A2A uses the a2a-tool-events-v0.1 extension: tool_call parts are carried as DataParts with AI SDK-style toolCallId, toolName, input, and output fields.

BytesRef has three variants because each protocol prefers a different one. A2A messages often ship bytes inline; AP typically links; email is inline but large attachments are frequently linked. Adapters SHOULD prefer content_addressed when they have the option, because it lets the runtime deduplicate and store safely. inline is acceptable for small payloads (under 64 KiB; runtime MAY reject larger). url is acceptable when the URL is expected to live at least as long as the message (set expires_at when known).

When a Connector bridges a platform whose file URLs require Connector-held credentials, bytes_ref.url MUST be a Connector-hosted capability URL rather than the provider’s private URL. The capability URL should be short-lived, audience/scoped, and budgeted. Provider OAuth tokens, bot tokens, and private download URLs do not belong in parts, raw copies forwarded to agents, or identity claims. A2A carries these descriptors as FilePart.file.uri; REST callers that need typed current-turn parts use the parts JSON sidecar from transport-rest-v0.1.md.

3.3 Recipient capabilities

When an LLM-backed agent receives a question and decides “this needs @gamebuilder’s perspective too,” the right next move depends entirely on the platform that just delivered the message:

Without this signal, an LLM agent reused across protocols will produce replies that work on Slack and silently fail on email. recipient_capabilities exposes the difference structurally so the agent (or the prompt scaffolding around it) can adapt.

type RecipientCapabilities = {
  mention_relay: MentionRelayCapability
  agent_chain?: AgentChain // see §3.3.5
}

type MentionRelayCapability =
  | { kind: 'inline' }
  // Body `@handle` is sufficient. The platform reads the reply text and
  // routes mentions itself. Slack, Discord.
  | { kind: 'recipient-field'; fields: ('to' | 'cc' | 'bcc')[] }
  // The platform routes by recipient envelope, not body. Adding the
  // sibling's address to one of `fields` is what wakes them. Body
  // `@`-text is decorative (or absent). Email is `['to', 'cc']`; a
  // platform that allows hidden recipients includes `'bcc'`.
  | {
      kind: 'addressing'
      envelope_fields: ('to' | 'cc')[]
      also_inline: true
    }
  // BOTH required. The actor IRI in the addressing collection AND a
  // body `@actor@host` mention. ActivityPub on Mastodon-compatible
  // servers — dropping either one breaks delivery on at least one
  // implementation.
  | { kind: 'none' }
// No platform relay. The agent must dispatch siblings itself
// (outbound A2A, etc.) and weave the answers into a single reply.
// A2A and SMS-style channels.

3.3.1 Per-protocol values

Adapters supply the value statically per inbound — there is no per-message variation in v0.1 for the AP and email transports. The values are:

received_viarecipient_capabilities.mention_relay
'activitypub'{ kind: 'addressing', envelope_fields: ['to', 'cc'], also_inline: true }
'email'{ kind: 'recipient-field', fields: ['to', 'cc'] }
'a2a'{ kind: 'none' } by default — but A2A inbounds MAY override via the mentionable.recipient_capabilities metadata extension (see §3.3.4).

Slack-reference is not a published adapter — it’s a Slack client that produces NormalizedMessage-shaped inputs for agent-side reuse. Where it constructs that shape, it MUST emit { kind: 'inline' }. The same rule applies to any future client / bridge: if it converts an inbound from a platform that routes body mentions, it emits 'inline'; otherwise it picks the matching kind from the union.

3.3.1.1 The A2A “client on behalf of a platform” case

When a Slack-style client (slack-connector, future Discord bridge, etc.) dispatches a user message to an A2A agent, the inbound transport on the receiving side is 'a2a', but the caller’s platform is what the eventual reply will be displayed on. That’s the platform whose mention-relay mechanism the agent’s reply needs to match — not A2A’s.

Example: a Slack user types @lean what does @gamebuilder think?. slack-connector dispatches the message to lean@firemanager.info over A2A. If lean’s LLM gets { kind: 'none' } (the conservative A2A default), it will laboriously dispatch gamebuilder itself and synthesize one reply. But Slack would have routed @gamebuilder automatically if the agent had just typed it — exactly the demo Mentionable wants.

The mentionable.recipient_capabilities A2A message-metadata extension fixes this (§3.3.4): the caller forwards its own platform’s capabilities, the inbound adapter lifts them onto NormalizedMessage, and the agent sees { kind: 'inline' } even though the wire transport is A2A.

3.3.2 Agent-side use

@mentionable/core exposes renderMentionRelayCapability(capability): string so every agent / prompt-builder gets the same human-readable description. LLM agents typically inject the rendered string into their system prompt:

The platform that delivered this message routes mentions to other agents as follows:

  • mechanism: recipient-field
  • to wake another agent, add them to To or Cc of your reply
  • body @-mentions are NOT routed by this platform

If you can’t add recipients, dispatch the sibling directly via A2A and weave the answer into your reply.

Agents that don’t need mention-relay (e.g. single-shot Q&A agents) MAY ignore the field entirely. The field is required so adapters can’t accidentally omit it for an agent that does care.

3.3.3 Why a structured union, not a boolean

A boolean (platform_relays_mentions: true | false) was the first impulse and it’s wrong. Email is the counter-example: the platform does relay mentions to siblings — provided you put them in Cc:. The agent needs the structural signal to know how to relay, not just whether it can. The union encodes that without forcing every consumer to special-case each protocol.

3.3.4 A2A metadata extension: caller-forwarded capabilities

The A2A wire format carries arbitrary Message.metadata. Mentionable defines the namespaced key

message.metadata.mentionable.recipient_capabilities : RecipientCapabilities

so a client that dispatches A2A on behalf of a richer platform (slack-connector forwarding for a Slack workspace, a Discord bridge, etc.) can tell the receiving agent what the displayed-on platform’s relay mechanism actually is.

transport-a2a reads message.metadata.mentionable.recipient_capabilities on every inbound. When present and structurally valid, it replaces the default { kind: 'none' }. When absent or malformed, the default holds — A2A-native callers (no platform wrapper) get the conservative behaviour.

Validation rules at the inbound boundary:

The receiving server trusts the caller’s claim. v0.1 does NOT cryptographically verify the metadata: a hostile A2A client could lie about its capabilities to confuse the agent. This is acceptable because (a) all of recipient_capabilities’s downstream effects are advisory (an LLM choice; the runtime never branches on it), and (b) the caller already controls the inbound message body. v0.2 may add signing or registry-based verification if abuse appears in practice.

Outbound clients SHOULD include the extension when they are bridging from a platform with a different relay mechanism. A client that is itself the user — running on the same platform that delivered the original message — has nothing to forward; the field is omitted.

3.3.5 A2A metadata extension: agent chain position

When a platform client chains multiple agents (A → B → C in the same Slack thread), each relay turn should tell the receiving agent where it sits in the chain so an LLM doesn’t keep inviting more agents past the configured cap.

Mentionable defines the namespaced key

message.metadata.mentionable.recipient_capabilities.agent_chain : AgentChain

carried inside the same recipient_capabilities extension as §3.3.4:

type AgentChain = {
  hop: number // 1-based position of this agent in the chain
  max_hops: number // operator ceiling (e.g. 3 = user → A → B → C max)
  is_final: boolean // true when hop === max_hops
}

transport-a2a lifts agent_chain from the metadata object alongside mention_relay. When absent or malformed the field is omitted on RecipientCapabilities — a chain-unaware sender or an A2A-native caller produces the same conservative behaviour as before.

@mentionable/core exposes renderAgentChain(chain): string | null (returns null when the field is absent) and renderRecipientCapabilities(capabilities): string which combines both fields into a single system-prompt block. Prompt builders SHOULD call renderRecipientCapabilities rather than formatting the fields individually.

The is_final flag is the primary signal for LLMs:

Validation rules mirror §3.3.4: hop and max_hops must be positive integers, 1 ≤ hop ≤ max_hops, is_final must be a boolean. Malformed values are silently ignored.

3.4 policy_resolution

policy_resolution?: PolicyResolution

Present on a turn that carries a verified completion of a prior payment_required or consent_required PolicyPart. Absent on all other turns, including historical turns in history[].

This field is set by whichever component performs the final verification — typically the agent’s own HTTP callback endpoint (/api/x402/callback, /api/stripe/webhook, etc.) rather than the transport adapter. See policy-part-v0.1.md §5 for the full specification, trust model, and wire shape.

Key invariants:

The PolicyResolution type is exported from @mentionable/core.

policy_resolution.kindPopulated by
'payment_required'Agent’s payment callback endpoint (x402, Stripe, PG, …) or A2A adapter for in-band x402 metadata
'consent_required'Agent’s consent callback endpoint or adapter consent cache hit

4. The response shape

type NormalizedResponse = {
  reply_to: string // NormalizedMessage.id being answered
  parts: Part[]
  status: 'ok' | 'partial' | 'error'
  error?: { code: string; message: string; retriable: boolean }
  streaming?: Streaming
  push_back?: PushBackHint
}

type PushBackHint = {
  channel?: 'activitypub' | 'a2a' | 'email' // default: received_via of the request
  thread_ref?: string // override the auto-derived thread target
}

type Streaming = {
  stream_id: string // groups frames that belong to one logical response
  seq: number // 0-based, strictly increasing within stream_id
  final: boolean // true on the last frame; MUST be true exactly once
}

5. Protocol mapping

The table below is the contract. Every adapter must populate these fields exactly this way, or document a deviation.

FieldActivityPub (Create { Note })A2A (task message)Email (MIME)
idAdapter-minted UUIDv7Adapter-minted UUIDv7Adapter-minted UUIDv7
thread_idSee §5.1.1A2A task_idSee §5.3.1
in_reply_toobject.inReplyTo if within local history, else the IRI as-isA2A parent message.id if presentIn-Reply-To: message-id
sender.addressactor IRI → WebFinger lookup → @preferredUsername@hostJWT subject / OAuth sub, required to match @agent@domain formFrom: mailbox → @local@domain
sender.display_nameAP actor nameAgent card name if subject is an agent, else token claim nameFrom: display-name
sender.profileAP actor name/preferredUsername/url/icon when availableVerified token/evidence profile claims; Connector history sender profilesFrom: display-name plus email provider/provider_subject; richer profile is provider-specific
sender.auth_methodap-http-signature or ap-object-integrity-proofa2a-jwt or a2a-oauthemail-dkim, email-dmarc, or none (see §5.3)
sender.verifiedSignature verified against actor’s public keyToken verified, audience matches, not expiredDKIM pass, d= tag covers sender.address domain
sender.key_idkeyId from signature headerkid from JWT headerDKIM s= selector + d= domain
sender.identitiesAP identity evidence after signature verificationA2A token evidence plus forwarded metadata.mentionable.identity_evidenceEmail identity evidence after DKIM/DMARC pass
recipientWebFinger-resolved address of the inbox ownerAgent card address for the targetTo: (first Mentionable-handled address)
partsSee §5.1See §5.2See §5.3
received_via'activitypub''a2a''email'
received_atNow (UTC)Now (UTC)Now (UTC)
rawParsed AP activity + signature headersParsed A2A task envelopeParsed MIME tree (PostalMime output)
recipient_capabilities.mention_relay{ kind: 'addressing', envelope_fields: ['to','cc'], also_inline: true }{ kind: 'none' }{ kind: 'recipient-field', fields: ['to','cc'] }

5.1.1 ActivityPub → thread_id

ActivityPub 2.0 does not define a standard conversation-grouping field. The derivation order is:

  1. object.context — the closest thing to a standard (referenced by FEP-7888 and common Mastodon usage). Use it when present and when it is an IRI, not an inline object.
  2. object.conversation — a non-standard Mastodon extension. Use it when context is absent. Publishers MUST NOT treat this as authoritative beyond the Fediverse interop value.
  3. The root of the inReplyTo chain, walked up to a depth of 10 or until a cycle is detected. “Root” means the oldest ancestor reachable via inReplyTo.
  4. If all of the above fail, thread_id is the activity IRI itself — this message starts its own thread.

Resolvers walking inReplyTo MUST apply the SSRF rules from webfinger.md §7 and MUST bound total work; deep chains are truncated, not followed indefinitely.

5.1 ActivityPub → parts

5.2 A2A → parts

5.3.1 Email → thread_id

Email threading in the wild is unreliable. Gmail, in particular, often omits References: on replies composed in its web UI and depends on server-side conversation grouping. The derivation order:

  1. The first message-id in the References: header if that header is present and syntactically valid. “First” means the root of the chain, not the most recent.
  2. Otherwise, In-Reply-To: if present. A Gmail-originated reply frequently has In-Reply-To: but no References:; this path is the common case, not a fallback.
  3. Otherwise, the current message’s own Message-ID: — this message starts a new thread.

Adapters MUST NOT infer threads from Subject: normalization (Re: / Fwd: stripping). Subject-based threading is a heuristic that belongs in client UX, not in the adapter layer.

When the same logical conversation is observed over multiple protocols (the same user replies by both email and AP), v0.1 produces two distinct thread_ids. Cross-protocol thread merging is out of scope for v0.1; see §9 Open questions.

5.3 Email → parts

5.3.2 Email → sender.auth_method

Email adapters MUST resolve sender.auth_method as follows:

  1. 'email-dkim' — DKIM pass AND the signing d= domain matches the From: domain.
  2. 'email-dmarc' — DKIM is absent or does not cover the From: domain, but DMARC evaluation passes for the From: domain (i.e. aligned SPF or aligned DKIM per RFC 7489). DMARC-only wins imply weaker key binding than a direct DKIM signature; agents that require per-message cryptographic authentication SHOULD treat this differently from 'email-dkim'.
  3. 'none' — otherwise (including SPF-only pass, failed authentication, or no authentication performed).

sender.verified is true for 'email-dkim' and 'email-dmarc' and false for 'none'.

5.4 history — per-transport population

NormalizedMessage.history is an optional, chronologically-sorted (oldest-first) array of prior turns from the same logical conversation, populated by the inbound adapter. Each transport derives it differently:

The size of history BEFORE policy trimming is bounded only by the upstream protocol (AP chain depth bound, A2A Task.history.length, etc.). Adapters apply a HistoryPolicy (see agent-interface.md §10) before invoking the agent, so the agent sees a budget-shaped array.

6. raw — what each adapter puts there

raw is typed as unknown because the runtime MUST NOT rely on its shape. For debuggability and for agents that deliberately opt into protocol specifics, these are the conventions.

AP (received_via: 'activitypub'):

{
  activity: Activity                  // the full Create / Update / … object
  signature: { keyId: string; algorithm: string; headers: string[]; signature: string }
  actor: Actor                        // dereferenced actor document
  delivery: { to: string[]; cc: string[]; bcc?: string[] }
}

A2A (received_via: 'a2a'):

{
  task: Task // full task object
  message: Message // the A2A message being normalized
  auth: {
    kind: 'jwt' | 'oauth'
    token_claims: Record<string, unknown>
  }
}

Email (received_via: 'email'):

{
  headers: Record<string, string | string[]>
  parsed: PostalMimeOutput            // from PostalMime
  dkim: { results: Array<{ domain: string; selector: string; status: 'pass' | 'fail' | 'none' }> }
  spf: { status: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' }
  dmarc: { status: 'pass' | 'fail' | 'none' }
  envelope: { mail_from: string; rcpt_to: string[] }  // SMTP envelope, may differ from headers
}

Agents that read raw take on a dependency on the protocol. That is intentional.

7. Canonicalization rules

8. Conformance

An adapter is conformant for v0.1 if:

  1. Every field in §3 is populated per §5 for every message it successfully parses.
  2. sender.verified is never true without a verified cryptographic binding to sender.address.
  3. Messages that cannot be mapped (unsupported AP object subtype, corrupt MIME, invalid A2A task) are rejected at the adapter boundary and never produce a NormalizedMessage. The runtime is not responsible for filtering malformed inputs.
  4. raw is populated with enough information to reconstruct the wire message for debugging, though not necessarily byte-for-byte.
  5. recipient_capabilities.mention_relay matches the per-protocol value in §3.3.1 / §5.
  6. It MUST NOT set policy_resolution without performing or delegating verification to the agent’s own callback endpoint. Setting this field is an assertion that verification succeeded.

An agent is conformant for v0.1 if:

  1. It produces a NormalizedResponse whose reply_to matches a received NormalizedMessage.id.
  2. It does not branch on received_via for business logic that would be different across protocols (push_back.channel is the correct way to override channel choice).
  3. It tolerates adapters that populate only the fields listed as required in §3 — it does not require any optional field.
  4. When policy_resolution is present, it MUST verify in_reply_to_state against its issuance store and atomically consume the token before acting. An agent that acts on policy_resolution without this check is non-conformant.

9. Open questions

These are flagged for review before v0.1 freezes.

10. Structured trace annotation v0.1

10.1 Why

The mix of human and agent receivers on ActivityPub and Email keeps growing. A “human face only” transport policy underserves the agent-to-agent half: the visible body has to be standard prose (Mastodon/Gmail can’t render structured tool calls), but a pure prose body loses the structured payload (tool_call args, results, errors, timings) that Mentionable-aware receivers want. The dual-view annotation resolves this without changing what the visible body means: the standard rendering ships unchanged; an additive sidecar carries the full structured NormalizedResponse for receivers that can use it. The visible body is standalone-interpretable; the annotation is ignorable.

This applies only to the AP and Email transports. A2A needs no annotation — its Part kinds already carry tool_call structure losslessly via the a2a-tool-events-v0.1 extension.

10.2 Payload format

The annotation payload is the JSON-serialized NormalizedResponse for the outbound message, base64-encoded. AP transports use base64url (RFC 4648 §5) because the payload sits in a URL fragment; Email transports use standard base64 because the payload sits in a MIME body with Content-Transfer-Encoding: base64. The decoded JSON shape is identical across transports.

The annotation is additive, not a replacement. The visible body continues to render every tool_call part via serializeToolCallToText (or the adapter’s richer surface, where one exists). Receivers that ignore the annotation see the prose-formatted tool calls in parts[] exactly as before; receivers that decode the annotation get the full structured response on NormalizedMessage.received_trace.

10.3 Wire patterns per transport

ActivityPub. Append one inline anchor to the Note’s HTML content, after the visible body:

<a class="mentionable-trace" data-version="0.1" href="<href>">[<anchor text>]</a>

<href> is either:

Servers MAY ALSO emit an attachment array entry with the same href as a Link object so AP nodes that render attachment chips show the trace link; receivers MUST treat the inline anchor as canonical.

Receivers MUST process only the first <a class="mentionable-trace"> anchor in the Note content; senders MUST emit at most one. The attribute parser is normative for the double-quoted form (class="mentionable-trace"); single-quoted attributes are not required to be parsed.

Email. Emit a multipart/alternative body with three siblings:

  1. text/plain — human plain-text fallback (existing parts[] rendering, unchanged).
  2. text/html — human HTML. Simple inline rendering close to “email like a normal email.” tool_call parts render as <p>✅ name(args) → result</p>; previous <div border> boxes and <details> collapsibles are intentionally removed because the structured surface has moved to part (3).
  3. application/json; profile="https://mentionable.dev/ns/normalized-message/v0.1" — full NormalizedResponse JSON, base64-encoded. The RFC 6906 profile parameter is the inbound parser’s positive-discrimination signal: a generic application/json attachment from another sender does not carry it. Mainstream mail clients (Gmail, Outlook, Apple Mail) preserve the part in the MIME chain but never display it.

A2A. No annotation. The protocol’s Part kinds carry tool_call structure losslessly already.

10.4 Two-tier emit policy (env)

Both AP and Email transports read MENTIONABLE_TRACE_VIEWER_URL once and apply the same table:

ValueBehaviour
unset (default)Tier 1, https://mentionable.dev/trace baked in as default
https://…Tier 1 using that URL
none (or empty)Tier 2 (data:application/json;base64,…)
anything elseTier 1 with default URL; one-shot stderr warning so operators notice the typo

Operators who want to host their own viewer point the env at the fork (https://my-trace.example.com/). Operators who want zero third-party reliance set none and accept Mastodon human-face degradation. The email transport ignores Tier 1 vs Tier 2 (the payload always sits inline in the MIME part) but still honours the env variable so a single operator setting governs both transports.

10.5 Size budget

The encoded annotation MUST fit in 64 KiB after base64 encoding. The cap keeps Tier 1 URLs pasteable and Tier 2 data: URIs below most AP server limits. When the natural payload is over, transports try once more with each tool_call part’s args and result summary truncated to 200 bytes (mirroring the serializeToolCallToText budget); when the truncated payload is still over, transports drop the annotation entirely and emit a one-line stderr warning. The visible body always ships regardless — the annotation never blocks a message from being sent.

Per-call args and result summaries SHOULD be truncated to 200 bytes each (matching serializeToolCallToText’s budget at §3.2) as the first truncation step, before evaluating the overall 64 KiB cap.

10.6 Symmetric in/out (acceptance gate)

Both transports MUST parse what they emit. The round-trip test is the gate: emit a non-trivial NormalizedResponse → wire shape → feed the wire back through the same transport’s inbound parser → the reconstructed received_trace deep-equals the original. No drift, no asymmetric capability. Receivers MUST treat inbound failures (no anchor, malformed payload, shape mismatch, oversize fragment) as non-fatal: log a warning, leave received_trace undefined, deliver the visible message normally.

10.7 Anchor text

The text inside the AP anchor MUST be human-meaningful prose summarizing the response — for example, [trace: web_search ✓ 412ms] or [trace: 3 tool calls — web_search ✓, file_write ✓, slow_lookup ✗]. The visible body MUST remain interpretable when Mastodon strips the href (Tier 2 degradation), so the anchor text is the last line of defence. Implementations cap the anchor text at ~120 characters and elide additional tool calls with a +N more suffix.

10.8 received_trace field semantics

NormalizedMessage.received_trace?: NormalizedResponse is an optional sidecar populated by the inbound parser when (and only when) a trace annotation was successfully decoded. Receivers MUST NOT rely on its presence — it is undefined whenever:

The visible parts[] body remains the source of truth in every case. Agents that branch on received_trace.parts[i].args (for example, to chain into a follow-up tool call) MUST gracefully degrade when the field is absent.

10.9 Capability negotiation — deferred

A future agent-card flag (mentionable:annotation_v1: true) could let senders skip emission for receivers that can’t parse, saving wire bytes. v0.1 always emits; non-Mentionable receivers ignore the annotation harmlessly (the AP anchor text remains valid prose; the email third part is silently dropped by mainstream clients). Capability negotiation is tracked as a follow-up to #498.

10.10 Anti-patterns

Implementations MUST NOT:

The dual-view annotation works precisely because it is an additive inline element with standard semantics (<a href> on AP, a sibling MIME part on Email) that degrades cleanly when ignored. Anything that depends on receivers preserving non-standard wire shape will lose to the next sanitizer rollout.