Project

Tenon — Typed, Reversible Actions for a Shared Document

Deep dive into Tenon, an open protocol and Rust journal that let humans and AI agents edit the same document through typed, scoped, reversible operations, with adapter-supplied inverses and a hash-chained audit log.

3,908 words20 min read
On this page

A note on this write-up. Written with AI assistance. The architecture, code, and decisions are mine; the prose was drafted with an AI editor and reviewed before publishing.

Welcome to Tenon

MCP gave agents tools. Tenon gives them a shared document.

Tenon is an open protocol for one visual workspace edited by a person and an AI agent. Instead of the agent taking a screenshot and clicking coordinates, it sends a typed operation: create this trendline, update that series, merge these two nodes. A server owns the op log, revisions, capabilities, and a hash-chained audit trail. An adapter owns the domain: it validates the operation, applies it to the document, and returns the inverse that undoes it.

The repository contains the whole spine: a normative protocol in spec/, five TypeScript packages, a Rust journal server built with Axum and SQLite, an MCP bridge, and six conformance fixtures. The first reference app is Amaterasu, a chart analyst that draws on a market chart. The second adapter is Weave, the knowledge graph from the previous write-up. They share an envelope, an ack, a turn model, and an audit chain, and nothing else.

That is the point. Tenon is not a chart feature and it is not a graph feature. It is the layer that makes both of them safe for an agent to edit.

Amaterasu, the chart reference app. A typed chart op is being composed in the op console; the chart shows the resulting range and trendline, and the ack list below records both applied ops.

The Amaterasu demo: data-space points in the op JSON on the right, the rendered annotations on the chart, and one ack per applied op. The demo uses deterministic sample data and has the agent disabled, because it is a static build.

The Missing Layer

A computer-use demo is easy to make compelling. The agent looks at a screenshot, moves a cursor, clicks a toolbar, drags a line across a chart, and types a summary. For thirty seconds it looks like the future.

Then the chart reflows. The user switches from daily to five-minute candles. The browser zoom is not 100%. A panel opens over the canvas. The agent clicks a coordinate that used to be a trendline. The line it drew is a sequence of pixels, not an object. There is no id, no inverse, no owner, and no record of which model made it. Undo removes the wrong thing, or nothing at all.

This is not a vision problem. The model can see the screen. The problem is that the screen is the wrong interface for an action.

MCP solved a real part of the integration problem: an agent can discover a tool and call it. But a tool call is still a stateless verb. draw_line(x1, y1, x2, y2) does not describe the document it belongs to, the object it creates, whether the user can undo it, who authorized it, or whether it was already applied. MCP standardizes how an agent reaches a tool. It says nothing about what happens to the document afterwards.

Tenon starts from four invariants:

  • Typed. An action is a semantic operation against a known object model, not a coordinate. A trendline has anchors in data space. A graph merge has a source and a target.
  • Reversible. Every applied operation carries enough information to undo it. The adapter owns the inverse because only the adapter knows what the operation meant in its domain.
  • Attributable. Every attempt records the actor, the turn, the capability, the target, and the time. Applied and rejected attempts both belong in the record.
  • Scoped. An agent does not inherit the user's full authority. It gets a capability: a document, a set of op types, permissions, an expiry, and optionally a human approval requirement.

There is a fifth property that falls out of the other four: accountable. The audit log is append-only and hash-chained, so the record can be verified rather than trusted.

Tenon is also deliberately not a CRDT. Conflict-free merge assumes writers do not need to explain themselves. Here order and attribution are the product, so edits are serialized into turns that can be attributed and undone. Multiplayer and offline conflict resolution are later work, not hidden assumptions.

The Authority Split

The most important design decision in Tenon is not the envelope. It is who owns what.

One operation from intent to audit. The agent sends an envelope; the server checks order, policy, and idempotency; the adapter validates and applies the domain operation; the ack returns the inverse; the server appends the attempt to the audit chain.

Two authorities, deliberately split:

  • The server owns op ids, per-document revisions, the append-only log, idempotency, capabilities, approvals, and the audit chain. It can always answer what happened, in what order, and who authorized it.
  • The adapter owns anchor resolution, projection, rendering, hit-testing, domain validity, and inverses. It is the only component that knows what a trendline means or how a graph merge should rewire edges.

