PolicyPart Guide
Single-line summary: PolicyPart is the normalized step-up mechanism — when ambient identity is not enough, agents emit a PolicyPart to demand consent, auth, or payment, and resume work from a verified PolicyResolution on the next turn.
See also: normalized-message-guide, identity-auth-guide, transport-module-guide, multi-agent-composition, glossary
Spec source: docs/spec/policy-part-v0.1.md. Type source: packages/core/src/policy.ts, packages/core/src/policy-resumption.ts.
Seven Well-Known Kinds
| Kind | Maps to HTTP | When to emit |
|---|---|---|
consent_required | (no direct mapping; OAuth-shaped flow) | Need user consent before continuing — e.g. linking a calendar account, accepting terms. |
unauthorized | 401 | Caller has no usable identity for this resource. |
payment_required | 402 | Caller must pay before the agent will proceed. |
forbidden | 403 | Caller is identified but not authorized. |
too_many_requests | 429 | Rate limit hit; carry retry_after_seconds. |
unavailable_for_legal_reasons | 451 | Region/legal block. |
service_unavailable | 503 | Temporary outage; carry optional retry_after_seconds. |
The wire-level kind field is intentionally an OPEN string. Receivers that don’t recognize a kind preserve it and route on kind + message. Per spec §3.6, receivers MUST NOT infer success from an unknown kind.
Base Fields (Every Kind)
type PolicyPartBase = {
kind: PolicyKindWire // open string discriminant
code?: string // machine-readable token, e.g. 'oauth:consent_required'
title?: string // short summary
message: string // REQUIRED human-readable detail
message_translations?: Record<string, { title?: string; message: string }>
url?: string // actionable URL; HTTPS only; origin-bound to agent's canonical host
action_label?: string // accessible button label
data?: Record<string, unknown> // protocol-native escape hatch; reverse-DNS prefix keys
}
Kind-Specific Fields
// 'unauthorized' — RFC 9110 challenges
{ kind: 'unauthorized', auth_challenges: Array<{ scheme: string; params?: Record<string, string> }> }
// 'payment_required' — at least one accepted scheme
{
kind: 'payment_required',
state?: string, // CSPRNG ≥128 bits; SHOULD be present for in-band resumption
accepted_payments: Array<{
scheme: string, // open: 'x402.exact', 'stripe.checkout', 'ln.bolt11', 'pg.inicis', ...
payload: Record<string, unknown>,
label?: string, // 'Pay $1.00 USDC'
description?: string, // 'Base Sepolia · sign within 5 min'
}>,
}
// 'consent_required' — CSPRNG state + return_to URL (HTTPS)
{ kind: 'consent_required', state: string, return_to: string }
// 'too_many_requests' / 'service_unavailable'
{ kind: ..., retry_after_seconds?: number }
// 'forbidden' / 'unavailable_for_legal_reasons' — base only
{ kind: ... }
payment_required.accepted_payments[].scheme
The scheme namespace is open. Examples in the wild:
x402.exact,x402.upto— x402 protocol over USDC on EVM chains. See@mentionable/x402-bridge.stripe.checkout— Stripe-hosted checkout.ln.bolt11— Lightning invoice.pg.inicis,pg.toss— Korean PG providers.ap2.*— agents.pub payment v2.
Renderers that don’t recognize a scheme display the label and description and fall back to opening url. Receivers that recognize the scheme can present an in-band payment UI.
PolicyResolution
When the prior turn ended with payment_required or consent_required, the next inbound turn carries a PolicyResolution on NormalizedMessage.policy_resolution:
type PolicyResolution = {
in_reply_to_state: string // matches the original PolicyPart's state
kind: 'payment_required' | 'consent_required'
confirmation: PaymentConfirmation | ConsentConfirmation
verified_by: string // logging only — agent must NOT branch on this
}
type PaymentConfirmation = {
scheme: string // matches one of original accepted_payments[].scheme
original_payload: Record<string, unknown> // verbatim payload echoed back for binding check
transaction?: string // tx hash / payment_intent_id / PG txn id
payer?: string // wallet address / customer email
network?: string // 'base', 'base-sepolia' — meaningful for x402
}
type ConsentConfirmation = {
scope_hash: string // sha256 over canonical JSON of scope
expires_at?: string // ISO 8601 UTC; absent = until revoked
}
Verification rule: the agent MUST look up in_reply_to_state against its OWN issuance store before acting. Presence of the field is not proof. The verified_by field is for logging and auditing only — agents do NOT branch on it for business logic.
Resumption Patterns
| Pattern | Where the receipt arrives | Used by |
|---|---|---|
| Pattern 1 — in-band | Transport itself (e.g. A2A metadata.mentionable.policy_resolution, REST request body). Adapter detects → verifies → sets policy_resolution before invoking agent. | A2A, REST. |
| Pattern 2 — out-of-band | Agent’s own callback endpoint (e.g. /api/x402/callback, /api/stripe/webhook). Endpoint verifies, then synthesizes a new NormalizedMessage with policy_resolution set and dispatches to the agent. | Email, ActivityPub, Slack, Teams. |
Wire Mappings
| Transport | How PolicyPart is carried |
|---|---|
| A2A | Final task message: task.status.message.metadata.mentionable.policy = <PolicyPart>. Streaming: PolicyPart in the FINAL frame only. |
Plain reply containing the message and url. The X-Mentionable-Policy header carries the structured PolicyPart for email clients that grok it (most don’t — DKIM-signed reply text is the user-visible surface). | |
| REST | HTTP status code maps directly: 401→unauthorized, 402→payment_required, 403→forbidden, 429→too_many_requests, 451→unavailable_for_legal_reasons, 503→service_unavailable. JSON body carries the full PolicyPart. consent_required maps to 401 with a body discriminator. |
| ActivityPub | The reply object body contains rendered text plus url; structured PolicyPart attaches under an https://mentionable.dev/ns/policy/v0.1 extension namespace. |
Security Rules
urlMUST be origin-bound to the agent’s canonical host (resolved from WebFinger). HTTPS only. No userinfo component. Receivers MUST reject mismatched origins.stateMUST be CSPRNG-issued, ≥128 bits. Single-use. Bound to the issuing agent + audience. Single attempt to consume.return_toMUST be HTTPS and origin-bound to the agent’s canonical host.- Streaming. PolicyPart MUST appear in the FINAL frame only. Mid-stream PolicyPart is malformed.
- Fan-out. When orchestrating sibling agents, propagate downstream PolicyParts to the user — do not silently swallow a
payment_requiredfrom a sub-agent. See multi-agent-composition.
Agent Capability Declaration
An agent declaring conformance to PolicyPart v0.1 publishes the extension URI in its agent card:
{
"a2a": {
"capabilities": {
"extensions": [{ "uri": "https://mentionable.dev/ns/policy/v0.1" }]
}
}
}
A2A-aware callers see the extension and know they can render in-band payment / consent UIs. Callers that ignore the extension fall back to the human-readable message + url.
Common Mistakes
- Emitting a PolicyPart mid-stream. MUST be the final frame.
- Not verifying
in_reply_to_stateagainst the agent’s issuance store. The wire field is a hint, not proof. - Branching agent logic on
policy_resolution.verified_by. That field is logging-only. - Pointing
urlat a third-party host. Origin-bind to the agent’s canonical host; use a redirect from your own host if you must hand off. - Reusing a
statetoken. Single-use; reject the second consumption. - Returning
payment_requiredwithoutaccepted_paymentsorunauthorizedwithoutauth_challenges. Both are validation errors per spec §3.6.