Transport Module Guide
Single-line summary: Each transport module verifies its native credentials, normalizes the platform message into NormalizedMessage, applies the configured HistoryPolicy, and dispatches to the agent — all four follow the same shape with protocol-specific verification.
See also: architecture-overview, normalized-message-guide, identity-auth-guide, webfinger-agent-card-guide, deployment-patterns, glossary
Spec sources: docs/spec/{normalized-message,a2a-agent-card,transport-rest-v0.1}.md. Implementations: packages/transport-{activitypub,a2a,email,rest}/.
Universal Adapter Contract
Every transport module follows the same five-step contract:
- Verify wire credentials. Set
sender.auth_methodandsender.verifiedaccordingly. - Mint
IdentityEvidencefor what was verified. Attach tosender.identities. - Apply
HistoryPolicyto the conversation history (oldest-first), trim to the consumer’s budget, attach tomessage.history. - Set
recipient_capabilitiesstatically per protocol (the per-protocol value is fixed; see table below). - Invoke the agent with the
NormalizedMessage+AgentContext.
The agent receives uniform input regardless of the transport — that is the point of the layer.
ActivityPub (@mentionable/transport-activitypub)
Library: Fedify.
| Aspect | Detail |
|---|---|
| Inbound | Federated POST to the actor’s inbox. Fedify verifies HTTP Signatures and (optionally) object integrity proofs. |
| Outbound | Fedify queues activities to the addressed inboxes; uses the agent’s actor key. |
sender.auth_method | ap-http-signature or ap-object-integrity-proof. |
sender.address | Resolved from the verified actor; preferredUsername + host. |
sender.profile | Projected from the actor’s name, icon, url after resolution; do NOT dereference uncritically — Fedify caches and we avoid icon dereferences for stranger actors per recent work in #384. |
thread_id | Derived from context / conversation, falling back to first non-self in the inReplyTo chain. |
recipient_capabilities.mention_relay | { kind: 'addressing', envelope_fields: ['to', 'cc'], also_inline: true } — Mastodon-compatible servers require both addressing AND inline @actor@host. |
| KvStore | Default Fedify in-memory; for serverless use @fedify/postgres. |
A2A (@mentionable/transport-a2a)
Library: @a2a-js/sdk.
| Aspect | Detail |
|---|---|
| Inbound | JSON-RPC over HTTPS. Auth via bearer-jwt (JWKS) or oauth2 per AgentCard.a2a.auth. |
| Streaming | https+sse transport — agent emits multiple NormalizedResponse frames. |
| Forwarded extensions | Mentionable namespace under metadata.mentionable.*: identity_evidence, policy, policy_resolution, recipient_capabilities, agent_chain, tool_call_events. |
sender.auth_method | a2a-jwt or a2a-oauth. |
sender.address | Derived from token subject / OAuth subject; mapped to @x@domain via WebFinger reverse lookup or token claim. |
recipient_capabilities.mention_relay | { kind: 'none' } — A2A is single-call. Sender that wants inline (e.g. Slack Connector dispatching A2A) overrides via metadata extension. |
Forwarded IdentityEvidence | Carried in metadata.mentionable.identity_evidence. The receiver applies filterVerifiedForwardedIdentityEvidence with its ForwardedIdentityEvidenceVerifier, audience-bound to its canonical address, freshness ≤10 min. |
AgentChain | Carried in metadata.mentionable.agent_chain — see multi-agent-composition. |
Email (@mentionable/transport-email)
Stack: Gmail API for the reference inbound; Postalsys (Nodemailer + smtp-server + ImapFlow + PostalMime) for self-hosted SMTP/IMAP. Outbound DKIM via Nodemailer.
| Aspect | Detail |
|---|---|
| Inbound (Gmail) | Pub/Sub watch → push notification → users.messages.get → PostalMime parse. DKIM signature checked by Gmail’s relay before we receive. |
| Inbound (SMTP/IMAP) | smtp-server accepts MAIL FROM with DKIM verification at our edge; ImapFlow polls for inbox state. |
sender.auth_method | email-dkim (signed) or email-dmarc (DMARC alignment); none if neither passes. |
sender.address | DKIM-aligned From header → @local@domain. |
sender.profile | display_name from From header. |
| Outbound | Nodemailer with DKIM signing key from agent config. PolicyPart MUST be reply with DKIM-signed body. |
recipient_capabilities.mention_relay | { kind: 'recipient-field', fields: ['to', 'cc'] }. Body @-text is decorative. |
thread_id | Thread-Index if present; else In-Reply-To chain root; else Message-ID. |
REST (@mentionable/transport-rest)
Built-in. The lightest-weight surface for browser, IDE, script, and Connector clients.
| Aspect | Detail |
|---|---|
| Endpoint | Per-agent base URL advertised on the agent card under https://mentionable.dev/ns/transport-rest/v0.1 extension endpoint. |
| Methods | POST <endpoint> to send; GET <endpoint>?... to stream (SSE). |
| Auth | Bearer token validated by the agent runtime. Identity Evidence forwarded via mentionable-identity-evidence HTTP header (legacy mentionable-identity / x-mentionable-identity accepted for v0.1 compatibility). |
sender.auth_method | none (REST has no native auth_method enum value); use sender.identities exclusively. |
recipient_capabilities.mention_relay | { kind: 'none' }. REST callers dispatch siblings themselves. |
| HTTP status mapping | 401→unauthorized, 402→payment_required, 403→forbidden, 429→too_many_requests, 451→unavailable_for_legal_reasons, 503→service_unavailable. |
Per-Protocol recipient_capabilities Defaults
import {
ACTIVITYPUB_RECIPIENT_CAPABILITIES,
EMAIL_RECIPIENT_CAPABILITIES,
A2A_RECIPIENT_CAPABILITIES,
SLACK_RECIPIENT_CAPABILITIES,
} from '@mentionable/core'
| Constant | mention_relay |
|---|---|
ACTIVITYPUB_RECIPIENT_CAPABILITIES | addressing (envelope_fields: to/cc, also_inline: true) |
EMAIL_RECIPIENT_CAPABILITIES | recipient-field (fields: to/cc) |
A2A_RECIPIENT_CAPABILITIES | none |
SLACK_RECIPIENT_CAPABILITIES | inline (used by Slack Connector when dispatching A2A) |
Identity Evidence Pipeline (Per Adapter)
native verification ─┐
├──► transport mints IdentityEvidence
│ proof.type = 'transport' (in-runtime)
│ OR proof.type = 'signed-attestation' (Connector forwarded)
▼
attach to sender.identities
▼
ALSO project safe claims.profile → sender.profile (only after full verification)
▼
invoke Agent
For forwarded evidence (e.g. Slack Connector → A2A → agent), the receiving adapter applies filterVerifiedForwardedIdentityEvidence:
- Verify
signed-attestationproof viaForwardedIdentityEvidenceVerifier. - Match
audienceagainst the receiver’s canonical address. - Enforce freshness (default: requireExpiresAt, maxAge 600s, maxTtl 600s, clockSkew 60s).
- Drop anything that fails any check — silently, never throwing into agent code.
HistoryPolicy Application
Adapters apply the configured HistoryPolicy BEFORE invoking the agent. Default: createSlidingWindowPolicy with maxTurns: 20, maxTokens: 20_000, keepHead: 1. The policy:
- Tokenizes each turn (model-aware tokenizer if provided, else
len/4). - Reserves the first
keepHeadturns as a stable prefix. - Walks the tail newest-first until it would exceed token or turn caps.
- Enforces role alternation in a single pass.
- Emits
cacheBreakpointAfterso prompt caches stay stable.
The agent does not re-apply HistoryPolicy. The shape of message.history is what the consumer should send to the model.
Adapter Authoring Checklist
- Set
sender.auth_methodto the correct enum value (or'none'if unverified). - Set
sender.verifiedaccordingly. - Mint
IdentityEvidencefor the verified subject; attach tosender.identities. - Project
claims.profile→sender.profileONLY after full verification. - Set
recipient_capabilities.mention_relayto the per-protocol constant. - Trim
historyviaHistoryPolicy(chronological oldest-first). - Validate emitted
partsforPolicyPartmembership rules (validatePolicyPart). - Stash full provider data in
raw— never lose information. - Never throw past the agent boundary; convert errors into
NormalizedResponsewithstatus: 'error'.
Common Mistakes
- Authenticating Layer 1 (
auth_method) but skipping Layer 2 (identities). The forwarded-attestation case requires both. - Letting agent code see un-trimmed
history. The adapter is responsible. - Forgetting to set
recipient_capabilities.mention_relay. Field is REQUIRED — set{ kind: 'none' }if unsure. - Branching adapter behavior on
received_viainside the agent. The adapter already encoded the difference; agents read typed fields. - Forwarding raw OAuth tokens or DKIM keys via
claims. Only safe public-ish facts.