previews · Architecture · The loop, in TypeScript · Framework shape

Lawrence engineering · Companion to "The loop, in TypeScript" · sketch

The agent framework, in lawrence-api

A first-pass shape for the new agents/ module: a generic framework/ over the AI SDK plus the generalised VFS, and thin {agent}/ definitions where an author supplies only a Langfuse prompt reference, a VFS config (which namespaces, which operations), the scope/principal it runs as, and an optional output schema. It's the TypeScript counterpart of the Python BaseAgent + hooks plan — same seams, native to lawrence-api. AI Engineering owns the framework; the product teams that own each domain own their slice of the VFS.

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.

router + registry
One route family — POST /agents/:slug/chat and /run; the slug resolves to a definition. Agents are registered as code (a folder), not a runtime config.
auth → principal + scope
Reuses lawrence-api's auth middleware to set the principal (admin/lawyer/…) and scope identity; the framework enforces the agent's declared principals and scope.
prompt resolution
langfusePrompt(ref) fetches + compiles the system prompt at run time, so prompt edits ship without a deploy.
the VFS
The generic verbs (read/create/edit/…), gated by the agent's vfs config and dispatched to team-owned adapters in-tier. Detailed next.
the loop + transports
streamText + stopWhen, terminating in text or a validated output object, over an SSE stream or one JSON response. Detailed next.
stream · citations · persistence
The 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
.without(…ns)
drop namespaces from the preset
.only(…ns)
keep just these
.readonly(…ns?)
restrict to read/list — all, or the named
.ops(ns, […])
set the exact ops for one namespace
.with(preset | obj)
merge another preset, builder, or plain config
type-safe
namespace names autocomplete; ops checked against NAMESPACES[ns].ops at registration

A 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 contractobject contract (output)
stream · SSELawrence chatstructured chat (matter-intake, streamed)
run · one JSONa quick text utilitytitle-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 namespaceOwned by (the domain team)
matters · keydates · tasks · filesmatter-service team
documents · artifactscontent-service team
contacts · parties · custom fieldsidentity-service team
messagesmessaging-service team
emailsemail-service team (Nylas)
quotes · feeagreementswork-service team
formsforms / matter-service team
threadsAI 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

POST /agents/:slug/chat auth principal · scope registry slug → def framework loop prompt · context · tools · model tools call services in-tier stream or structured output
// 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_schemaoutput: 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 seamframework/stream.ts — the ai-stream adapters, reused from chat/
compaction / skills mixinscompaction? / skills? flags

This is a sketch — what to pin down next