What review asked for
The framework-shape draft got specific, actionable feedback. Each ask maps to one of the moves below.
Lawrence engineering · Proposal · June 2026
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.
The framework-shape draft got specific, actionable feedback. Each ask maps to one of the moves below.
| Reviewer | Ask | Folded as |
|---|---|---|
| Jaime | Fluent, trpc-ish builder (.with(vfs(…))); a central declarative registry, not a module-load global; model from config | A · C · D |
| Wills | matter-intake shouldn't sit under framework/ — reserve that for cross-agent code | E |
| Arnold | Opt-out trades safety for convenience — prefer deny-by-default; express it with combinators | B |
| Harry | Model belongs on the prompt config; validate scope vs vfs consistency | D · noted |
| Scdales | Type-safe modelId from an allowed set? | D |
| Sean | Make a single one-shot LLM call from a work-service function | F |
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.
One big object literal becomes a chain. Each step returns a new immutable builder;
.build() produces the exact same AgentDefinition the runtime already consumes.
defineAgent({
slug: "prompt-suggestions",
prompt: langfusePrompt("…/system"),
model: "anthropic/claude-haiku-4.5",
scope: "matter",
output: promptSuggestionsOutput,
initialContext: buildInitialContext,
})
registerAgent(promptSuggestionsAgent)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.)
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.
prompt: langfusePrompt("…/system"), initialContext: async (ctx) => ({ caseContext: await getCaseContext({ matterId: ctx.scope.matterId, }), }), // keys → {{placeholders}} matched at // runtime; nothing links them in code
.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.
.with() + VFS combinatorsCapabilities 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.
vfs: {
matters: ["read", "create"],
files: ["read"],
}
// allow-list: unlisted = no access
// (already deny-by-default).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?").
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.
// in every agent module, at import: registerAgent(promptSuggestionsAgent) registerAgent(lawrenceMiniAgent) // global mutable Map; sprawling; // import-order-sensitive
// 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
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.
model: "anthropic/claude-haiku-4.5" // required, in code // resolvePrompt() returns config.model, // but code value is the source of truth
.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.
framework/ and agents/ as peersToday 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.
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
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.
For a single structured LLM call from, say, a work-service Inngest function (Sean) — sugar over the run transport.
defineAgent("quote-summary").scope("matter") .prompt(langfusePrompt("quote-summary/system")) .output(QuoteSummary) .oneShot() // = stepCountIs(1) + requires an output schema .build()
define-agent.ts…).prompt.ts: confirm we're not double-encoding what Langfuse can template natively
(Jaime's "does this undermine composition?").runtime.ts.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.