previews · Architecture & design · Framework redesign

Lawrence engineering · Proposal · June 2026

defineAgent, reshaped

The first pass at the TypeScript agent framework drew good review. This folds that feedback into a proposal: a fluent builder, a declarative central registry, deny-by-default VFS with combinators, and the model resolved from Langfuse config — each shown side by side with what's there today. Nothing here is built yet; it's for reaction before code.

folds review from Jaime, Wills, Arnold, Harry, Scdales, Sean current vs proposed 2 open calls

What review asked for

The framework-shape draft got specific, actionable feedback. Each ask maps to one of the moves below.

ReviewerAskFolded as
JaimeFluent, trpc-ish builder (.with(vfs(…))); a central declarative registry, not a module-load global; model from configA · C · D
Willsmatter-intake shouldn't sit under framework/ — reserve that for cross-agent codeE
ArnoldOpt-out trades safety for convenience — prefer deny-by-default; express it with combinatorsB
HarryModel belongs on the prompt config; validate scope vs vfs consistencyD · noted
ScdalesType-safe modelId from an allowed set?D
SeanMake a single one-shot LLM call from a work-service functionF
Two things are already true (so we don't over-build)

VFS is already deny-by-default. VfsConfig is an allow-list keyed by namespace — anything you don't list, the agent can't touch. So Arnold's ask is about ergonomics for wide agents, not a safety hole to close.

The model can already come from Langfuse. resolvePrompt() already returns config.model from the prompt; the change is to make the in-code model a fallback, not the required source of truth.

A · A fluent builder

One big object literal becomes a chain. Each step returns a new immutable builder; .build() produces the exact same AgentDefinition the runtime already consumes.

Current
defineAgent({
  slug: "prompt-suggestions",
  prompt: langfusePrompt("…/system"),
  model: "anthropic/claude-haiku-4.5",
  scope: "matter",
  output: promptSuggestionsOutput,
  initialContext: buildInitialContext,
})
registerAgent(promptSuggestionsAgent)
Proposed
defineAgent("prompt-suggestions")
  .scope("matter")
  .prompt(langfusePrompt("…/system"), buildVars) // vars fill {{…}}; model from config
  .output(promptSuggestionsOutput)
  .build()
// registration moves to one place — see C

Surface: .scope() .principals() .prompt(ref, varsFn?) .model() .input() .output() .with() .build(). (.context() stays reserved for genuine non-prompt run-context later.)

A′ · Template variables → the system prompt

The biggest clarity miss in the shipped shape: nothing shows that what initialContext returns becomes the {{placeholders}} in the system prompt — they're glued by string-key matching at runtime, invisible at the call site (a typo renders empty, silently). Co-locating the var-builder with the prompt makes the binding structural and self-evident.

Current — implicit, separate
prompt: langfusePrompt("…/system"),
initialContext: async (ctx) => ({
  caseContext: await getCaseContext({
    matterId: ctx.scope.matterId,
  }),
}),
// keys → {{placeholders}} matched at
// runtime; nothing links them in code
Proposed — co-located with the prompt
.prompt(langfusePrompt("…/system"), async (ctx) => ({
  caseContext: await getCaseContext({
    matterId: ctx.scope.matterId,
  }), // → fills {{caseContext}}
}))
// the vars fn belongs to the prompt: reads
// as "these keys fill that prompt"

Optional hardening: declare the prompt's variables — langfusePrompt("…", { vars: ["caseContext"] }) — so a missing/typo'd key is a type error, not a silent empty render. ctx carries principal + scope (incl. matterId), so vars are always assembled server-side.

B · Composable .with() + VFS combinators

Capabilities attach as plugins, so the core stays small and new ones (VFS, tools, later legal data or memory) don't grow the top-level shape. For wide agents, combinators build an explicit allow-list from a preset — still opt-in.

Current — fine for small agents
vfs: {
  matters: ["read", "create"],
  files: ["read"],
}
// allow-list: unlisted = no access
// (already deny-by-default)
Proposed — scales to full Lawrence
.with(vfs({ matters: ["read","create"], files: ["read"] }))

// or, from a preset, still explicit:
.with(vfs.from(MATTER_VFS,
  vfs.readonly("files", "contacts"),
  vfs.without("quotes", "feeAgreements"),
  vfs.merge(vfs.only(FIRM_VFS, "precedents")),
))

Combinators (from, readonly, without, only, merge) only ever narrow a preset — nothing is granted that isn't in the result (Arnold's flow(…)).