The server does not hold a document reducer. It holds a log, a materialized snapshot for fast reads, and the rules around them. When a turn is undone, the server does not know how to reverse a trendline. It loads the inverses the adapter returned when the original ops were applied and replays them.

This is a deliberate deviation from the original build plan, which had the server owning the document store and undo. The spec records the change in its deviations section. The implementation made the reason obvious: a domain-neutral server cannot validate a chart anchor or compute a graph merge without learning every domain, and a server that learns every domain is no longer domain-neutral.

The split also gives each side a clean failure mode. If the server rejects an operation, the agent knows it is a scope, order, or policy problem. If the adapter rejects it, the agent knows the data or the domain is wrong. The rejected_by field says which.

Human edits use the same road. The browser applies a local edit, then records it through /records with its own inverse and snapshot. The audit chain does not care whether the actor was a person, an agent, or an import job.

One Envelope

Every operation is wrapped in the same envelope. The domain coordinates never appear at the top level.

JSON
{
  "v": 1,
  "doc_id": "doc_reliance",
  "turn_id": "turn_ses_9f2:msg_41",
  "seq": 1,
  "actor": { "kind": "agent", "id": "chart-analyst", "session": "ses_9f2" },
  "capability": "cap_local",
  "type": "chart.shape.create",
  "target": { "kind": "chart.shape" },
  "anchor_space": "chart.data",
  "payload": {
    "op": "draw",
    "kind": "trendline",
    "points": [
      { "t": 1730000000, "p": 1284.5 },
      { "t": 1730900000, "p": 1312.0 }
    ]
  },
  "rationale": "Higher low at the 20-SMA; invalidation below 1276.",
  "idempotency_key": "turn_ses_9f2:msg_41:1",
  "created_at": "2026-10-20T09:41:02Z"
}

The fields carry more meaning than their names suggest:

  • v is the protocol version. v1 clients reject unknown major versions.
  • op_id is server-assigned. Clients must omit it; a supplied value is ignored.
  • turn_id groups one actor turn. It must be stable across retries, and two different messages must never share one. In Amaterasu it is derived from the session and message id.
  • seq is the one-based position within the turn. The pair (turn_id, seq) is unique.
  • actor is human, agent, or system, with an id and optional model and session.
  • capability carries only the capability id. The signed token travels in the X-Tenon-Capability header, so it is not duplicated into every audit entry.
  • type is <domain>.<object>.<verb>, for example chart.shape.create.
  • target names the object kind and, for update or delete, its id.
  • anchor_space names the coordinate space when the payload contains anchors. In the chart adapter it is chart.data.
  • payload is the domain object, validated by the adapter.
  • rationale is why. It is written to the audit entry verbatim, capped at 500 characters.
  • idempotency_key is unique per document for 30 days.
  • expected_revision is optional optimistic concurrency. If present and stale, the op is rejected with revision_conflict.

The envelope and the payload have opposite strictness on purpose. The envelope is a loose object: unknown fields survive a round trip, so a newer peer can add metadata without breaking an older one. The payload is closed: unknown fields are rejected, enums are fixed, and there is no raw HTML or free-form styling. That is the forward-compatibility channel and the safety boundary in one design.

Ops travel as an ordered batch of up to 200. They are applied in order. A rejection does not roll back earlier ops in the batch, because an agent needs partial success to self-correct. Turns are atomic for undo, not for application. The response contains one ack per submitted op, in the same order.

The Ack

The ack is the agent's view of what happened. It is deliberately richer than a boolean.

JSON
{
  "v": 1,
  "op_id": "op_01J9Z4K3T7",
  "turn_id": "turn_ses_9f2:msg_41",
  "seq": 1,
  "status": "applied",
  "revision": 42,
  "result": {
    "object_id": "sh_01J9Z4",
    "normalized": {
      "points": [
        { "t": 1730000000, "p": 1284.5 },
        { "t": 1730900000, "p": 1312.0 }
      ]
    }
  },
  "inverse": [
    {
      "type": "chart.shape.delete",
      "target": { "kind": "chart.shape", "id": "sh_01J9Z4" },
      "payload": { "op": { "op": "remove", "id": "sh_01J9Z4" } }
    }
  ]
}

