Open KnowledgeOpen Knowledge
Internals

Agent Write Path

How external agents write into the CRDT via markdown, and how the server-authoritative bridge keeps WYSIWYG and source mode views in sync.

External agents (Claude Code, Cowork, Cursor, etc.) write into the Y.Doc via HTTP APIs. The system provides two write endpoints; the server-authoritative cross-CRDT bridge then propagates the write to both Y.XmlFragment (TipTap) and Y.Text (CodeMirror) so the result is visible in either editor mode without any client-side merge.

Write endpoints

Raw write (POST /api/agent-write)

Appends a paragraph to the Y.Doc using raw Yjs operations:

Y.XmlFragment('default')
  └─ Y.XmlElement('paragraph')
      └─ Y.XmlText() with applyDelta([{ insert: "text" }])

This is the original V3 validation endpoint. It works but bypasses the markdown pipeline, so the content must be structured as y-prosemirror-compatible nodes.

Markdown write (POST /api/agent-write-md)

Accepts a JSON body:

{
  "markdown": "## New section\n\nAgent-written paragraph.",
  "position": "append"
}

The endpoint routes through applyAgentMarkdownWrite (XmlFragment-authoritative composition per AGENTS.md precedent #10):

  1. Opens a DirectConnection to the Y.Doc
  2. Reads the current Y.XmlFragment — reflects all CRDT-synced content including any concurrent client WYSIWYG typing that has propagated via the tree-sync message. Server-side cross-CRDT sync (precedent #14) runs under a single-writer afterAllTransactions settlement dispatch (one fire per outermost doc.transact() drain, SPEC 2026-04-16 bridge-correctness §6 R4); the client observer is a baseline-tracking shell that does not write Y.Text.
  3. Serializes XmlFragment to markdown via yXmlFragmentToProsemirrorJSON + MarkdownManager.serialize
  4. Composes the agent's delta at the markdown level per position ('append' / 'prepend' / 'replace')
  5. Parses the composed markdown and applies to XmlFragment via updateYFragment() — structural diff preserves user-content Items at matching positions
  6. Mirrors the canonical post-fragment markdown to Y.Text via applyFastDiff (character-level DMP write exported from @inkeep/open-knowledge-core/bridge) — minimal mutation, preserves non-agent Y.Text Items and their origins

The XmlFragment-authoritative shape is required to preserve concurrent user WYSIWYG content during agent writes. The prior implementation (syncTextToFragment, deleted by the 2026-04-14-bridge-convergence-under-concurrent-writes spec — FR-9) read Y.Text as the authoritative input, which lagged the XmlFragment for client-originated WYSIWYG typing and caused updateYFragment to stomp user content (Bug-A, deterministic).

V0-14's future applyAgentUndo handler must follow the same pattern — see evidence/bug-d-mechanism.md in the bridge-convergence spec for the template.

Source mode live injection

Source mode (CodeMirror) binds Y.Text('source') directly via y-codemirror.next. Agent writes are visible in real time without any application-level observer:

  1. applyAgentMarkdownWrite updates Y.XmlFragment and mirrors the canonical markdown to Y.Text inside one server-side doc.transact() block under AGENT_WRITE_ORIGIN (a paired-write origin per AGENTS.md precedent #1).
  2. The Y.Text mutation propagates to every connected client through the standard CRDT sync.
  3. y-codemirror.next applies the incoming delta to the CodeMirror buffer with character-level precision — cursor and selection on the local keystroke side are preserved by the binding.

The server's bidirectional observer sync runs exclusively on the server under the afterAllTransactions settlement dispatch (precedent #13(b)). One outermost doc.transact() call produces exactly one settlement fire; Observer A (XmlFragment → Y.Text) runs before Observer B (Y.Text → XmlFragment) within each drain, and Observer B canonicalizes Y.Text via applyFastDiff after updateYFragment so the bridge invariant stripTrailingWhitespace(ytext) === stripTrailingWhitespace(serialize(fragment)) holds at every settlement point. There is no debounce window, no injected Scheduler, and no wall-clock timing in either bridge observer file (enforced by the grep gate at packages/server/src/bridge-no-wallclock.test.ts).

Paired-write origins (AGENT_WRITE_ORIGIN, FILE_WATCHER_ORIGIN, ROLLBACK_ORIGIN, MANAGED_RENAME_ORIGIN) declare context.paired: true at their definition site. Observer A AND Observer B both detect that marker structurally via isPairedWriteOrigin(transaction.origin) and refresh their shared baseline synchronously inside the observer callback — the settlement handler then has no work to dispatch for the drain (the paired writer already made both CRDTs consistent).

Client-side keystroke protection is not needed on the bridge layer because the client observer does not write the derived CRDT under precedent #14.

Mode toggle (no merge)

The WYSIWYG/source mode toggle is a UI visibility flip, not a serialize-and-merge operation. EditorActivityPool mounts both TiptapEditor and SourceEditor concurrently per active document and uses display:none to swap between them. Each editor stays bound to its Y type (Y.XmlFragment('default') for TipTap via Collaboration; Y.Text('source') for CodeMirror via y-codemirror.next) for the lifetime of the mount.

The server's bidirectional bridge keeps the two Y types continuously in sync, so toggling back from source mode reveals a TipTap view that already reflects every Y.Text edit the user made — no client-side three-way merge runs on toggle. Concurrent agent writes that arrived during a source-mode session are visible to TipTap on toggle-back for the same reason.

Three-way merge for content preservation has moved to the bridge layer itself: when an inbound CRDT update arrives during local Y.XmlFragment editing, Observer A's Path B uses the hybrid diff3+DMP mergeThreeWay algorithm (@inkeep/open-knowledge-core/bridge) with a content-preservation post-condition (assertContentPreservation). On post-condition violation, the production policy is to (a) emit a structured bridge-merge-content-loss log, (b) write a silent named version-history checkpoint via saveInMemoryCheckpoint (rendered in TimelinePanel so users can recover via the existing Restore UI), and (c) apply the merge as-computed so the editor keeps responding. Dev/test re-throws so integration suites fail loudly; conversion-class regressions are caught at PR tier by the deterministic fidelity PBT (packages/app/tests/fidelity/bridge-observer-conversion.test.ts). The bridge-convergence fuzz is preserved for ad-hoc sampling via bun run measure:fuzz (see specs/2026-04-19-ci-signal-quality/ for the CI signal quality rationale). See specs/2026-04-16-bridge-correctness/SPEC.md §6 R1/R7 for the full contract and packages/server/src/server-observers.ts for the wiring.

Attribution journal (summaries)

Each agent-write API call records a contributor entry in the in-memory accumulator pendingContributors: Map<agentId, ContributorEntry> (packages/server/src/contributor-tracker.ts). The optional fifth argument to recordContributor(docName, agentId, displayName, colorSeed, summary?) appends a summary to the contributor's flat summaries: string[] array, preserving insertion order across the L2 debounce window.

On the 15-30s L2 debounce, swapContributors() drains the accumulator and formatContributorsFrom(snapshot) emits one ok-contributors: JSON line per contributor into the shadow-repo commit body:

ok-contributors: {"v":1,"id":"agent-abc","name":"Claude","colorSeed":"...","docs":["foo.md","bar.md"],"summaries":["Fixed token-refresh race","Added example block"]}

The summaries field is flat (per-contributor, not per-doc) and omitted entirely when no summaries were recorded — legacy commits and summary-less writes are byte-identical to pre-feature behavior. The JSON line stays at v: 1; the field is purely additive (precedent #9).

Truncation single-source-of-truth. normalizeSummary in packages/server/src/agent-write-summary.ts is the only place summary text is validated and truncated. The five API handlers (/api/agent-write, /api/agent-write-md, /api/agent-patch, /api/rename, /api/rollback) each call it at the request boundary before threading the normalized value into recordContributor. The cap is 80 characters (79 visible + U+2026 ); the Zod schemas on MCP tool inputs cap at 200 as a separate transport-safety bound.

Attribution guard for rename and rollback. handleRename and handleRollback only call extractAgentIdentity + recordContributor when the request body carries an explicit agentId field. The in-editor Restore button (EditorPane.tsx) posts with no identity and therefore stays anonymous on the timeline — the "positive externality" of MCP-driven rename/rollback attribution does not leak to human-driven UI calls. This is a LOCKED behavior contract.

Default summaries. When no summary is provided, MCP-driven rename_document and rollback_to_version substitute a server-generated default ("Renamed <from> → <to>" or "Restored to <sha-short>") through the same normalizeSummary path. write_document and edit_document have no default — an absent summary means an absent bullet.

Parser tolerance. parseContributors in packages/core/src/shadow-repo-layout.ts accepts both legacy (no summaries field) and new commits. A malformed summaries value drops just that field while preserving the contributor entry — a deliberate divergence from the whole-entry-skip policy applied to other optional fields, because decorative loss beats attribution loss.

Consumers.

  • TimelinePanel renders summaries as a collapsible bulleted list under the author line (first bullet inline, remainder behind a "Show N more" expander). The full doc list stays visible as ground truth.
  • exec / read_document enrichment output carries the field through automatically via history.contributors[*].summaries, so agents reading prior timeline state see intent alongside identity.
  • Metrics counters in packages/server/src/metrics.ts: agentWriteCalls (denominator), summariesProvided (numerator for M1 adoption rate), and summariesTruncated (M2 cap-efficacy) — all surfaced via GET /api/metrics/reconciliation.

Agent simulator

The CLI tool supports both write modes:

bun run src/server/agent-sim.ts                      # Single raw write
bun run src/server/agent-sim.ts --rapid 5            # 5 raw writes, 100ms apart
bun run src/server/agent-sim.ts --markdown           # Single markdown write
bun run src/server/agent-sim.ts --markdown --rapid 5 # 5 markdown writes, 100ms apart

The --markdown flag uses POST /api/agent-write-md (the unified path). Without it, the simulator uses POST /api/agent-write (raw Y.XmlElement).