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.

v0.4.0 Nov 5, 2026 Python and TypeScript Apache-2.0 code, CC BY 4.0 spec

  • 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.

  1. 1
    Agent → ExecutorTool call{"tool": "send_email", "args": {...}}
  2. 2
    ExecutorBuild the Previewfields bound to args, details, digest(action), exp at most 900 s
  3. 3
    Executor → Person's signerShow the Previewsummary, every field, every detail, the undo kind
  4. 4
    Person's signer → ExecutorConsentJWS typ oac-consent+jws, items: [preview digest, action digest]
  5. 5
    ExecutorVerify, then runenvelope, aud, sub, time, digests, binding, tier, jti (atomic)
  6. 6
    Executor → AgentResult and ReceiptJWS typ oac-receipt+jws, prev = token_digest of the last receipt
Change “2 people” to “20” after the yes, or slip in a bcc, and step 5 refuses the call.

Signed objects, real output.

The README quickstart, run through the Python SDK. Pick an object.

Action 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
{
  "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
}

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.

  1. 1
    Envelope, then fieldson failure: malformed, not_canonical, bad_type, bad_alg, unknown_key, bad_signature, wrong_issuer
  2. 2
    Audience and subjecton failure: wrong_audience, subject_mismatch
  3. 3
    Timeon failure: not_yet_valid, expired, lifetime_too_long
  4. 4
    One Preview and action per itemon failure: preview_mismatch
  5. 5
    Per item: digests, executor, profile, every argument declared, binding, tieron failure: preview_mismatch, action_mismatch, executor_mismatch, preview_expired, unknown_tool, undeclared_argument, binding_mismatch, tier_d
  6. 6
    Payment only with a paying itemon failure: payment_mismatch
  7. 7
    Device proof, if requiredon failure: WebAuthn checks (spec 3.6)
  8. 8
    Nonce, last and atomicon failure: replay

The same Consent, three calls (real verdicts from the SDK)

CallResult
the action the person sawaccepted
the agent adds a bcc after the yesrefused action_mismatch
the agent changes the recipientrefused 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.

TierMeaningNeeds
Aread-only, or no effect outside the person's own datanothing; a Receipt is optional
Ba write the executor can take back itself (native undo)runs; must produce a Receipt with an Undo token
Csends, pays, deletes shared data, or cannot be undone in one stepa Consent bound to its Preview, or a Mandate that covers it
Dforbiddenmust 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, TypeScript open-action-consent/mcp. Tier C answers with a URL-mode elicitation; receipts ride in _meta["com.atlasiw/oac-receipt"].
  • OpenAI Agents SDKoac.openai_agents. The framework's own approval alone runs nothing without the person's signed Consent.
  • LangGraphoac.langgraph, same rule: a signed Consent or nothing runs.
  • Vercel AI SDK 7open-action-consent/vercel-ai (with ai).
  • Gated Browseroac-gated-browser: final clicks only with a signed yes on that exact page; page-change refusal, no secrets typed.
  • Approval pageoac.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.

Python
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())
TypeScript, MCP server
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.

  1. 3Encoding (opens in a new tab)
  2. 4Tiers (opens in a new tab)
  3. 5Action Profile (opens in a new tab)
  4. 6Preview and Consent (opens in a new tab)
  5. 7Mandate (opens in a new tab)
  6. 8Receipts and Undo (opens in a new tab)
  7. 9Payments (opens in a new tab)
  8. 10Bindings (opens in a new tab)
  9. 12Security considerations (opens in a new tab)
  10. 13Conformance (opens in a new tab)

Open gaps (help wanted)

Mapping to EP authorization receiptsthe exact signed-byte mapping between a device proof and an EP authorization context is open
Mandates in the gates and MCP plug-inscheck_mandate / checkMandate work, but the reference gates and plug-ins only accept a Consent per action
Durable storesthe reference nonce, occurrence and receipt stores live in memory; a production executor needs database-backed ones
More passkey typesdevice proofs accept ES256 credentials only, and the signature counter is not checked

Changelog.

Every release, newest first.

VersionDateWhat changed
0.4.0Nov 5, 2026Wire 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.0Nov 5, 2026Changes 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.1Nov 5, 2026No code or protocol changes. npm releases are staged and go live only after a maintainer approves them.
0.2.0Nov 5, 2026One 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.0Nov 4, 2026First 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.