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).
Update · AI SDK v7 is the base (GA 2026-06-25).
This module shape holds on v7. defineAgent, the VFS config and the two transports are unchanged;
the framework just sits on v7 primitives — and can wrap ToolLoopAgent / WorkflowAgent
rather than hand-composing the loop (an open call). See the migration doc (v7 update) for the
base decision and the deferred-decision changes.
The module
A domain module like the others in lawrence-api/src (it mirrors how
chat/ is laid out: a router.ts, sub-folders for the pieces). The split is
generic framework vs. thin agent definitions.
apis/lawrence-api/src/agents/ ├─ router.ts # POST /agents/:slug/chat — auth, principal + scope, dispatch ├─ framework/ # generic, over the AI SDK — the "BaseAgent" of the TS world │ ├─ defineAgent.ts # the author API; registers a definition │ ├─ runtime.ts # the loop: streamText + stopWhen, or a structured run │ ├─ registry.ts # slug → AgentDefinition │ ├─ context.ts # principal + scope + agentParams → initial context │ ├─ prompt.ts # resolve a Langfuse prompt ref (+ label) │ ├─ vfs/ # the generalised VFS — catalogue, config, generic verbs │ │ ├─ namespaces.ts # ported ResourceNamespace + scope + ops + descriptions │ │ ├─ config.ts # VfsConfig: namespace → allowed ops │ │ └─ verbs.ts # read/list/stat/create/edit — gated, dispatch to content/vfs adapters │ ├─ tools.ts # the few genuinely non-VFS tools (legal search, fill-form…) │ ├─ output.ts # structured-output strategy (forced response tool / generateObject) │ ├─ citations.ts # citation config + the ❬…❭ parser │ ├─ stream.ts # ai-stream wire format (reuses chat/agent-mode adapters) │ └─ types.ts # AgentDefinition, AgentRequest, Principal, Scope ├─ shared/ │ ├─ tools/ # reusable non-VFS tools (legal search, fill-form…) │ └─ schemas/ # shared zod (participant, fieldValue, citation…) ├─ matter-intake/ # a specific agent — thin │ ├─ agent.ts # defineAgent({ slug, prompt, vfs, scope, principal, output }) │ └─ schemas.ts # matterDraft (output) + agentParams └─ lawrence/ # (later) Lawrence re-expressed as a definition over the same framework └─ agent.ts
Only framework/ and shared/ are written once. Adding an agent is
adding a folder with a thin agent.ts — the "delete test" of the Python plan: drop the
folder and the agent cleanly disappears.
What an agent author writes
The whole of matter-intake/agent.ts. A prompt reference, a VFS config, who it
runs as, and the shape it returns — nothing about the loop, streaming, auth, or wiring.
import { defineAgent, langfusePrompt } from '../framework'; import { matterDraft, matterIntakeParams } from './schemas'; export default defineAgent({ slug: 'matter-intake', prompt: langfusePrompt('matter-intake/system', { label: 'production' }), // ← Langfuse ref model: 'anthropic/claude-sonnet-4.6', // routed via AI Gateway principals: ['admin'], // who may invoke it scope: 'firm', // can address firm-rooted paths; opens a matter params: matterIntakeParams, // zod: validated agentParams output: matterDraft, // zod → structured run → OPENING matter // the agent's reach is a VFS config, not bespoke tools — // namespace → allowed ops (a subset of what the namespace supports): vfs: { matters: ['read', 'create'], // firm-scoped: list / open a matter (OPENING) files: ['read'], // the uploaded intake docs parties: ['read'], notes: ['create'], // the matter summary / file note keydates: ['create'], tasks: ['create'], }, // tools: [] — matter-intake needs none; other agents add tools: [legalSearch, …] for non-data actions });
An agent is a config object, not a class. A Langfuse prompt, a VFS config (which namespaces, which ops), scope + principal, an output schema. The framework turns that into the generic VFS verbs and the loop.
What the framework owns
Why an agent definition stays that thin: everything generic lives in framework/,
written once and shared by every agent. The big two — the VFS and the transports — get their own
sections next.
POST /agents/:slug/chat and /run; the slug resolves to a definition. Agents are registered as code (a folder), not a runtime config.admin/lawyer/…) and scope identity; the framework enforces the agent's declared principals and scope.langfusePrompt(ref) fetches + compiles the system prompt at run time, so prompt edits ship without a deploy.read/create/edit/…), gated by the agent's vfs config and dispatched to team-owned adapters in-tier. Detailed next.streamText + stopWhen, terminating in text or a validated output object, over an SSE stream or one JSON response. Detailed next.ai-stream wire format (the existing chat/agent-mode adapters), the ❬…❭ citation parser, and the scope-generalised thread model.The VFS config, generalised from matter level
Today the VFS is matter-level and all-or-nothing: every agent-visible namespace is exposed,
every path rooted at a matter (documents://<matterId>/…). Generalising is two moves —
make the namespace set per-agent and op-aware, and let the scope root climb above
the matter.
1 · Per-agent, op-aware. Instead of “every namespace minus parties,”
each agent declares a VfsConfig — which namespaces it gets and which operations on each
(a subset of what the namespace supports). The framework renders the prompt's namespace section from
exactly these, and exposes the generic verbs gated to them. Lawrence is just the config with (almost)
everything on; matter-intake is the tiny config above.
2 · The scope root climbs. A path is
<namespace>://<scopeRef>/<resource>; the namespace's scope sets the
ref's level (matter / firm / user / org / jurisdiction), and the agent's declared scope + the
ACL bound which refs it may address. The neat part: a matter becomes a resource in a firm-scoped
matters:// namespace, exactly as a document is a resource in a matter-scoped
documents:// one. “Open a matter” is create matters://<firm>
— the same recursion, one level up. That one new namespace is the only VFS piece the trial adds.
// framework/vfs/namespaces.ts — the catalogue (ported from shared_types, + scope & ops) export type VfsOp = 'read' | 'list' | 'stat' | 'create' | 'edit'; export type VfsScope = 'matter' | 'firm' | 'user' | 'org' | 'jurisdiction'; export const NAMESPACES = { // matter-scoped — today's set documents: { scope: 'matter', ops: ['read','list','stat','create','edit'], desc: 'Editable Word-grade documents…' }, files: { scope: 'matter', ops: ['read','list','stat','edit'], desc: 'Read-only uploaded originals…' }, notes: { scope: 'matter', ops: ['read','list','create','edit'], desc: 'File notes…' }, emails: { scope: 'matter', ops: ['read','list','create','edit'], desc: 'Email — drafts editable, sent read-only…' }, tasks: { scope: 'matter', ops: ['read','list','create'], desc: 'To-do items…' }, keydates: { scope: 'matter', ops: ['read','list','create'], desc: 'Deadlines & hearings…' }, contacts: { scope: 'matter', ops: ['read','list'], desc: 'People & companies…' }, forms: { scope: 'jurisdiction', ops: ['read','list'], desc: 'Form definitions for the jurisdiction…' }, // …artifacts, calls, messages, quotes, feeagreements — matter-scoped, as today // climbing above the matter — the generalisation precedents:{ scope: 'firm', ops: ['read','list'], desc: 'Firm template library…' }, matters: { scope: 'firm', ops: ['read','list','create'], desc: 'Matters in the firm — open one (OPENING)…' }, // NEW threads: { scope: 'user', ops: ['read','list'], desc: 'Prior AI threads for the lawyer…' }, } as const; export type Namespace = keyof typeof NAMESPACES;
// framework/vfs/config.ts — what an agent declares export type VfsConfig = Partial<Record<Namespace, VfsOp[]>>; // namespace → allowed ops (⊆ NAMESPACES[ns].ops) // validated at registration: every requested op must be supported by the namespace, and the // agent's `scope` must permit the namespace's scope (a firm agent can't read a matter it didn't open).
The namespace vocabulary + descriptions come straight from the Python
shared_types.ResourceNamespace / NAMESPACE_DESCRIPTIONS (16 namespaces); the
generalisation adds a scope and an explicit ops list per namespace, the per-agent
VfsConfig, and the firm-rooted matters://. Each namespace is owned by the product team that
owns that domain (see Who owns what) — the catalogue is assembled from their adapters, not authored centrally.
Configuring the VFS — opt-in, or a preset to trim
A scoped agent lists what it needs; an agent like Lawrence would touch a dozen namespaces, so
it starts from a preset and trims. Both are just the vfs field — it takes a plain config
object or a builder.
Opt-in — the default, and the safe one. A bare object lists exactly the namespaces
and ops you want — the vfs block in matter-intake above. Explicit, and a namespace a team
adds later is not silently granted; the right posture for a scoped agent.
Opt-out — a preset + a builder. A scope preset is every namespace of that scope at its full ops; you refine it with an immutable, chainable builder. This is how Lawrence stays short despite touching everything matter-level:
// framework/vfs/presets.ts — every namespace of a scope, full ops; immutable + chainable export const MATTER_VFS = preset('matter'); // documents, files, notes, emails, tasks, keydates… export const FIRM_VFS = preset('firm'); // matters, precedents… export const USER_VFS = preset('user'); // threads… // lawrence — start from the matter preset, then refine vfs: MATTER_VFS .without('quotes', 'feeagreements') // drop namespaces it shouldn't touch .readonly('files', 'contacts', 'calls') // restrict to read where it only reads .with(FIRM_VFS.only('precedents')), // pull in a firm-scoped namespace
read/list — all, or the namedNAMESPACES[ns].ops at registrationA preset is opt-out: a namespace a team later adds at that scope flows to preset users
automatically — what you want for Lawrence, and exactly why scoped agents stay opt-in. The
vfs field accepts either form; a builder resolves to a VfsConfig at registration.
Two transports: streaming chat, and single-shot agents
Output has two independent axes — transport (a streamed SSE turn, or one
JSON response) and contract (free text, or a validated output object). They
compose — which kills the old mode/surface matrix and makes utility agents
trivially cheap.
| text contract | object contract (output) | |
|---|---|---|
| stream · SSE | Lawrence chat | structured chat (matter-intake, streamed) |
| run · one JSON | a quick text utility | title-gen · suggestions · classify · extract · eval |
Most cheap agents live in the bottom-right — single-shot, structured, no chat. They need none of the streaming machinery (no SSE, no citations, no compaction), so the definition is tiny:
// a single-shot utility agent — the cheapest definition there is export default defineAgent({ slug: 'title-gen', prompt: langfusePrompt('title-gen'), output: titleSchema, // → /agents/title-gen/run returns { title } }); // no vfs, no tools, no thread, no stream
The runtime exposes both as typed entrypoints over the same loop — runtime.stream(def, req)
→ an async iterable of parts, and runtime.run(def, req) → a Promise<Out> —
behind /agents/:slug/chat and /agents/:slug/run. A slug picks its transport; there
are no mode or surface flags.
Threads follow the transport. A single-shot run is thread-less —
there's nothing to persist. A streaming chat turn gets a thread, and that thread is generalised
from matter-anchored to scope-anchored: the lawrence-api AIThread carries
(scopeType, scopeId) — matter | firm | user | org — plus kind and
agentSlug, with matterId kept as a nullable denormalised column for the matter
case. Without that, a firm- or user-scoped chat agent has nowhere to live; with it, every agent persists
the same way.
Who owns what
The split isn't only in the code — it's ownership. AI Engineering owns the framework; the product teams that own each domain own their slice of the VFS.
AI Engineering owns the framework. The agent runtime, the generic verbs and their
semantics, dispatch + gating, the loop and streaming, the defineAgent contract — and the
adapter interface every namespace implements. One framework, every agent.
Each domain-owning product team owns its namespace(s). They implement the adapter and, crucially, own the access policy — who may read / create / edit what. The team that owns the domain owns access to it; the framework never touches their database, only their adapter.
The VFS is just another API onto the domain a team owns — an agent-facing surface alongside their tRPC API. Its access rules belong with the domain, not the framework.
So the namespace catalogue is federated: teams register their namespaces against the
framework's adapter contract; AI Engineering doesn't author them. And it isn't all VFS — an agent's
reach is vfs (platform data, owned by the domain teams) plus tools (everything
else: bespoke actions, external APIs, compute). VFS is the data-access half; tools is the
first-class peer for the rest.
| VFS namespace | Owned by (the domain team) |
|---|---|
| matters · keydates · tasks · files | matter-service team |
| documents · artifacts | content-service team |
| contacts · parties · custom fields | identity-service team |
| messages | messaging-service team |
| emails | email-service team (Nylas) |
| quotes · feeagreements | work-service team |
| forms | forms / matter-service team |
| threads | AI Engineering — the agent's own thread store |
Follows the backing domain services; exact assignments to confirm with each team. The framework
knows only the adapter interface — it never reaches a team's database directly. tools are
owned by whoever builds them (AI Eng or a product team), the same way.
No action-policy layer — the VFS exposure is the control surface
We deliberately don't build a policy engine. What an agent may do is its vfs + tools,
nothing more — and VFS writes are drafts by construction (a create emails://
makes a draft, never a send), so “propose, then a human confirms” is inherent in the write surface,
not enforced by a separate layer. Control is the namespace/op exposure in the config. A formal policy layer
earns its place only once a tool can take an irreversible or external action (an actual send, a payment);
until then it's complexity we don't need.
How a request flows
// framework/runtime.ts — the generic loop, in skeleton export async function runAgent(def: AgentDefinition, req: AgentRequest) { const ctx = await buildContext(def, req); // principal/scope/params (+ initialContext) const system = await resolvePrompt(def.prompt, ctx); // Langfuse const tools = resolveTools(def.vfs, def.tools, ctx); // generic VFS verbs (gated) + non-VFS tools if (def.output) return runStructured({ model: def.model, system, tools, schema: def.output, messages: req.messages }); return streamText({ model: def.model, system, tools, messages: req.messages, stopWhen: [stepCountIs(20)], onChunk: ctx.emit }); }
// framework/vfs/verbs.ts — the only VFS tools the model sees, gated by the agent's config const vfsVerbs = (cfg: VfsConfig, ctx: AgentContext) => ({ read: tool({ inputSchema: z.object({ path: vfsPath }), execute: (i) => dispatch('read', i.path, cfg, ctx) }), list: tool({ inputSchema: z.object({ path: vfsPath }), execute: (i) => dispatch('list', i.path, cfg, ctx) }), create: tool({ inputSchema: z.object({ path: vfsPath, body: z.unknown() }), execute: (i) => dispatch('create', i.path, cfg, ctx, i.body) }), edit: tool({ inputSchema: z.object({ path: vfsPath, patch: z.unknown() }), execute: (i) => dispatch('edit', i.path, cfg, ctx, i.patch) }), }); // dispatch(): parse <namespace>://<scopeRef>/… , assert (namespace, op) is in cfg and // scopeRef is within the agent's scope/ACL, then call the content/vfs adapter — in-tier. // e.g. create matters://<firm> { title, market, jurisdiction, participants, fields } // → mattersAdapter.create(…) → matterServiceClient…createMatter({ status: 'OPENING' })
Same seams as the Python plan
This isn't a new design — it's the capability-sequence
BaseAgent + hooks, expressed in TS.
| Python BaseAgent (the plan) | TS framework seam |
|---|---|
| system_prompt() | prompt: langfusePrompt(ref) — resolved at run time |
| vfs_config() | vfs: VfsConfig — namespace → ops, scope root generalised (matter | firm | user | org). The agent's whole data reach in one object. |
| tools() | the generic VFS verbs (from vfs) + a small tools list for genuine non-VFS actions |
| initial_context() | initialContext(ctx) hook (optional) |
| output_schema | output: zodSchema — switches the terminal step to a structured run |
| two output dimensions (transport × contract) | runtime.stream() / runtime.run() entrypoints + the output schema; one slug per combination — no mode/surface matrix |
| agentParams + principal + matter/scope (cap-01 envelope) | params + principals + scope on the definition; principal/scope set by auth |
| the emitter seam | framework/stream.ts — the ai-stream adapters, reused from chat/ |
| compaction / skills mixins | compaction? / skills? flags |
This is a sketch — what to pin down next
- Prompt resolution. Confirm the Langfuse JS SDK (or a thin prompt-store wrapper) for run-time fetch + compile + caching; the Python side already pulls prompts from Langfuse.
- Thread model. This assumes the lawrence-api
AIThreadgeneralises from matter-anchored to scope-anchored (the cap-01 lawrence-api counterpart). Firm-scoped agents like matter-intake need it. - The
matters://namespace. The one new VFS piece — a firm-scoped namespace whose resources are matters, fronting the existingcreateMattermutation; it's what lets “open a matter” be acreate, not a bespoke tool. Reuse the existingcontent/vfsadapters as the in-tier backend. - Scope ACL on paths. Pin down how a namespace's scope + the agent's
scopebound whichscopeRefs it may touch — e.g. an intake run may write keydates/tasks/notes only to the matter it just opened. - The adapter contract + where namespaces live. AI Eng defines the adapter interface and verb semantics; domain teams implement their namespaces and own the policy. Decide whether adapters stay in
content/vfs(per-team CODEOWNERS) or move into each owning service over time. - Lawrence's migration. The shape supports Lawrence becoming one definition among many, but its citation subsystem + full tool set are the heaviest; bring it across last, after a small agent (matter-intake) proves the framework.