Research LabSpec draft 0.2
Open Action Consent
Signed previews, consents, mandates and hash-chained receipts for agent tool calls. JCS canonical JSON, compact JWS (EdDSA or ES256), JSON Schema 2020-12 and an MCP binding.
- 6objects: profile, preview, consent, mandate, receipt, undo
- 4tiers, A to D
- 241 / 241conformance vectors
- ~22,600differential inputs, identical answers in both SDKs
- 0runtime dependencies (TypeScript core)
The consent flow.
The executor builds the Preview from the exact action it will run. The person's signer signs that Preview. The executor runs the action only if the signature, the binding and the limits check out, then signs a chained receipt.
- 1Agent → ExecutorTool call
{"tool": "send_email", "args": {...}} - 2ExecutorBuild the Preview
fields bound to args, details, digest(action), exp at most 900 s - 3Executor → Person's signerShow the Preview
summary, every field, every detail, the undo kind - 4Person's signer → ExecutorConsent
JWS typ oac-consent+jws, items: [preview digest, action digest] - 5ExecutorVerify, then run
envelope, aud, sub, time, digests, binding, tier, jti (atomic) - 6Executor → AgentResult and Receipt
JWS typ oac-receipt+jws, prev = token_digest of the last receipt
Signed objects, real output.
The README quickstart, run through the Python SDK. Pick an object.
{
"oac": "0.2",
"type": "action-profile",
"tool": "send_email",
"effect": "send",
"undo": {
"kind": "none",
"window_s": 0
},
"tier_floor": "A",
"binds": {
"to": "to",
"title": "subject"
},
"other_args": [
"body"
]
}{
"tool": "send_email",
"args": {
"to": [
"sam@example.com"
],
"subject": "Lunch?",
"body": "Friday 1 pm?"
}
}{
"oac": "0.2",
"type": "preview",
"id": "pv_smixm5YNB0sL2avM",
"executor": "mail.example",
"sub": "person:1",
"tool": "send_email",
"action": "sha-256:waru-WFOeoKrn1L-RXSg_nw20tkeDbvJ_d7fs4ibOSo",
"summary": "Email sam@example.com: Lunch?",
"fields": {
"to": [
"sam@example.com"
],
"title": "Lunch?"
},
"undo": {
"kind": "none",
"window_s": 0
},
"details": {
"body": "Friday 1 pm?"
},
"iat": 1793836800,
"exp": 1793837400
}// protected header
{
"alg": "EdDSA",
"kid": "AA0YJDAEtnJpcjEW_Btq1yb82mEZDSs0NfFa3NoWG0c",
"typ": "oac-consent+jws"
}
// payload (canonical JSON, RFC 8785)
{
"aud": "mail.example",
"exp": 1793837100,
"iat": 1793836800,
"iss": "signer.example",
"items": [
{
"action": "sha-256:waru-WFOeoKrn1L-RXSg_nw20tkeDbvJ_d7fs4ibOSo",
"preview": "sha-256:HubtB46BF5kUyc2aMMQvvpgU1OccC3faO_DBs7RybuM"
}
],
"jti": "jcjkCkVpfV-ihLG5rfa_UA3I",
"oac": "0.2",
"signer": {
"kind": "custodial"
},
"sub": "person:1",
"type": "consent"
}Real objects: open-action-consent 0.4.0 (Python), README quickstart, now = 1793836800. Key ids and nonces differ on every run.
Verification, in order.
The executor runs these checks in this order and reports the first failure's code. The nonce is recorded last and atomically, so a refused Consent does not use it up and parallel retries run at most once.
- 1Envelope, then fields
on failure: malformed, not_canonical, bad_type, bad_alg, unknown_key, bad_signature, wrong_issuer - 2Audience and subject
on failure: wrong_audience, subject_mismatch - 3Time
on failure: not_yet_valid, expired, lifetime_too_long - 4One Preview and action per item
on failure: preview_mismatch - 5Per item: digests, executor, profile, every argument declared, binding, tier
on failure: preview_mismatch, action_mismatch, executor_mismatch, preview_expired, unknown_tool, undeclared_argument, binding_mismatch, tier_d - 6Payment only with a paying item
on failure: payment_mismatch - 7Device proof, if required
on failure: WebAuthn checks (spec 3.6) - 8Nonce, last and atomic
on failure: replay
The same Consent, three calls (real verdicts from the SDK)
| Call | Result |
|---|---|
| the action the person saw | accepted |
| the agent adds a bcc after the yes | refused action_mismatch |
| the agent changes the recipient | refused action_mismatch |
Both tampered calls fail at check 5: the action no longer hashes to the digest the person signed.
Risk tiers, A to D.
| Tier | Meaning | Needs |
|---|---|---|
| A | read-only, or no effect outside the person's own data | nothing; a Receipt is optional |
| B | a write the executor can take back itself (native undo) | runs; must produce a Receipt with an Undo token |
| C | sends, pays, deletes shared data, or cannot be undone in one step | a Consent bound to its Preview, or a Mandate that covers it |
| D | forbidden | must be refused, whatever is presented |
The tier is computed, never declared
A hint (for example from a model) may raise the tier; because the result is a maximum, it can never lower it. Taint is decided by the host runtime, never by the model.
implied(profile) = A if effect is read
= B if effect is write or delete and undo.kind is native
= C otherwise
tier = max(implied(profile), profile.tier_floor, executor policy floor,
C if the run is tainted and effect is not read,
any hint)Bindings and conformance.
OAC sits next to MCP, A2A, AP2 and ACP, not on top of them. A payment Consent points to the rail's own authorization; OAC never moves money or holds a card.
- MCP serversPython
oac.mcp, TypeScriptopen-action-consent/mcp. Tier C answers with a URL-mode elicitation; receipts ride in_meta["com.atlasiw/oac-receipt"]. - OpenAI Agents SDK
oac.openai_agents. The framework's own approval alone runs nothing without the person's signed Consent. - LangGraph
oac.langgraph, same rule: a signed Consent or nothing runs. - Vercel AI SDK 7
open-action-consent/vercel-ai(withai). - Gated Browser
oac-gated-browser: final clicks only with a signed yes on that exact page; page-change refusal, no secrets typed. - Approval page
oac.approver: self-hostable; a non-extractable WebCrypto key in the browser signs the Consent.
Conformance
241 shared cases: valid ones and planted defects. Python verifies tokens signed by TypeScript, and the Python tests switch off each safety check one at a time to confirm a vector then fails.
- forged signature
- changed arguments
- hidden bcc
- two addresses in one
- replay
- expired, revoked or widened mandate
- one signer speaking for another
- signature twins
- broken receipt chain
- rewritten history under a seal
- double undo
- passkey proof from the wrong origin
- the same order paid twice
Install Open Action Consent.
- Python
pip install open-action-consent - JavaScript / TypeScript
npm install open-action-consent
The core needs only cryptography in Python and nothing in TypeScript; each part pulls in its framework only when you ask for it.
Use it in your code
Build a Preview, have the person sign it, verify the Consent before the action runs. In TypeScript, wrap the tools of an MCP server.
from oac import build_preview, make_consent, verify_consent, generate, KeySet, MemoryNonces
profile = {"oac": "0.2", "type": "action-profile", "tool": "send_email", "effect": "send",
"undo": {"kind": "none", "window_s": 0}, "tier_floor": "A",
"binds": {"to": "to", "title": "subject"}, "other_args": ["body"]}
action = {"tool": "send_email", "args": {"to": ["sam@example.com"], "subject": "Lunch?", "body": "Friday 1 pm?"}}
preview = build_preview(profile=profile, action=action, executor="mail.example", sub="person:1",
summary="Email sam@example.com: Lunch?", now=1793836800)
user_key = generate() # lives with the person's signer, never with the model
consent = make_consent(key=user_key, iss="signer.example", sub="person:1", aud="mail.example",
previews=[preview], now=1793836800)
verify_consent(consent, keys=KeySet({"signer.example": [user_key.jwk]}), executor="mail.example",
sub="person:1", previews=[preview], actions=[action], profiles={"send_email": profile},
now=1793836810, nonces=MemoryNonces())import * as z from "zod";
import { McpServer } from "@modelcontextprotocol/server";
import { OAC, ID } from "open-action-consent/mcp";
const oac = new OAC({ executor: "https://tables.example/oac", key: executorKey, signers: signerKeys,
consentUrl: (p) => `https://signer.example/consent?r=${p.id}`, person: (ctx) => userIdFromYourAuth(ctx),
tainted: (ctx) => yourHostSaysOutsideTextEntered(ctx) });
const server = new McpServer({ name: "tables", version: "1.0.0" }, { capabilities: { tools: {}, extensions: { [ID]: oac.settings() } } });
oac.registerTool(server, "book_table",
{ description: "Book a table", inputSchema: z.object({ restaurant: z.string(), time: z.string(), party: z.number() }) },
{ effect: "write", undo: "compensate", windowS: 86400, binds: { payee: "restaurant", when: "time", count: "party" } },
async ({ restaurant, time, party }) => ({ content: [{ type: "text", text: "Booked." }] }));The specification.
Draft 0.2, CC BY 4.0, not endorsed by any standards body. Objects say "oac":"0.2"; earlier objects are refused.
- 3Encoding (opens in a new tab)
- 4Tiers (opens in a new tab)
- 5Action Profile (opens in a new tab)
- 6Preview and Consent (opens in a new tab)
- 7Mandate (opens in a new tab)
- 8Receipts and Undo (opens in a new tab)
- 9Payments (opens in a new tab)
- 10Bindings (opens in a new tab)
- 12Security considerations (opens in a new tab)
- 13Conformance (opens in a new tab)
Open gaps (help wanted)
| Mapping to EP authorization receipts | the exact signed-byte mapping between a device proof and an EP authorization context is open |
|---|---|
| Mandates in the gates and MCP plug-ins | check_mandate / checkMandate work, but the reference gates and plug-ins only accept a Consent per action |
| Durable stores | the reference nonce, occurrence and receipt stores live in memory; a production executor needs database-backed ones |
| More passkey types | device proofs accept ES256 credentials only, and the signature counter is not checked |
Changelog.
Every release, newest first.
GitHub (opens in a new tab)PyPI (opens in a new tab)npm (opens in a new tab)Specification (opens in a new tab)Test vectors (opens in a new tab)Full changelog (opens in a new tab)
| Version | Date | What changed |
|---|---|---|
| 0.4.0 | Nov 5, 2026 | Wire version 0.2: every object says "oac":"0.2" and 0.1 objects are refused. Passkey device proofs in the gates and MCP plug-ins, occurrence scope shared across tools, WebAuthn backup flags, Python and TypeScript export parity. 241 shared test vectors. |
| 0.3.0 | Nov 5, 2026 | Changes from an outside review: receipts name the exact token the executor verified, WebAuthn passkey proofs over a Consent, and business occurrences, so one order is paid at most once. 230 shared test vectors. |
| 0.2.1 | Nov 5, 2026 | No code or protocol changes. npm releases are staged and go live only after a maintainer approves them. |
| 0.2.0 | Nov 5, 2026 | One package per language, with optional parts: MCP, the Gated Browser, a self-hostable approval page, and adapters for the OpenAI Agents SDK, LangGraph and the Vercel AI SDK. |
| 0.1.0 | Nov 4, 2026 | First public draft: Action Profile, Preview, Consent, Mandate, Receipt and Undo; tiers A to D; an MCP binding; Python and TypeScript reference SDKs; 205 conformance vectors. |
Built while making Atlas.
Atlas is the AI workspace these tools come from. Free during the private beta.