Custom tools attach through the same .with(), as a separate plugin — .with(tools({ fillForm })). VFS and tools stay distinct: VFS is config that generates scoped, server-bound tools (the deny-by-default allow-list), while tools(…) are raw tool() objects you own. Both flow through one uniform surface and converge in the loop (answering Peter's "what about other tools besides vfs?").

C · A declarative central registry

The scattered, import-order-sensitive registerAgent() calls become one declarative map (Jaime). The sealed map's key type is the union of registered slugs, so lookups are typesafe and unknown keys are compile errors.

Current
// in every agent module, at import:
registerAgent(promptSuggestionsAgent)
registerAgent(lawrenceMiniAgent)

// global mutable Map; sprawling;
// import-order-sensitive
Proposed
// agents/agents.ts — the one place
export const agents = agentMap()
  .register("prompt-suggestions", promptSuggestionsAgent)
  .register("lawrence-mini", lawrenceMiniAgent)
  .seal()

// agents.get("prompt-suggestions") → typesafe
// a lint/test asserts every agent lands here

D · Model from Langfuse config

The in-code model becomes an optional fallback; the source of truth is the prompt config, so we can change models without a redeploy — the way lawrence-2 / system already do.

Current
model: "anthropic/claude-haiku-4.5" // required, in code

// resolvePrompt() returns config.model,
// but code value is the source of truth
Proposed
.prompt(langfusePrompt("lawrence-2/system"))
// model lives in the prompt config

// resolution order at run time:
//   Langfuse config.model
//   → .model() fallback (optional)
//   → throw if neither
.model("anthropic/claude-haiku-4.5") // optional

Optional: a loose ModelId string-union so an in-code fallback is checked (Scdales), without stopping Langfuse from using ids outside it.

E · Folder layout — framework/ and agents/ as peers

Today everything sits under one agents/ dir with framework/ nested inside it. Splitting them into top-level peers separates the engine from the instances — and makes the framework a self-contained module that can later graduate to a shared @lawhive/agent-framework package (Jaime) by just moving the directory.

Current — all under agents/
agents/
  defineAgent.ts
  registry.ts        # global Map
  router.ts  routes/
  framework/         # ← nested under agents/
    runtime.ts  prompt.ts
    models.ts  vfs/
  prompt-suggestions/index.ts
  lawrence-mini/index.ts
Proposed — top-level peers
lawrence-api/src/
  chat/  content/  …   # existing modules
  framework/         # the engine → @lawhive/agent-framework
    define-agent.ts
    registry.ts      # agentMap()/seal()
    runtime.ts  prompt.ts  models.ts
    vfs/  transport/
  agents/            # instances + populated registry
    agents.ts        # agentMap().register(…).seal()
    prompt-suggestions/index.ts
    lawrence-mini/index.ts
    matter-intake/index.ts

Dependency direction: agents/ → framework/; the framework imports nothing agent-specific. Two seams keep it extractable — the populated registry lives in agents/agents.ts (the mechanism stays in framework/registry.ts), and VFS adapters are injected rather than imported — so graduating to a package is a move, not a rewrite.

F · One-shot calls

For a single structured LLM call from, say, a work-service Inngest function (Sean) — sugar over the run transport.

Proposed
defineAgent("quote-summary").scope("matter")
  .prompt(langfusePrompt("quote-summary/system"))
  .output(QuoteSummary)
  .oneShot() // = stepCountIs(1) + requires an output schema
  .build()

Cleanup folded in

Two calls before this gets built

1. Builder only, or builder + object both? Supporting the object form too makes migration trivial and lets the builder be pure sugar; builder-only is cleaner but a bigger edit.

2. Generic .with(plugin) or named methods? A generic .with((draft) => draft) keeps the core open to new capabilities; named .vfs() / .tools() are more discoverable but fixed.