An applied ack carries the server-assigned id, the new revision, the adapter's normalized result, and the inverse ops. A rejected ack carries a stable code, a one-sentence message, whether the same input could succeed later, and an optional structured suggestion.

The rejection codes are part of the protocol, not strings invented per endpoint. invalid_op, unknown_op_type, unknown_target, duplicate_object_id, revision_conflict, idempotency_conflict, capability_denied, capability_expired, approval_required, approval_invalid, anchor_out_of_range, object_limit, payload_too_large, already_undone, and internal are all defined in the same schema the client uses.

The suggestion is what makes self-correction practical. If an agent asks for a trendline starting at a timestamp outside the loaded window, the adapter rejects it with anchor_out_of_range and returns the nearest valid bar. The agent retries with that anchor instead of guessing again. The rejection is instructive rather than opaque.

Idempotency is equally concrete. The key is (doc_id, idempotency_key), unique for 30 days. A replay with the same key returns the original ack byte-identical except for replayed: true; it does not re-apply. A different payload under the same key is rejected with idempotency_conflict. Rejected attempts are not cached, so the client can fix the request and retry with the same key. The cache is part of the op log, not a separate side table, so it survives a restart.

Inverses and Undo

Undo is where the authority split pays off.

Undo as a new turn. The applied turn stores the adapter's inverses; an undo request replays them in reverse as a new audited turn, and the original turn is marked as undone rather than rewritten.

The adapter computes the inverse by diffing the document before and after the operation. The chart adapter compares annotations and series and emits the ops that restore the old state. The graph adapter has a subtler job, because a merge can delete a node, rewire edges, and remove duplicates at the same time. Its inverse generation is ordered so that the server's reversal is dependency-safe:

TypeScript
// The server reverses this array. Order it so the reverse is dependency-safe:
// restore nodes, restore edges, update nodes, update edges, delete edges, delete nodes.
for (const node of after.nodes) {
  if (!beforeNodes.has(node.id)) inverses.push({ op: "node.delete", id: node.id })
}
for (const edge of after.edges) {
  if (!beforeEdges.has(edge.id)) inverses.push({ op: "edge.delete", id: edge.id })
}
// …updates, then creates for objects that disappeared…

The server stores that array on the applied ack. When the user presses undo, it loads the turn's applied ops in seq order, concatenates their inverses, reverses the result, and submits it as a new turn:

  • a fresh turn id, with undoes: "turn_..." on the record;
  • the actor is the human who requested the undo;
  • the rationale is undo of {turn_id};
  • the original turn is marked undone_by: "turn_undo_...".

The log is append-only. Nothing is deleted or rewritten. Undo is itself auditable and reversible, so redo is simply an undo of the undo turn. A second undo of an already-undone turn is rejected with already_undone.

This is also why the server never needs a domain reducer. It does not know what a trendline is; it knows how to store an inverse and replay it later. Reversibility is supplied by the component that understood the operation in the first place.

One honest limitation: if an inverse is rejected, the undo is partial. The ack says so, and the UI must surface it rather than claiming the turn was reverted. That is a better failure than silently leaving the document in a state nobody can explain.

Capabilities and Approvals

An agent should not inherit the user's full authority. It gets a capability: a signed, scoped, expiring grant.

The token format is tn1.<base64url(claims_json)>.<base64url(ed25519_signature)>. The signature covers tn1. plus the encoded claims. The token travels in the X-Tenon-Capability header; the envelope only names the capability id. The reference server uses Ed25519 and persists its local key so tokens survive a restart.

The claims are the policy:

JSON
{
  "v": 1,
  "cap_id": "cap_01J9Z4",
  "iss": "tenon-local",
  "sub": { "actor_id": "chart-analyst", "session": "ses_9f2" },
  "documents": ["doc_reliance"],
  "op_types": ["chart.shape.*", "chart.series.*"],
  "permissions": ["read", "apply", "undo"],
  "approval": { "mode": "human", "op_types": ["chart.shape.delete"] },
  "expires_at": "2026-10-20T10:00:00Z"
}

