YEA — design decisions
Each section records a decision, the alternatives we considered, and why we chose what we did. Where a measurement drove the choice, the numbers are included.
A protocol, not conventions on HTTP
Decision. YEA defines its own frames, verbs, reply kinds, errors and authorization. HTTP is one of four transports (§2.4), not the substrate.
Alternatives. (a) REST conventions plus headers (Prefer: preview, Idempotency-Key, OAuth scopes). (b) A JSON-RPC method set (MCP's approach). (c) GraphQL mutations with a dry-run flag.
Why. What an agent needs has to hold for every service it talks to: a preview before side effects, consent bound to exactly what was previewed, delegated authority with limits, output that fits a budget, and a text form the model can read. Conventions get adopted piecemeal, and a preview header that only a third of APIs honor is worse than none, because the agent can't rely on it. The same logic made HTTP a protocol rather than a set of conventions on FTP. We kept an HTTP bridge because reach matters: it runs on serverless platforms and passes through proxies, while the semantics stay YEA's.
Intent → proposal → commit
Decision. State changes go through INTENT (inert), then PROPOSALS, then COMMIT. ASK is read-only by definition.
Why. It's the minimal structure that makes an agent's mistakes cheap. The service, which knows its own domain, spells out the consequences: effects, what it uses, risk and undo window. The agent, or a human, commits to exactly that, and the proposal hash guarantees it. A side benefit is that services can offer alternatives ("standard or express") that CRUD can't express.
The cost we measured. The preview adds a round trip. In an early, unpublished run of the benchmark (before the changes below), that made YEA use about 1.5× the total input tokens of plain REST on a two-step order task, because every turn re-reads the context. The fix is below.
Policy-gated auto-commit
Decision. INTENT with auto: true commits the first proposal immediately if, and only if, a grant authorized the INTENT, a grant from that grant's principal authorizes the commit without consent, and the proposal is undoable (§4.3.1). An INTENT no grant authorized has no principal: it never auto-commits, and only a consent commits its proposals (§4.4). Through a tool host, a plain COMMIT is the model's, not a person's, so it can't stand in for the principal's approval (#287).
Alternatives. An agent-side flag with no service check, which is unsafe. A separate DO verb, which duplicates INTENT's semantics. Always preview, which we measured as too slow.
Why. It puts the review/speed trade-off where it belongs, in the principal's signed policy. "My agent may move meetings and spend up to $40 on anything it can undo" becomes one round trip. Anything outside that falls back to proposals automatically. Irreversible actions never auto-commit. In the scripted payload benchmark this took the reschedule task from 3 calls to 1, which saves 36% against REST's CRUD calls (YEA sends 51% more than an equivalent outcome-level REST endpoint). Over all tasks YEA sends 2% fewer tokens than minified-JSON REST (17% fewer than pretty JSON), though the tool definitions yea mcp ships are still about 1.5 times REST's. The first published figure, 32% fewer, measured an older tool surface; see #297 and #300. In live agent runs (bench/agent-eval), total cost comes out about even (+3–15% per task): turns dominate cost there, and models don't always hand their goal straight to an intent. When they do, it's 1 call instead of 3.
Auto frames are replay-safe: the proof is bound to auto:{capability}:{frame id}, and services dedupe (key, id) for 600 s. An independent implementer found that attack during review.
Lens: the model's view is part of the spec
Decision. Every reply has a canonical text rendering, specified to the byte (§9) and checked by conformance vectors.
Alternatives. Leave presentation to clients (what every API does today). Markdown (ambiguous and verbose for data). YAML (whitespace-significant and verbose for lists of records). Pure JSON (what REST gives models: quotes, braces and repeated keys on every row).
Why. The model is the client, so its view is part of the interface. Specifying it buys three things:
- Compactness where it counts. Uniform lists become tables that name their keys once (
items[60]{sku,name,usd,cal,protein}:), strings are unquoted when safe, and effects use one-character operators (~ update,+ create,> send). The 60-item menu costs 1,095 tokens in Lens against 1,919 minified and 3,019 pretty-printed. - Consistency across services. A model that has read one YEA receipt can read all of them.
- Budgets that mean something. A budget constrains the rendering the model actually reads, not a JSON byte count.
Shared proposal attributes (uses, risk, undo, expiry) are stated once in the header. Receipts don't repeat effects the model has already seen, unless the service auto-committed. Both rules came out of the benchmark.
Budgets and the shared token estimate
Decision. Budgets are in "tokens" measured by one regex that every implementation computes identically (§8), not by any model's tokenizer.
Alternatives. Bytes/4 (the classic rule of thumb, and our first draft). A real tokenizer (model-specific, large, and different in every language).
Measurement. Compared to o200k BPE counts over 63 texts of at least 15 tokens each (58 conformance Lens outputs, 2 benchmark payloads, and the menu as minified JSON, pretty JSON and Lens). To regenerate the table, run npm run build && node bench/estimator.ts from the repo root:
| Estimator | mean ratio to real tokens | min | max |
|---|---|---|---|
ceil(bytes / 4) | 1.31 | 0.33 | 5.02 |
ceil(bytes / 3) | 1.75 | 0.43 | 6.69 |
[A-Za-z]+|[0-9]{1,3}|\n {2,}|[^ \t\n\r\f\vA-Za-z0-9] | 1.01 | 0.77 | 2.17 |
Bytes/4's mean above 1 doesn't mean it overcounts. It undercounts 51 of the 63 texts, by up to 3×. The mean is pulled up by 11 indentation-heavy conformance vectors (mostly deep-nesting tests), where runs of spaces are many bytes but few tokens, and there it overcounts by 2.7× to 5×. It lands between 0.8× and 1.25× the real count on 2 texts; the regex does on 55. On the full menu as Lens, bytes/4 estimates 623 tokens when it's really 1,095, so an "800-token" budget let the whole thing through (the regex says 994).
The regex isn't exact either. Its 0.77 floor means real tokens can run about 1.3× over the estimate, so a budget is approximate in real tokens, not a hard cap. At the top it overcounts runs of JSON punctuation, which BPE merges into single tokens: 1.91× and 2.17× on the two conformance vectors with deeply nested brackets or braces, and 1.55× on the menu as minified JSON, which isn't Lens. Every other Lens output falls between 0.77 and 1.47. The regex is four alternatives long.
Elision safety. Budget fitting may drop whole trailing proposals or capabilities, and may elide anything inside data and result. It may never alter a proposal's effects, summary or hash, because the model has to see exactly what it would commit. (The first TS draft could truncate effects inside the proposals list. The Python implementer caught it.)
Grants
Decision. Authorization uses public-key capability chains: a principal signs a root block (holder key and caveats), and each holder may append a block that delegates a narrower grant to another key. Every request that uses a grant carries a proof of possession signed by the holder key.
Alternatives.
- API keys / bearer tokens: all-or-nothing, and a leak is total.
- OAuth 2 scopes / JWT: identity-centric, and scopes are coarse strings. There's no standard for spending limits or risk ceilings, and delegating to a sub-agent means another round trip to an authorization server.
- Macaroons (HMAC): the right attenuation model, but verification needs the root secret, so only the minting service can check them.
- Biscuit: public-key and attenuable, with a Datalog policy language. Powerful, but a heavy dependency for every implementer, and its generality works against a fixed fail-closed caveat set.
- UCAN: the closest relative. It's built on DIDs and JWT/IPLD encodings and designed for resource capabilities. YEA's needs are narrower and more agent-specific: spend accounting, risk ceilings, and consent bound to a proposal hash.
Why this shape.
- Offline and decentralized. Any service that trusts a principal's key can verify a chain without calling anyone, and delegating to a sub-agent needs no server.
- Monotonic. Caveats only accumulate, so a delegation can only narrow.
- Fail-closed. Unknown caveats and malformed values are hard failures, so an older service never over-authorizes a newer grant, and
{"svc": "a.example"}(a string, not a list) doesn't sneak past through a substring match. - Agent-shaped caveats:
eachandtotal(limits on anything an action uses),risk(a ceiling on what the agent may do without asking), andonly(consent bound to one proposal hash). - Consent is just a grant. A human approval is a root grant with
[{svc: [service]}, {verbs: ["COMMIT"]}, {can: [capability]}, {only: hash}, {exp: …}]. No new machinery, and it's cryptographically bound to the exact effects the human saw. The first draft had onlyonlyandexp. BecauseonlyconstrainsCOMMITalone, that grant would have authorized every other verb, includingUNDOof unrelated orders, until it expired. The Python implementer found it by exploiting it, and the scoping caveats are now required by the spec. - Soft vs hard failures. If only
each,totalorriskfail, the service repliesconsent_requiredrather thanforbidden. That's the protocol-level "ask the human."
Measures, not money
Decision. Proposals report what a commit would use up in uses, a map from a service-chosen measure name to an exact decimal quantity ({"spend": {"amount": 2287, "scale": 2, "unit": "USD"}, "emails": {"amount": 1}}). Grants limit any measure with each and total. The spec gives names and units no meaning, and money is a convention, not part of the protocol.
Why. Draft 1 had a cost field in currency minor units and per/spend caveats in a currency, so the only thing a person could limit was money, and Lens carried a table of zero-decimal currencies. Agents do plenty that isn't spending but still needs a ceiling: emails sent, records deleted, invitations. Changing it before the first release cost nothing on the wire.
Details that matter.
- Names, not just units. A limit names the measure (
of: "spend") as well as the unit, so aspendin EUR fails a USDspendlimit, and asks the person, instead of slipping past as an unrelated unit. Nothing is converted. - Exact decimals.
amountis an integer andscaleplaces the point, so values compare exactly across scales (100at scale 0 equals10000at scale 2) with no floating point. Bounds (amount≤ 2^53−1,scale≤ 18) keep JavaScript and Python in agreement. - Only what's reported is bound. A proposal that doesn't report a measure passes a limit on it. Services are already trusted to describe their effects; this is the same trust.
Wire format
Decision. UTF-8 JSON frames, newline-delimited, multiplexed by id, over any reliable byte stream (TCP, TLS, stdio) or the HTTP bridge.
Alternatives. Protobuf/gRPC (a schema compiler for every implementer and opaque on the wire). CBOR (compact, but it saves bytes where bytes don't matter). HTTP/2 streams.
Why. An agent round trip costs seconds of model time and thousands of tokens. Serialization overhead is noise next to that. JSON is universal, debuggable with nc, and trivially implemented: the Python implementation needed no dependencies beyond Ed25519. NDJSON gives streaming (EVENT progress frames) and multiplexing for free.
Canonical JSON for hashing and signing allows only integers. Floats have no cross-language canonical form, so money is in minor units (1250 = 12.50 USD). Proposal data is excluded from the hash, so services can put floats there.
Why TypeScript for the reference implementation
Decision. The reference is TypeScript with zero runtime dependencies, using WebCrypto Ed25519. The second implementation is Python, built independently from the spec.
Alternatives. Rust (then bindings), Go, or Python first.
Why.
- Where agents run. Agent harnesses, MCP servers, edge functions and browsers are overwhelmingly TS/JS and Python. A reference in one of them is directly usable; a Rust core would ship through bindings in both.
- One codebase, every runtime. The core uses only web-standard APIs (WebCrypto,
fetch, streams), so the same build runs on Node ≥ 20, Bun and Deno (all tested). It targets Cloudflare Workers (fetchHandler) and browsers (thehttpclient) too, though those aren't in CI yet. - Performance is irrelevant here. Verifying a grant is microseconds, while a model turn is seconds. There's no hot path for Rust to win.
- Two languages keep the spec honest. The Python implementation started from
SPEC.mdand the vectors (its first cut passed 63 of 67 vectors before its author had read any TS). It was then aligned with the TS source where the spec was silent. That cross-reading surfaced real gaps and bugs: Lens float formatting, rounding, fail-open caveats, elision of effects, replayable auto frames, and a consent grant that was far broader than one proposal. Each fix went into the spec and vectors, not just the code.
Security review
After both implementations existed, the TypeScript reference got an adversarial audit. It confirmed 15 findings, and every one now has a regression test in ts/test/security.test.ts plus, where it's protocol-level, a SPEC change:
- High: a malformed or aborted HTTP request crashed the bridge process. Concurrent commits could exceed a
spendcap (now atotallimit), because spend was counted after execution; it's now reserved atomically (§6.3). A malicious service could get the MCP bridge to ask the human to sign a consent for a different service's proposal behind a friendly summary. Consent is now built only from the proposal the bridge showed, and it's checked against the service's request (§6.6). - Medium: a proposal from an anonymous
INTENTcould be committed by someone else's agent. Proposals are now bound to the requesting key (§4.4). A replayedCOMMITcould leak another principal's receipt, and theconflicterror leaked the hash. A retried commit could be refused by the spend it had itself used up. Levenshtein "did you mean" suggestions were a CPU DoS on huge names. - Low: unbound
EXPANDhandles, a dedupe window shorter than proof validity,risk: "toString"failing open, a mismatchedreon concurrent failingUNDOs, grant selection that ignored the proposal's principal, an unenforced 1 MiB frame limit, unbounded state maps, andcheckGrantthrowing on anullcaveat. - Base64url and local state (GHSA-8mvj-4xpr-rmqw), fixed before the first release: base64url decoding accepted more than one spelling of the same bytes, so a grant's signature could be re-encoded into a new block id and its
totallimit counted from zero again. Keys, seeds, signatures and grants now decode only from canonical base64url (§6.1), and Python checks the strict alphabet too. Alongside it: the principal key path is checked link by link and read without following a symlink or opening a FIFO; the server key directory may not be a symlink or group-writable; store files are 0600 in 0700 directories; an undo claim re-checks whether the undo already finished; asvccaveat covers a service only if every list names it; out-of-range approval state is refused; and Python's client checksINTENTreplies and sweeps service state as TypeScript does.
Non-goals and known limits (v1)
The principal key must be isolated from the agent. Everything rests on it. If an agent can read the principal key, it can sign its own consent. The CLI supports keeping it elsewhere (
YEA_PRINCIPAL_HOME), and approval requires an interactive terminal, but a local shell-capable agent on the same account defeats both.Services are trusted to describe their own effects. YEA makes the description explicit and binds commits to it, but a malicious service can still lie. Signed receipts (below) make lies attributable.
No revocation. Keep grants short-lived with
exp.eachalone doesn't bound anything. A per-commit limit can be dodged by splitting. In our real Claude session the model pointed this out itself and declined to do it.totalbounds overall exposure, but splitting within it is still possible, so sizetotalas the most you're willing to lose.totalaccounting is per service. A grant used at two services has two independent totals. Scope limited grants withsvc.Limits bind only what services report. A service that under-reports
usesisn't stopped by a limit, just as a service that lies about its effects isn't.Lens is English-centric. Its keywords (
uses,undo,risk) are fixed tokens, not UI strings.
Roadmap
- Revocation lists, published by principals and checked by services.
HOLD: multi-party atomic commits. Collect proposals from an airline and a hotel, hold both, then commit both or neither.- Signed receipts for non-repudiation and audit logs a principal can verify.
- Discovery by DNS (
_yeaTXT → endpoint), alongside/.well-known/yea. - QUIC / WebTransport transport.
- More implementations. Go and Rust ports are welcome, and
conformance/is the contract.