mentionable.dev

NormalizedMessage Guide

Single-line summary: NormalizedMessage is the single contract between adapters and agents — a transport-neutral envelope every agent reads from and writes back into via NormalizedResponse.

See also: architecture-overview, identity-auth-guide, policy-part-guide, transport-module-guide, multi-agent-composition, glossary

Spec source: docs/spec/normalized-message.md v0.1. Type source: packages/core/src/message.ts.

Shape Summary

type NormalizedMessage = {
  id: string
  thread_id: string
  in_reply_to?: string
  sender: Sender
  recipient: string // @agent@domain
  parts: Part[] // text | file | link | artifact | tool_call | policy
  history?: HistoricalMessage[] // chronological, oldest-first
  recipient_capabilities: RecipientCapabilities
  received_via: 'activitypub' | 'a2a' | 'email'
  received_at: string // ISO 8601, UTC
  raw: unknown
  policy_resolution?: PolicyResolution // payment/consent completion signal
}

type Sender = {
  address: string // @x@domain
  display_name?: string
  profile?: SenderProfile // context only, NEVER authorization
  auth_method: AuthMethod // see below
  verified: boolean
  key_id?: string
  identities?: IdentityEvidence[] // extensible identity surface
}

sender.verified vs sender.identities

These are NOT the same and the difference matters.

FieldLayerMeaning
sender.auth_method + sender.verifiedv0.1 compatibility viewThe transport-native proof was checked by the receiving adapter. Single value, single trust scope.
sender.identities[]Extensible identity surfaceZero or more IdentityEvidence envelopes. Carries platform-native, delegated, wallet, OAuth, agent-signed identities — including ones forwarded across boundaries by Connectors.

Always authorize against sender.identities (and Trusted Connector Issuer policy) — see identity-auth-guide. Do NOT authorize on sender.verified alone unless your agent runs entirely inside a single transport’s trust boundary.

sender.profile Is Context, Not Authorization

sender.profile carries presentation/attribution facts: display_name, username, avatar URL, locale, timezone, provider, provider_subject, and namespaced extensions. Adapters and Connectors populate it from platform sources.

Profile is NEVER authorization. Do not branch on sender.profile.provider_subject, sender.profile.extensions.slack.team_id, usernames, or display names. Those values arrive only via the same evidence pipeline that produced sender.identities; if you need to authorize a Slack workspace member, check sender.identities for a verified slack-workspace-member evidence — see slack-identity-acl-cookbook.

Profile is for: rendering an avatar to humans, rendering an attribution line in an LLM Harness, choosing localized strings.

Parts

| Kind | Purpose | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | text | Body content. mime is text/plain, text/markdown, or text/html. | | file | A file attachment carried by bytes_ref (inline base64, URL, or content-addressed sha256). | | link | A URL the agent should treat as a hyperlink, not a file. Carries title, description. | | artifact | Structured agent output — datasets, generated images, code outputs. Same bytes_ref shape as file, plus optional artifact_type. | | tool_call | A tool the agent invoked while producing this response, surfaced to the recipient. Streaming re-emits the same id first without then with result/error. | | policy | A [[policy-part-guide | PolicyPart]] — step-up demand for consent / auth / payment / refusal. |

BytesRef variants:

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

history (Trimmed Before the Agent Sees It)

history?: HistoricalMessage[] is chronological, oldest-first. Each turn carries its own sender, parts, role: 'user' | 'assistant', and timestamp. The adapter is responsible for trimming history via the configured HistoryPolicy BEFORE the agent is invoked. Agents do not re-trim — they consume what the adapter produced.

The default createSlidingWindowPolicy keeps a stable head (keepHead) and a recency window, enforces role alternation, and emits cacheBreakpointAfter so prompt caches stay stable.

policy_resolution

When the previous turn ended with a payment_required or consent_required PolicyPart, the next inbound turn carries the verified completion in policy_resolution. Agents MUST verify in_reply_to_state against their own issuance store before relying on it — the field’s presence alone is not proof. See policy-part-guide#policyresolution.

recipient_capabilities.mention_relay

This required, non-optional field describes how the inbound platform routes mentions of OTHER recipients in the agent’s reply. Agents read the discriminator and choose how to dispatch sibling agents.

VariantPlatformsMeaning
{ kind: 'inline' }Slack, DiscordAgent types @handle (or @handle@domain); platform reads body and routes.
{ kind: 'recipient-field'; fields: ['to', 'cc'] }EmailPlatform routes by envelope only. Add address to To/Cc to wake the recipient. Body @-text is decorative.
{ kind: 'addressing'; envelope_fields: ['to', 'cc']; also_inline: true }ActivityPub (Mastodon-compatible)BOTH addressing collection AND body mention required. Dropping either breaks delivery.
{ kind: 'none' }A2A, SMS-styleNo platform relay. Agent must dispatch sibling itself (typically outbound A2A) and synthesize a single reply.

Adapters set this statically per protocol. Slack-style Connectors that dispatch over A2A SHOULD override the receiver’s default 'none' to 'inline' so the chained agent knows to type @handle. See multi-agent-composition.

Protocol Mapping Table

Where each NormalizedMessage field comes from, by protocol:

FieldActivityPubA2AEmail
idactivity IRImessageIdRFC 5322 Message-ID
thread_idcontext / conversation / first non-self in inReplyTo chaintaskIdThread-Index / In-Reply-To chain
in_reply_toinReplyTo IRIparent messageIdIn-Reply-To
sender.addressactor preferredUsername + hostfrom-address (header / OAuth subject)From (DKIM-aligned)
sender.auth_methodap-http-signature or ap-object-integrity-proofa2a-jwt or a2a-oauthemail-dkim or email-dmarc
sender.profileactor name, icon, url(often absent — A2A has no profile concept)display name from From
recipient_capabilities.mention_relayaddressingnonerecipient-field
received_via'activitypub''a2a''email'
raworiginal AP JSON-LDoriginal A2A requestoriginal RFC 5322 + headers

Common Mistakes