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.
| Field | Layer | Meaning |
|---|---|---|
sender.auth_method + sender.verified | v0.1 compatibility view | The transport-native proof was checked by the receiving adapter. Single value, single trust scope. |
sender.identities[] | Extensible identity surface | Zero 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.
| Variant | Platforms | Meaning |
|---|---|---|
{ kind: 'inline' } | Slack, Discord | Agent types @handle (or @handle@domain); platform reads body and routes. |
{ kind: 'recipient-field'; fields: ['to', 'cc'] } | Platform 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-style | No 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:
| Field | ActivityPub | A2A | |
|---|---|---|---|
id | activity IRI | messageId | RFC 5322 Message-ID |
thread_id | context / conversation / first non-self in inReplyTo chain | taskId | Thread-Index / In-Reply-To chain |
in_reply_to | inReplyTo IRI | parent messageId | In-Reply-To |
sender.address | actor preferredUsername + host | from-address (header / OAuth subject) | From (DKIM-aligned) |
sender.auth_method | ap-http-signature or ap-object-integrity-proof | a2a-jwt or a2a-oauth | email-dkim or email-dmarc |
sender.profile | actor name, icon, url | (often absent — A2A has no profile concept) | display name from From |
recipient_capabilities.mention_relay | addressing | none | recipient-field |
received_via | 'activitypub' | 'a2a' | 'email' |
raw | original AP JSON-LD | original A2A request | original RFC 5322 + headers |
Common Mistakes
- Using
received_viafor business logic. Forbidden.received_viais for telemetry, persistence keys, and adapter-specific log scoping only. Branch on typed fields likerecipient_capabilities.mention_relayandsender.identitiesinstead. - Authorizing on
sender.profile. Profile is presentation context. Authorization comes fromsender.identities+ local Trusted Connector Issuer policy. - Re-trimming history in the agent. The adapter already applied
HistoryPolicy. Re-trimming defeats prompt caching and risks dropping turns the operator chose to keep. - Mutating
policy_resolution.in_reply_to_statewithout store lookup. Always look up your own issuance before acting. - Filling
parts: []. Every NormalizedMessage carries at least one Part. Emptypartsis a malformed message — reject upstream.