The reason this is one tool call and not a free-text rewrite is trust. A lawyer must be able to see, and reject, exactly what the agent changed. So the DSL is built so that every edit is anchored to real text, lands as a reviewable suggestion, and fails loudly when the document has moved under it.
This is a companion to The content service, end-to-end. That page covers the storage core, the transform pipeline and the file lifecycle; this one zooms into the single mechanism the agent uses to change a document.
The one-paragraph version
An edit is a JSON array of commands. Each command names a target (a block, a
piece of text to find, an occurrence) and an intent (replace,
insert, delete, format, change the block type, wrap a
list). lawrence-api resolves each target to a ProseMirror position range, plans the change, and
builds one ProseMirror transaction for the whole batch. Most commands land as a
diffSuggestion mark, a tracked change the lawyer reviews, not a
silent overwrite. The transaction is applied to the Yjs document, encoded as a delta update, and
submitted through matter-service to content.applyContentYjsUpdate with an
expectedEpoch guard and an idempotent batchId.
Demo first
The eight commands, interactively
The DSL is a discriminated union on type. Click through each command to see the
JSON Lawrence emits, the ProseMirror operation the planner produces, and whether it lands as a
tracked suggestion or a direct edit. The first six are tracked changes; the last two
(set_block, list_op) apply directly.
1 · the command Lawrence emits
2 · the planner's ProseMirror op
All commands share a targeting core (block_id, find, occurrence, expected_block_hash) and a required explanation. Planners live in apis/lawrence-api/src/content/vfs/resolvers/planners/.
The grammar
The DSL reference
The whole grammar is a Zod discriminated union in
matter-document-edit-schemas.ts. Every command mixes in a shared
targeting core and a required explanation (the human-readable
reason, shown to the lawyer and used for audit). Here is the union:
| type | Distinguishing fields | Effect | Lands as |
|---|---|---|---|
| edit | replace: string | Replace the found range | tracked |
| insert | position: "before"|"after", replace | Insert text at a boundary | tracked |
| delete | (targeting only) | Suggest removing the found range | tracked |
| format | mark: InlineMarkKind, op: "set"|"unset", attrs? | Add/remove an inline mark | tracked |
| refine | diff_suggestion_id, new_replace?, new_explanation? | Mutate an existing Lawrence mark | tracked |
| withdraw | diff_suggestion_id | Remove a Lawrence mark + undo it | tracked |
| set_block | kind: BlockKind, attrs?, clear_inline_styles? | Change block type/attributes | direct |
| list_op | kind: ListOpKind, ordered_start?, ordered_type? | Structural list change | direct |
The shared targeting core
Mixed into edit, insert, delete, format,
set_block and list_op (the id-addressed refine /
withdraw skip it):
| Field | Zod type | Role |
|---|---|---|
| block_id | string().min(1).optional() | Top-level block anchor from a DocMap read (e.g. doc[2]:paragraph) |
| expected_block_hash | string().min(1).optional() | sha256 of the block's normalised text: a precondition that the block hasn't changed |
| find | string().min(1).optional() | The text to locate within the block |
| context_before / context_after | string().optional() | Disambiguators when find is not unique |
| occurrence | number().int().positive().default(1) | Which match of find to use |
| explanation | string().min(1) (all commands) | Why: surfaced to the lawyer, stored on the mark |
The request envelope (MatterDocumentEditPayloadSchema) wraps the commands:
content_version_id (nullish; the optimistic-concurrency token from the read),
optional section_node_id (scope the edit to a sub-section), commands
(1 to 50, MAX_EDIT_COMMANDS), and personId (the acting
lawyer, force-injected server-side, see the pipeline section). The constrained sub-enums keep the
agent inside the OOXML schema:
- InlineMarkKind: bold, italic, underline, strike, subscript, superscript, code, textStyle, highlight, link.
- BlockKind: paragraph, heading, blockquote, codeBlock.
- BlockAttrs (
.strict()): level (1-6), textAlign (left/center/right/justify, no start/end, this is DOCX), indentLeft/Right/FirstLine, lineSpacing, spaceBefore/After, styleId. - ListOpKind: convert, wrap_bullet, wrap_ordered, unwrap.
numIdis deliberately never exposed. - FormatAttrs (
.strict()): fontFamily, fontSize, color, textDecorationLine, highlightColor, href, target.hrefis refined to^(https?://|mailto:).
Where it comes from
A self-describing tool
Here is the thing that surprises people reading Lawrence's system prompt: the command
grammar isn't in it. The prompt carries only the policy: edits land as tracked
suggestions, the status codes are routing instructions to recover from rather than errors to
escalate, read immediately before editing, batch up to 50 commands, use refine /
withdraw on your own marks instead of stacking a competing edit. Then it points at
the tool itself: "the runtime tool description carries the full payload schema, consult it before
constructing a call." The DSL is self-describing.
The authoritative grammar is injected into the edit tool's own description at call
time. edit.py's _build_tool_description pulls the per-surface
documentation from MatterDocumentContentSurface.get_documentation(), the command
payloads plus a STATUS HANDLING block, generated from the same definitions this page documents. So
the model reads an up-to-date spec from the tool schema on every call, and it cannot drift from
the code the way a hand-maintained prompt would. In practice Lawrence learns to edit from four
layers, narrowing from general policy to the specific live signal:
The Zod schemas in matter-document-edit-schemas.ts are the single source of truth; the runtime description and the agents-side mirror are generated from the same surface definitions, so prompt and code can't diverge.
Why this matters if you're auditing the prompt
From the system prompt alone the edit DSL looks under-specified: a partial list of status
codes and two field types (string, prosemirror), and no commands. That
is expected, not a gap. The full contract lives in the tool description and the drafting skill.
The prompt owns the policy (suggestions not overwrites, silent recovery, batching, never leaking
internal ids to the lawyer); the tool owns the grammar. One thing the policy layer is worth
sharpening on: it tells the agent to always call edits "proposed", but set_block and
list_op apply directly, so a structural change isn't actually a reviewable
suggestion.
Aiming
Targeting: how Lawrence points at text
Lawrence cannot send ProseMirror positions: it has never seen the document as a position tree,
only as Markdown or a block map. So targeting is a translation problem. A command says "block
doc[2]:paragraph, the 1st occurrence of £12,500", and the resolver
turns that into a concrete {from, to} ProseMirror range. Three helpers do it, and all
three must agree byte-for-byte with content-service:
computeBlockIdreproduces the same block id thegetContentMapread handed out: the node's ownattrs.id, or a positional fallbackdoc[<index>]:<type>.computeTextHashis sha256 of the block's whitespace-normalised text. If the command carriesexpected_block_hashand it no longer matches, the block changed under the agent and the command returnsblock_hash_mismatchrather than editing the wrong thing.resolveFindWithinBlockbuilds a per-character map from the block's normalised text back to source ProseMirror positions, finds the requestedoccurrenceoffind(optionally disambiguated bycontext_before/after), and maps the match back to gap-based PM positions.
doc[2]:paragraph → the top-level node. Not found → orphaned.block_hash_mismatch.ambiguous.from:40, to:47 for the transaction.For insert the range is a zero-width point at the boundary; for set_block / list_op it spans the whole block.
A load-bearing cross-service contract
computeBlockId and computeTextHash in lawrence-api must byte-match the
equivalents in content-service's ooxmlProsemirror.ts (the map builder). If they ever
drift, the ids and hashes the agent reads won't resolve, and every command will come
back orphaned or block_hash_mismatch. The same goes for two other
constants: CONTENT_ROOT = "content" (the Yjs fragment name) and
AGENT_AUTHOR_NAME = "Lawrence" (which marks count as the agent's). These are silent
contracts spanning three files and two repos.
Suggestions
Tracked changes vs direct edits
The default is a tracked change. When Lawrence edits prose, the planner does not mutate the
text in place: it wraps the change in a diffSuggestion mark, a
ProseMirror mark carrying {id, originalText, suggestedText, author: "Lawrence", source,
date, formatChange, comment}. In the editor the lawyer sees each as a green insertion or a
red strikethrough attributed to "Lawrence" with a timestamp, and an accept/reject card in the
margin; a batch of up to 50 commands lands as one revision, one card per command. A deletion is a
diffSuggestion with an empty suggestedText; a format change carries a
formatChange instead of text. Six of the eight commands work this way.
Two commands cannot, today. set_block and list_op change block
structure (a paragraph becomes a heading, a paragraph becomes a list item), and the
diffSuggestion mark only models inline deltas. There is no inline mark that
means "this used to be a paragraph". So they apply directly, with
diff_suggestion_id: null, and cannot be rejected the way an inline suggestion can.
The tracked equivalent (an OOXML w:pPrChange) is tracked as future work in LEX-380.
Tracked · diffSuggestion
edit · insert · delete · format · refine · withdraw
- Lands as a mark the lawyer reviews; nothing is silently overwritten.
- Carries
author: "Lawrence", so the system knows whose change it is.refine/withdraware guarded to Lawrence-authored marks only. - Overlap is rejected: if the target range already has any diffSuggestion mark (any author), the command returns
overlap_conflictrather than stacking changes.
Direct · no mark
set_block · list_op
- Structural block changes applied via
setNodeMarkup/ list transforms. diff_suggestion_id: null: not reversible through the reject-suggestion flow.- Deferred tracked equivalent: LEX-380 (block-property change tracking).
The machine
The pipeline: command to Yjs update
A whole batch of commands becomes one ProseMirror transaction and one Yjs update. The resolver fetches current state, gates on the version, plans every command, applies them to a single transaction (remapping positions as earlier edits shift later ones), writes the mutated doc back into the Yjs fragment, and ships the encoded delta. Step through it:
The single-transaction design means a burst of edits is one atomic CRDT update with one batchId. Per-command progressive flush is deferred (LEX-376).
Concurrency: two guards, one retry
There are two independent staleness checks. The version gate
(content_version_id) catches "a new saved version exists since you read": every
command short-circuits to stale. The epoch CAS at content-service
catches "an update landed since you read": expectedEpoch must equal
currentEpoch. On an epoch race the resolver re-fetches and retries once; if
the re-fetched contentVersionId has moved it aborts instead (you'd be reapplying onto
a different document). The batchId makes the whole thing idempotent: a duplicate
returns exists, never a double-apply.
Outcomes
Result statuses and recovery
Every command returns its own result, so a batch can be partly applied. The status vocabulary
is precise on purpose: each value tells the agent exactly how to recover. The whole point of
failing with ambiguous or block_hash_mismatch rather than guessing is
that a wrong edit to a legal document is worse than no edit.
| status | When it's returned |
|---|---|
| applied | Plan built and the Yjs transaction applied successfully |
| ambiguous | occurrence beyond the match count, or find matches in multiple blocks |
| orphaned | block_id not found, find not found, or a zero-width / empty target |
| section_not_found | section_node_id is not present in the document |
| block_hash_mismatch | expected_block_hash no longer matches the block's text (it changed under the agent) |
| overlap_conflict | The target range already carries a diffSuggestion mark (any author) |
| stale | Version gate failed, or an epoch race persisted after the one retry |
| target_not_found | refine / withdraw: no mark with that diff_suggestion_id |
| not_authored_by_agent | refine / withdraw: the mark's author is not "Lawrence" |
| failed | The transaction threw, or attrs were invalid (e.g. a bad format value) |
| not_yet_implemented | Reserved in the enum (and the Python mirror) but never produced by any planner today |
Recovery is the model's job, not the framework's. There is no automatic retry on the
agents side: the per-command results are surfaced to Lawrence verbatim, and the tool
description tells it what to do (on stale, re-read and re-issue with a fresh
content_version_id; on overlap_conflict, refine or
withdraw the existing mark instead). The only programmatic retry anywhere is
the single epoch-race retry inside the lawrence-api resolver. This keeps the agent in the loop and
auditable rather than silently hammering a document.
Starting fresh
The create path
Creating a document is simpler than editing one, because there's no concurrency to defend.
create takes one of two strict shapes, and the generic /write verb is
deliberately blocked for documents so that all changes flow through the reviewable
edit path.
From ProseMirror JSON
{ name, personId, json }
- The agent supplies a full ProseMirror document.
- →
createMatterDocumentFromProsemirrorJson→ content-servicecontent.createContentFromProsemirrorJson(scopematter/<id>). - Used when Lawrence drafts something new from scratch.
From a precedent
{ name, personId, sourcePrecedentPath }
- A
precedents://<matter>/<templateId>path, scope-checked. - →
createMatterDocumentFromTemplate: a byte-for-byte copy of the published template version, no AI rewriting. - Supplying both
jsonandsourcePrecedentPathfails the union.
personId (and actorIdentityId for task/keydate creates) is
auto-injected from the agent's context on the Python side, so the LLM never chooses who it is
acting as. Both create shapes return { path, id }.
Reality check
Gotchas & open questions
The honest edges of the system, so the map matches the territory:
In the status enum and the Python mirror, but no planner ever emits it. Held for future command types.
Only set / unset are supported; toggle is not.
No multi-paragraph wrap and no per-item sink/lift nesting yet.
Direct edits, so they can't be rejected as suggestions. Tracked block-property change is LEX-380.
The whole batch ships as one Yjs update / one batchId. Progressive per-command flush is LEX-376.
Replaced text inherits only the leading character's marks; styling that varies across the span is lost.
The resolver's dep type allows newEpoch?, but the apply path never returns it; the retry loop always re-fetches. Effectively unused.
Must byte-match content-service ooxmlProsemirror.ts. Drift → every command orphaned / block_hash_mismatch.
The resolver's record* hooks are no-ops with stable metric names; no metrics emitted yet.
No automatic retry in the Python client; recovery is LLM-driven from the status. Only the resolver's one epoch-race retry is programmatic.