Lawrence engineering · Tech plan · June 2026

Form filling, without the wait

Court forms on Lawhive are filled by a background pipeline the lawyer can't see. We're moving that work into Lawrence's chat loop: the form fills in front of you, every value carries a citation to the case material it came from, and when the case file has a gap, Lawrence just asks.

agents + platform-v3 flag-gated · legacy fallback intact status: active

What the lawyer sees

The lawyer asks for a form in chat and watches it fill in the open FormEditor. Fields land one collection at a time, each tagged "Completed by Lawrence" for review; when the case file can't answer something, Lawrence asks in the thread and carries on from the reply.

legal-os · matter / Mitchell v Mitchell
FL-100 · Petition
Lawrence

The problem

Today, a lawyer on a divorce matter types: "Can you fill out the FL-100 petition for the Mitchell divorce?" What happens next is silence.

The message fires an Inngest event into a background pipeline and the chat turn ends. The pipeline fans out across the form's collections, fills each group of fields with one LLM call per group, and writes everything back as a single document. It's a well-built batch system: grounded fills, citations verified verbatim against the source material, one atomic write.

The lawyer experiences none of that. They see a loading state for several minutes: no progress, no partial output, and no way for the pipeline to ask a question. If the case file never mentions a date of separation, the pipeline can't ask the one person who knows it. The lawyer finds the blank field during review, long after the conversation that could have answered it in five seconds has moved on.

A batch system bolted onto a conversation

The legacy pipeline isn't bad; it's the wrong shape. Three structural costs follow directly from fire-and-forget:

Meanwhile, two things became true that weren't when the pipeline was built: Lawrence has a production edit surface that lands batched field edits as unreviewed "Completed by Lawrence" changes (shipped for the form-editing skill), and the FormEditor live-merges those edits on a 3-second poll. The expensive parts of "fill the form inside the chat" already exist. What's missing is a way to start a blank form, and a procedure for filling it honestly.

Move the fill inside the turn

If the agent already holds the case context and already has a write surface to the live form, the pipeline isn't earning its async boundary.

So: keep the request inside the chat turn. Lawrence reads the form's outline, gathers the case context it doesn't already have, creates a blank draft the lawyer can see immediately, and fills it collection by collection. Anything it can't ground gets skipped with a reason instead of guessed, and the turn ends with one batched set of questions. The diagram puts the two designs side by side.

Legacy pipeline · fire-and-forget fan-out Agentic flow · in-loop incremental fill Lawyer (in chat) "fill the FL-100" Lawrence · fill_form tool emits one Inngest event → chat turn ends async boundary: the chat turn has already returned ai-platform · lawrence-engine · fan-out / fan-in (Inngest) retrieve form definition fan-out → collections run in PARALLEL (independent) collection collection collection groups run SEQUENTIALLY · each sees prior answers process_group · one group of fields ① filter → supported vs unsupported ② unsupported → canned (UNSUPPORTED / INCOMPLETE) ③ supported → retrieve case chunks → group LLM call + judge → value + citations fan-in · consolidate → FilledFormOutput (+ optional calculation pass across groups) emit final_asset_generated · one event artifact persisted platform-v3 · matter-service persist whole form · single version Lawyer sees the finished form only when the whole pipeline completes: no live progress, and no way to ask a question mid-fill. Lawyer is outside the loop Lawyer (in chat) "fill the FL-100" agents · Lawrence chat loop · form-filling skill whole fill in one chat turn · the outline is the fact checklist ① read form + gather context read forms:// outline → per-field descriptions gap reads: notes:// parties:// files:// messages:// ② create filled-forms://<matter> persist blank skeleton (v1) · reuse open DRAFT ③ fill loop ↻ small batches: a few fields at a time edit filled-forms://…/fields set / checked → Complete · skip → Skipped inspect results[] → retry failed once budget gate → continue or stop each batch → a new version (lawyer sees it land) each edit carries a reason + citations (source-backed) ④ end turn summary + batched clarification questions batch of edits lawrence-api · resolver merge batch into whole form reviewed field → skipped lawyer's value is kept matter-service persist whole form → new version CAS: stale write rejected, re-read + re-apply FormEditor (FE) 3s poll · fields appear live merge skips fields you're editing watches fields land live clarification ⇄ answers Lawyer watches each field land live and answers clarifying questions inline, before the turn ends. Lawyer is inside the loop legacy / async write agentic write live poll clarification round-trip