The scope supports a trailing wildcard on one segment. Permissions are read, propose, apply, undo, and export. The token is rejected when it is expired, when its subject does not match the envelope actor, or when it grants neither apply nor propose. A propose-only capability cannot apply directly; it receives approval_required with an approval id.

Approval is the part that makes the model usable for destructive actions. In approval.mode = "human", ops matching approval.op_types are rejected with approval_required and a suggestion carrying an approval id. The UI shows the pending attempt. On approval, the server returns a one-shot approval id bound to the document, op type, and capability, with a five-minute TTL. The agent retries the same envelope with approval: "<id>".

In the reference setup, the local agent capability gates deletes and clears on human approval. That is a deliberate bias toward safety: the destructive path is the one place where the default should be "ask."

The reference server seeds local capabilities for the agent bridge, a human editor, and undo. It refuses to mint capabilities when bound to a non-loopback address. Hosted deployments would replace the local registry with a verifier that checks tokens minted by the workspace server.

The Audit Chain

Every journaled attempt — applied or rejected — becomes an audit entry. A malformed envelope that fails before it can be journaled still gets a typed ack, but it has no log row to chain. For everything that reaches the log, the entry includes the document, sequence, revision, op id, turn id, status, actor, capability, op type, target, rationale, timestamp, previous hash, and hash.

The chain is per document and gapless. The hash is:

TEXT
blake3( utf8(prev_hash) || "\n" || utf8(canonical_json(entry without hash/prev_hash)) )

The newline is domain separation: the previous hash is the prefix, not a field inside the body. The genesis hash is blake3: followed by 64 zeros. Canonical JSON sorts object keys by codepoint, emits no insignificant whitespace, and omits undefined members, so a verifier can recompute the same bytes.

RUST
pub fn hash_entry(previous: &str, entry: &Value) -> Result<String, JournalError> {
    let mut body = entry.clone();
    if let Some(object) = body.as_object_mut() {
        object.remove("hash");
        object.remove("prev_hash");
    }
    let canonical = serde_json::to_string(&body)?;
    let mut hasher = blake3::Hasher::new();
    hasher.update(previous.as_bytes());
    hasher.update(b"\n");
    hasher.update(canonical.as_bytes());
    Ok(format!("blake3:{}", hasher.finalize().to_hex()))
}

Verification recomputes every digest and checks the linkage at every index. Tampering with one entry breaks the chain at that entry, not at the end. The client package ships the same helpers with an injectable hash function, so a target can verify a chain with blake3, SHA-256, or whatever primitive it already uses.

Rejections are audited on purpose. A refused operation is evidence that the system denied something, not noise to discard. The audit trail is a record of attempts, not only of successes.

Two Adapters, One Protocol

A protocol that only works for charts is a chart feature. Tenon's second adapter is Weave, and the contrast is the proof.

The chart adapter stores annotations and computed series as data-space anchors: { t, p, liOffset? }. t is the canonical epoch second of a real bar, p is price, and liOffset is a fractional offset for whitespace beyond the last bar. Pixels never enter the document. Anchor resolution has four tiers: an exact bar, a time inside the loaded window (nearest bar plus residual, with a warning), a time beyond the last bar (last bar plus a nominal offset), and a time outside the window (anchor_out_of_range with the nearest available timestamp). Agent ops must be viewport-independent: the same input produces the same normalized output at any zoom.

The graph adapter stores nodes and edges. Its ops are node.create, node.update, node.delete, edge.create, edge.update, edge.delete, merge, and view.set. GraphStore rejects edges with missing or identical endpoints, refuses to merge a node into itself, and removes orphaned or duplicate edges after a merge. Layout and viewport are adapter concerns; the document only holds nodes, edges, and view state.

The Weave bridge applies graph.* envelopes to the Zustand graph over SSE, computes the inverse, and posts the ack. Manual edits go through the same journal with a cap_human capability. The graph can be edited by a person and an agent without either one having to know how the other produced its change.

That is what a protocol is supposed to buy: the same envelope, the same ack semantics, the same undo model, and the same audit chain across domains that share no vocabulary.

The MCP Bridge

