Today the agents backend is a single 1,500-line chat agent welded to an HTTP
request. Six capabilities, built in dependency order, turn it into a runtime that hosts many agents,
survives a reload, runs in the background, and reacts to matter changes on its own. This is the
sequence, and what each step unlocks.
By Adolfo Tamayo · Lawyer Experience (LEX)
agents reposequence, not a calendarstatus: draft for review
AgentLoop
lawrence-2matter chat
globalacross matters
lawraadmin app
proactivereacts to MCC
Post-review iteration (29 Jun 2026). This drew detailed feedback — fluent builder, declarative registry, deny-by-default VFS, model-from-config, layout. It's folded into a proposal: defineAgent, reshaped (current vs proposed, side by side).
Pick a Lawrence. See what it costs.
Every feature we want next lands on the same handful of capabilities. Select one of
the things we want to build and the ladder lights up exactly the steps it needs, in the order it
needs them. The dashed amber steps are platform-side prerequisites that live in other repos.
01
Generalised Agent API
Route by slug. Optional matterId. Generic agentParams + principal.
Select a scenario above to trace it through the ladder.
Why this shape
The Next Steps doc is right that the next set of upgrades needs a more holistic lift of the
agents infrastructure, not another feature bolted to the side. The evidence is that every initiative
on the roadmap converges on the same place in the code. Today ChatAgent.generate_stream()
is one method, around 780 lines inside a 1,500-line class, that interleaves prompt selection,
context loading, the tool loop, citation parsing, tracing, and SSE rendering. A new surface means
another elif in prompt routing; a new mode means another boolean; a new agent means
copying the whole thing.
Boolean modes don't scale. The cure is the same five seams whichever feature asks
for it first.
So the plan is not eight tracks competing for attention. It is a capability ladder:
a generalised API and a framework refactor first, which between them let us stand up many agents over
HTTP cheaply, then durability, then background runs, then triggers, with code execution last and
demand-driven. Each rung is useful on its own, and each one is a precondition for the rungs above it.
The order is the point.
The sequence
Roughly this order, not strictly. Each capability below carries the design notes that actually
decide whether it lands cleanly.
01Generalised Agent API
POST /agents/{slug}/chat routed by slug, slugs registered in code (agents as
code). One envelope { threadId?, messages, agentParams } where agentParams is
generic and the agent validates it against its own Pydantic schema. matterId stops being
mandatory and moves into agentParams for the agents that need it.
Matter scoping is a global guard today (BaseAgent._clean_tool_parameters force-overrides
any LLM-supplied matter_id with the authenticated one). With optional matterId this becomes
per-agent-declared, or the first non-matter agent silently loses the boundary.
The envelope carries the principal (lawyer | admin | client | system),
not an assumed lawyer. Lawra and a client-app Lawrence enter through different auth chains.
Two output dimensions, not one. Transport: SSE stream vs a single JSON response
(prompt-suggestions and title-gen are single-shot). Contract: free-form text vs a
validated object, via an optional output_schema — the symmetric twin of the
agentParams input schema. Folds mode/surface/PostHog residue into
slugs and kills the matrix.
Ships before, and independent of, the framework refactor. Chat is the first slug; the existing
route stays as an alias until callers migrate.
02Agent Framework / ChatAgent refactor
BaseAgent owns the base logic (loop, tool calling, streaming) with method hooks for the
customisable parts and mixins for the optional ones. Each agent becomes a thin definition. Done by parts
as needed, behind the existing route, with a stream-protocol fixture as the tripwire.
Hooks: system_prompt(), initial_context(), tools(),
vfs_config() (which paths/namespaces are enabled). Mixins: compaction, skills. What an agent
is allowed to do is governed by which tools it gets, not a policy layer — and VFS writes are
drafts by nature (a create on emails:// produces a draft, never a send), so "propose for review" is inherent. A formal
action-policy layer only earns its place later, once a tool can take an irreversible or external action.
Promote the output seam to part one: the loop writes to self.emitter
instead of yielding SSE directly. Capabilities 03 and 04 both swap exactly this seam. Cheap now, a
re-refactor later.
Output is a strategy on one loop, not a subclass. An optional output_schema
switches the terminal step from "stream free text" to "force a response tool, validate, retry on invalid"
(reusing the Create/Edit validateDoc feedback loop); the emitter becomes a collector.
Resist StreamingBaseAgent / StructuredOutputBaseAgent siblings — output is
one axis, transport (HTTP / background) is another, and subclassing per axis is N×M classes, the
same "modes don't scale" disease in a class hierarchy. Capture the type-safety win with typed entrypoints
over the shared loop (stream() → AsyncIterator vs run_structured(schema) → T),
so structured×background composes for free.
Insurance before carving the loop: a golden-fixture stream-protocol test plus a compaction
round-trip fixture. Loop coverage is near zero and the ai-stream wire format is the contract
platform-v3 depends on.
Citation-subsystem freeze until Citations P2 ships, since both touch the same BaseAgent streaming
code.
Milestone — after 01 + 02
With routing and a hookable loop in place we can add HTTP-only agents,
but many of them, cheaply: Global Lawrence, the library precedent drafter, a skill-drafting
assistant, Lawra for admin, a client-app Lawrence, even title-gen and eval agents are now just slugs with
a context hook and a tool set. Everything below adds reach in time (durability, background, reaction);
the breadth of who Lawrence can be is unlocked here.
03Durable streams
Fire an agent, reload the tab, re-attach to the running stream. A Redis stream per run,
every chunk gets an id, the client reconnects with a last-chunk-id cursor. Pattern A, the same shape as the
durable-llm-streams POC. It splits into two halves, and we already have the first.
Half of this is already prototyped at the lawrence-api layer (platform-v3 PR 11161 /
ADR 0047): a Redis Stream as single source of truth, giving client reload, multi-tab, stop, and
cross-instance cancel — and it lands independently of the framework refactor, because
lawrence-api owns the buffer. (Even today a browser reload doesn't lose the run, since lawrence-api
consumes to finalisation; what this layer adds is live re-attach and surviving a lawrence-api restart.)
The other half needs the agent to own the stream. Once the cap-02 emitter writes to
Redis directly (or the run is a background job), the generation survives any process dying and
can stream with no client at all — the precondition for background/proactive runs
(04). The producer relocates from lawrence-api into the agent; the reader/resume/cancel logic survives
almost verbatim, now reading the agent-owned stream.
So: lock the stream wire shape now (raw AI SDK chunks already align both sides) so the
Python writer and TS reader agree and we never double-buffer. Decide Upstash vs self-hosted Redis (prd-us
second region); chunk coalescing is a cheap follow-up on per-command billing.
04Background execution
Run the agent as an Inngest function with an event consumer in lawrence-api that creates
the thread. Output reuses AIThread/AIMessage with a kind field
(user_chat | scheduled | proactive). No new entity.
Step-per-loop vs step-per-run: answer empirically and early. The framework eval flagged Inngest
step-boundary overhead (~200ms+/iteration) as a possible deal-breaker; since durable streams already give
resume and observability, step-per-run may be enough. But the tradeoff cuts the other way for
resilience: to resume a generation mid-run after an ungraceful kill or a run that
outlived the drain window (rather than restart from scratch), the checkpoint boundary has to be inside
the loop — so survival argues for step-per-loop, latency argues for step-per-run.
Don't reach for durable resume just to survive a routine deploy. A graceful drain
(stop taking new requests on SIGTERM, let in-flight runs finish within the orchestrator's grace window,
then exit) survives an ordinary agent deploy for interactive runs with zero in-loop checkpointing.
Durable/resumable runs are for what draining can't cover: runs longer than the grace window, ungraceful
kills (crash, OOM, spot reclaim), and background runs with no request to drain.
Auth is the long pole: the agent acts as the user with no active session, which is a
security and compliance question in a legal product. Start that design when 03 starts, not when 04 does.
The fulfilment gap is narrow. Almost everything a background agent does is already
server-fulfilled: all creates (emails, notes, messages, tasks, key dates, documents), plus
documents:// edits, filled-form fields, and folder ops. The only client-fulfilled
writes are edits to emails:// and notes:// (patch resolvers the
browser applies via handleEditToolCall). So a background run can create and act freely; it
just can't revise an existing email or note until those two edit paths move server-side —
Edit-DSL #8C, gated on server-owned content (notes → content-service in flight; emails need the equivalent).
05Triggers
An agent subscribes to Inngest events (material case change, CCO production, user cron).
Target state: Lawrence reacts to a matter update and proactively does something for the lawyer, reporting
back in a new thread.
The machinery already exists engine-side: handle_material_case_change accumulates,
thresholds, and returns should_fire_material_change_event with a payload. Capability 05
consumes that event rather than re-deriving it.
The CCO is already versioned (CaseContextSerializer keeps versions and can fetch the
latest before a timestamp), so "diff since the last MCC" is an API exposure, not new storage.
lawrence-engine already emits app/lawrence-proactive/generate-suggestion-* and
next-step events. Decide whether to route those into agent runs or duplicate trigger logic; don't build
a second proactive brain.
The design content is throttle / quota / dedup: a CCO regeneration storm must not fan out into N
agent runs. The engine-side threshold already coalesces some of this.
06Code execution / sandbox
The harder agent abilities. No longer hypothetical — two concrete drivers already
exist: the numerical amount calculations the current form-filling pipeline does in code
(which agentic form filling has to keep), and court bundle assembly (merge / paginate /
index matter PDFs). Still demand-sequenced and last in the order, but the demand is here.
It's a dependency of fully replacing the form-filling pipeline, not an extra: the
legacy pipeline computes amounts in code, so the in-loop version needs the same. Court bundling needs PDF
primitives (assemble, paginate, index) over matter files — an HTML explainer is exploring the
agentic paths now (court-bundles-agentic-paths).
It's a spectrum, not one sandbox. The two drivers sit at opposite ends. Form-fill
numbers need only a minimal compute sandbox (arithmetic over known inputs, no filesystem
or network — e.g. monty): cheap to stand up, tiny blast radius, lands early and de-risks the
form-filling replacement. Court bundling needs a fuller sandbox — real filesystem,
PDF libraries, memory, and the VFS file bridge (short-lived signed tokens) to read and write the
documents.
So policy gates (no arbitrary code over PII / disclosure docs without an approved policy) matter most
for the fuller tier; the minimal tier just takes values in and returns a value. Output (a filled value, an
assembled bundle) lands back as a server-fulfilled VFS write.
The demo we faked, made real
On one demo matter, a thread looked like Lawrence had noticed an intake file land, read it, and drafted
an email for the lawyer to review, unprompted. That is capabilities 02, 03, 04 and 05 working together. The
one wrinkle — the run writes without being asked — needs no policy layer: a VFS email is
always a draft, never sent, so the review step is built into the write surface. Here is the real trace.
proactive run · agent_slug: proactive · kind: proactiveno connected client
1
eventapp/case/file.uploaded → trigger (05) matches the matter-intake rule, enqueues a background run (04).
2
authInngest function starts as the matter's lawyer via the stored permission grant. No session, no browser.
3
readvfs:// read the intake file and the current CCO through the agent's vfs_config().
4
createLawrence calls create on emails:// — no send tool exists, so the write is a draft by construction. Create is server-fulfilled, so the draft is written server-side and this works in a background run today.
5
persisttool outputs + draft written server-side (03), since no client exists to forward them.
6
notifyconsumer opens a new kind: proactive thread; the lawyer sees "I drafted this for your review" on next visit.
The same path serves autonomous action on a material case change: swap the trigger for
should_fire_material_change_event, feed the CCO diff since the last MCC as context, and give the
agent the safe live-write tools (create task, set key date, add note). It still has no send tool, so anything
client-facing stays a draft for review — auto-execute is just which tools the agent holds.
The other backend: lawrence-api
Half of this plan lives in a repo the ladder doesn't draw. lawrence-api (platform-v3, TS) owns the thread
model, the persistence, the auth boundary, and the client-fulfilled write paths — so it's in scope as a
strategic backend in its own right, not a list of favours from another team. The agents-side envelope
generalisation (01) has a direct counterpart here, and it's the biggest piece: the thread model.
Generalising the thread model beyond matters
Today AIThread is matter-anchored by construction: matterId is a required
column, the uniqueness and index keys are [matterId, userId, singleton], every create and list
path requires it, and access is authorised by authRouteForMatter("FIRM") against matter-service.
There is no firm, user, or org scope — the lone exception is AICustomInstructions, already
user-scoped, a useful precedent that non-matter AI state can exist. Global Lawrence, the library precedent
drafter, Lawra (admin) and a client-app Lawrence are none of them matter-scoped, so today they have nowhere
to persist.
The clean move mirrors the envelope: just as 01 makes matterId optional and folds it into
agentParams, the thread carries a generalised scope instead of a hardcoded
matter.
Scope = (scopeType, scopeId) — matter | firm | user | org —
with matterId kept as a nullable, indexed denormalised column for the
99%-of-volume matter case, so matter-thread queries don't regress. The unique key generalises to
[scopeType, scopeId, userId, singleton].
Add kind (user_chat | scheduled | proactive, the refactor
plan's already-decided field) and agentSlug (which agent owns the thread).
Both are orthogonal to scope and fall out of 01's slug routing; the legacy mode enum folds into
them over time.
Authorisation generalises with it: authRouteForMatter becomes
authRouteForScope(type, id) — matter → matter-service ACL (today), firm → firm
membership, user → self, client → client + their lawyer. This is the lawrence-api twin of 01's
per-agent scoping (the _clean_tool_parameters override on the agents side): the same idea on
both ends of the wire.
Migration is backward-compatible: every existing thread is matter-scoped, so backfill
scopeType = matter, scopeId = matterId and leave matterId populated; new
non-matter threads set their scope with matterId null.
Thread scope is not the same as data-access scope. A Global Lawrence thread is user-scoped,
yet reads across many matters.
That distinction is the trap. A global thread belongs to the lawyer (user scope), but its tools read many
matters' metadata — a data-layer concern (permission-scoped cross-matter reads), not a thread-scope one.
Generalising where a thread lives and generalising what an agent can read are two separate
problems; conflating them is how the isolation guarantee gets lost. This sub-section is the persistence half
of Threads M2 / Global Lawrence, already on the roadmap — here aligned with the agents envelope so both
halves move together. (AIMatterMemory hits the identical question the moment memory goes firm- or
user-wide.)
The rest of the lawrence-api surface
Permission-scoped cross-matter reads: matters-search + metadata reads enforced at the
data layer per the user's ACL — Global Lawrence's real work, the data-access half of the split above,
and it survives prompt injection by construction.
Server-fulfilled edits for emails:// and notes://: the only
two client-fulfilled writes, so a background agent can't revise an email or note until they move server-side
(Edit-DSL #8C; notes → content-service in flight, emails need the equivalent). Creates already work.
Trigger event coverage: a file-upload / intake event if one isn't already emitted
(case.opened, message.received, note.created exist).
CCO diff API (lawrence-engine, not lawrence-api): expose the versioned-CCO diff via an
endpoint or the MCC event payload.
What depends on what
The order is a dependency graph, not a staffing plan. What must come first, and what can move at the same
time:
keystone milestone depends on cross-repo (lawrence-api) lawrence-api lane — can start now Left–right is rough sequence, not a calendar; nodes in the same column run in parallel.
02 (framework refactor) is the keystone. 04, 05 and the full half of 03 all build on
its emitter seam and an off-HTTP loop; nothing downstream is safe to start before those seams exist.
01 (envelope) runs in parallel with 02 — it's a routing/schema layer in front of
the existing agent, not loop internals. It does need a coordinated lawrence-api change (collapse the three
chat clients) and, for non-matter agents, the thread-model generalisation above.
03 splits. The client-facing half (Pratik's PoC) is already in flight and independent;
the full half (agent owns the stream) waits on 02's emitter.
04 → 05. Background execution needs 02 + 03's server-side persistence + the
agent-as-user auth grant; triggers are thin on top of 04. Background email/note edits additionally
need server-fulfilled writes (#8C); creates already work.
06 runs alongside the 04→05 chain. Code execution only needs 01 + 02, so both
sandbox tiers (minimal for form-fill calcs, full for bundling) proceed independently of durability and
triggers.
The lawrence-api track is its own lane. Thread-model generalisation and
server-fulfilled edits don't block the lower rungs; they gate what sits above the "many Lawrences" line
(non-matter agents) and background edits respectively. Notes → content-service is already moving.
So from day one, three things can move at once: 01 + 02 on the agents side, the client-facing
durable-stream half on the lawrence-api side, and the thread-model generalisation. The 04→05 chain and
06 open up the moment 02's seams land.
Not doing this horizon
Full framework migration (LangGraph/Mastra) — the call is stay-home, decompose first. Voice mode
waits on durable streams. Memory/personalisation beyond keeping the nonce-composer seam. An agent config
UI — agents-as-code per Next Steps. (Code execution is no longer on this list — it has two real
drivers now, see 06 — but it stays last in the order, gated on the VFS bridge.) The point of writing
these down is so the ladder stays a ladder.
One scope boundary worth stating: the VFS-native, cited, conversational, proactive
message/email drafting experience is its own product project. This plan only covers retiring
today's bespoke message-drafting service directly, the moment structured output exists —
parity, minus a deployable unit. The nicer experience is built on top, separately.