The operations Lawrence runs

The same FL-100 fill, but as the sequence of tool calls Lawrence issues inside the chat turn: the developer's view of the walkthrough at the top of the page. Each step is one operation against the VFS, with its real request and response. Scroll through the full trace.

Lawrence · tool calls matter mat_8x · FL-100

The design, in five decisions

Most of this project is plumbing that already exists. The decisions below are the parts that aren't: each one chosen over a real alternative, with the tradeoff named.

1 · A blank skeleton first, on a forms-native VFS scheme

The fill starts with the generic VFS create verb: create filled-forms://<matter>, the form id in the payload, not a bespoke start_form tool. A filled form gets its own scheme, distinct from the blank forms:// definitions it's filled from: the same template-versus-instance split the VFS already draws between precedents:// and the documents:// they create. create, read and edit all live under filled-forms://, so one scheme covers a form's whole lifecycle.

read forms://fl-100
"Petitioner: full legal name of the spouse filing." Meaning only; no PDF ids, no widget types.
create filled-forms://<matter>
Server rebuilds the skeleton from the raw template annotations, or returns the open DRAFT if one exists.
DRAFT v1 · live in the FE
field_id 2 · text · Incomplete … with Unsupported widgets pre-marked.
The outline carries meaning; the skeleton carries targets. Only the server ever sees both.

Creation is idempotent: an open DRAFT is returned, not duplicated (a partial unique index on (matterId, formSlug) enforces it). That is also the resume mechanism: a follow-up turn gets the same draft, re-reads field states, and continues.

2 · Small batches the lawyer watches land

The legacy data model already encodes which fields are independent: collections run in parallel, groups within them run sequentially because later groups depend on earlier answers. The agentic loop reuses that seam, but the unit of work is a small batch, not a whole collection. The skill has the agent fill a few fields at a time, grouping fields that share reasoning and never crossing a dependency group, under a low hard cap. That is what makes the fill watchable: fields land a few at a time through the FormEditor's existing poll, rather than a whole collection appearing at once (which would just reproduce legacy's all-at-once feel). A large collection is split across several small batches, and each batch persists a new version.

After each batch:

v2 · batch: parties
Petitioner, Respondent land applied
v3 · batch: marriage
Date of marriage applied · Date of separation skipped
v4 · batch: children
No minor children applied
Each small batch is its own version. The lawyer watches a few fields land at a time through the 3-second poll, not the whole form at once.

3 · Citations and confidence ship in v1, because the slots already exist

"Trust, but verify."
Russian proverb

Every filled value carries a citation and a confidence score, because the slots already exist: the artifact has per-field citations and confidence, the FormEditor renders both today (source badges, a low-confidence "Review" flag), and the legacy pipeline populates them. The agentic path writes the same slots; skipping them would ship a visible regression.

Petitioner Jane A. Mitchell Completed by Lawrence
petitioner named in intake note 1
Intake call · divorce instructions  · note · matnt_4f
"Petitioner: Jane A. Mitchell"
{ "field_id": "2", "value": "Jane A. Mitchell",
  "reason": "petitioner named in intake note ❬1❭",
  "citations": [
    { "ref": 1,
      "source":  { "type": "note", "id": "matnt_4f" },
      "passage": { "text": "Petitioner: Jane A. Mitchell" } }
  ] }
Top: the field as the lawyer reviews it; the ❬1❭ badge opens the cited source with its verbatim passage. Bottom: the single edit that produced all of it.

4 · Ask at the end, not in the middle

Pausing mid-fill to ask sounds attentive but costs the finished form: it burns loop iterations and strands half-filled drafts when the lawyer doesn't answer. Instead, Lawrence skips what it can't ground, finishes the pass, and ends with a summary plus one batched set of questions.