The MCP server is a thin client over a running journal. It exposes four tools:

  • doc_get — metadata, audit verification, and the latest adapter snapshot.
  • op_apply — submit one or more envelopes; the server journals and capability-checks them before the adapter applies them.
  • turn_undo — undo one applied turn by replaying its inverses as a new turn.
  • state_query — metadata, recent ops, and audit entries with verification.

The tool inputs are full envelopes, not domain shorthands. That is deliberate: the adapter registers its own op types and the server remains domain-neutral. The bridge does not hold state. It needs a reachable Tenon server and a connected adapter; without them, there is nothing to apply to.

The HTTP submit blocks until the adapter acks, with a 15-second timeout in the reference server. On timeout the op is journaled as rejected with internal and retryable: true. The MCP client fetches and caches the signed capability token for the envelope's capability, so the server must be reachable for token minting.

This is the cleanest explanation of how Tenon relates to MCP. MCP standardizes how an agent discovers and calls a tool. Tenon standardizes the state that tool mutates, the permission it runs under, and the record it leaves behind. The MCP bridge is one transport into Tenon, not the protocol itself.

Conformance

The repository ships six language-agnostic fixtures:

  • anchor survival across zoom, pan, round-trip, and view ops;
  • undo/redo, including the rule that a turn is undone once;
  • idempotent retry, including a changed payload under the same key;
  • capability denial for out-of-scope, expired, and mis-bound grants;
  • malformed envelopes, unknown types, bad payloads, and missing targets;
  • audit-chain verification and tamper detection at the exact index.

The same JSON runs against the TypeScript MemoryTarget and the Rust harness in server/tests/conformance.rs. The Rust harness uses the real journal, capabilities, revisions, undo, and audit chain, with a small in-test domain adapter. An implementation can therefore prove compatibility instead of asserting it.

This is the least glamorous part of the project and the one that makes the protocol claim credible. A spec plus a reference implementation is an opinion. A spec plus a shared corpus that two independent targets pass is a protocol.

What I Would Do Differently

Publish the packages, or stop calling them installable. The 0.1.0 packages build, pack, and install from tarballs, and the landing page says so. But npm publication is intentionally paused, the install commands are placeholders, and the live demo has the agent disabled. That is an honest preview, but the gap between "source-ready" and "a developer can add this in an afternoon" is the whole adoption story. I would either publish the smallest stable subset or remove the install surface until it is real.

Approvals should survive a restart. The one-shot approval records live in memory. A server restart loses pending approvals, which is acceptable for a local demo and not acceptable for anything that runs for days. The journal already has SQLite; approvals belong in it, with the same TTL and one-shot semantics.

The local capability lifetime is too generous. The seeded local capabilities expire in ten years because they are meant to keep a local app working without token plumbing. The protocol describes short-lived grants, and a hosted issuer would mint them. I would make the local mode explicitly a development mode, with a visible warning, rather than let a ten-year token look like the real policy.

The reference server still carries a product. tenon-server also hosts Amaterasu's market data, OAuth, and agent bridge. That was pragmatic: it let the journal land against a real app instead of a mock. But it means the "reference journal" is not yet a clean standalone service, and a new adapter has to read around someone else's domain code. I would extract the journal into a crate with a thin demo binary.

There is no wire-level conformance target yet. The six fixtures exercise the journal and the adapter contract; they do not prove that an HTTP or SSE implementation preserves the same semantics over the wire. The protocol says WebSocket is a v2 concern, but a transport target is the next useful test.

The chart inverse has a known style-merge edge. An update merges style, so an inverse may not remove a style key introduced by the original op. The code says so rather than pretending otherwise. The fix is a replace-style op or a full snapshot fallback for style-only changes. I would rather have the limitation documented and tested than hidden behind a clean demo.

Closing

Tenon is an attempt to answer a narrow question: what does an agent need in order to edit a document that a person also owns?

The answer is not a better screenshot loop. It is an object with an id, an operation with a type, a turn that can be attributed, an inverse that can be replayed, a capability that can be revoked, and a log that can be verified. None of those ideas is new. Applying all of them to the same visual workspace, and making the server stay out of the domain, is the interesting part.

MCP standardized what agents can do. Tenon standardizes what they can safely change.

Repository

github.com/Sidharth-Singh10/tenon

← Back to projects