Date of separation Skipped · no date in the case file
Lawrence · end of turnWhen did the parties separate?
The Skipped field is the question list: durable on the artifact, answered in chat, and left visibly Skipped (never re-asked) if the lawyer ignores it.

The questions anchor to the Skipped fields on the artifact, not conversation memory. Next turn, Lawrence re-reads them, edits exactly the ones the answers cover, accepts partial answers, and never re-asks what the lawyer ignored.

5 · Whose value wins

Once the lawyer can edit while Lawrence fills (something the legacy pipeline never had to survive), collisions become real. Three guards, at different layers:

Petitioner Jane Amelia Mitchell Reviewed by you
edit { "field_id": "2", "value": "Jane A. Mitchell" } → skipped
An agent edit against a reviewed field comes back as a per-edit skipped result; the lawyer's confirmed value stays exactly as they left it.
lawyer saves
edits Petitioner, a different field; the form is now v5.
agent write · read at v4
batch sets Respondent with version: v4stale (v4 ≠ v5), rejected.
re-read v5, re-apply
Respondent lands applied → v6. The lawyer's Petitioner is untouched.
Compare-and-set: the write carries the version it read, so a stale write is rejected rather than clobbering the lawyer's concurrent save. Lawrence re-reads and re-applies only its own fields.

The build, step by step

High level, in dependency order. Phase 1 (platform-v3) is inert until called; Phase 2 (agents) merges flag-off.

  1. Create surface. In lawrence-api, add the create filled-forms://<matter> resolver: build a blank FILLED_FORM skeleton from the raw template annotations, idempotent on an open DRAFT. matter-service already persists the shape.
  2. Move the surface onto filled-forms://. Point the filled-form read and edit resolvers at filled-forms://<matter>/<id> so create, read and edit share one scheme. artifacts://…/fields stays as a deprecated alias only if it's already live behind the flag; otherwise it's a clean rename.
  3. Extend the edit surface. In filled-form-edit-schemas.ts and filled-form-edit-resolver.ts, accept skip, citations and confidence per edit, write them through, and return skipped for a lawyer-reviewed field instead of overwriting it.
  4. Compare-and-set on persist. Carry the version the agent read into the whole-document write; reject a stale write so a concurrent lawyer save on a different field isn't clobbered, and have the loop re-read and re-apply.
  5. Client keystroke guard. In the FormEditor's poll merge, skip any field the lawyer is mid-edit on so a landing batch can't clobber an unsaved value.
  6. Create verb + tool card. Add a create verb to the VFS client (the filled-forms://<matter> path routes to the create resolver), returning the artifact id and a per-collection field summary, plus a friendly "Creating the form" card in the legal-os tool-message components.
  7. form-filling skill. New skills/form-filling/SKILL.md: read outline → gather context → start or resume → batched edits (skip, cite, confidence) → batch-at-end questions.
  8. Flag the path. In agents/chat/.../agent.py, gate tool selection on agentic-form-filling (and the skills flag); flag-off keeps the legacy fill_form tool.
  9. Verify and roll out. Side-by-side parity on seeded matters, then flip the flag gradually, checking for open DRAFTs before each change.

What could go wrong

The parts we're least sure about, and what we're doing about each:

Rollout: two phases, behind a flag

Phase 1 lands in platform-v3: the create filled-forms://<matter> resolver and the edit-surface extensions (skip, citations, confidence, the reviewed-field guard, the compare-and-set), all inert until called. Phase 2 lands in the agents repo (the create verb, the form-filling skill, the per-turn flag wiring) and merges flag-off. The whole agentic path sits behind one PostHog flag, agentic-form-filling; it also rides on the skills flag, since agentic-on with skills-off must fall back to legacy rather than load the fill path without its procedure. Nothing changes until the flag flips; flipping it back restores legacy untouched. One rollback drill: a leftover agentic DRAFT must still render after the flag goes off (it does; same artifact shape), and we check for open DRAFTs before any flip.

After v1

Deliberately out of scope for the first release, in rough priority order:

What we still don't know

Updates

What's changed since this plan was first shared, newest first.