Tools, workflows, engines
Tell them apart by who reads the result. One engine per capability, as many exposures as it has readers, and a lint rule so there's only one door in.
“Generate a query” ends up written three times: once as a chat tool, once inside the dashboard builder, once for a page’s form. Each version grounds a little differently, and the fixes land in one and not the others.
Split by who reads the result:
| Who reads it | Example | |
|---|---|---|
| Tool | the model, in its context | lookup returns rows the model summarizes |
| Workflow | the user, as one card. The model gets a short summary | query makes a query card; dashboard makes a dashboard |
| Engine | code. Never exposed by itself | the evidence engine: questions in, grounded queries and rows out |
┌──────────── engine: answerQuestion / collectEvidence ────────────┐
│ one way to turn a question into a checked query and rows │
└──────▲──────────────────▲─────────────────────▲────────────┘
│ │ │
tool: lookup ─────┘ workflow: query┘ workflows: dashboard, goal page, form fields
(model reads rows) (user gets card) (call the engine directly)
Rules
- One door. Outside the engine’s folder, code imports only its
index.ts. An import-boundary lint rule fails the build otherwise (dependency-cruiser, ESLintno-restricted-imports, Nx module boundaries, whatever you already run). - Workflows call engines, never tools or other workflows.
- Every composer has the same shape: plan (one model call) → evidence (one parallel batch) → write (one model call). An open-ended tool loop inside a workflow is the thing to remove.
- Tools return to the model, workflows return to the user. Anything else the user should see goes on a step as an inspectable.
// e.g. a dependency-cruiser rule
{
name: "query-engine-one-door",
severity: "error",
from: { path: "^src/", pathNot: ["^src/engines/query/", "\\.test\\.ts$"] },
to: { path: "^src/engines/query/", pathNot: ["^src/engines/query/index\\.ts$"] },
}
Why it works
A grounding fix lands once and every exposure gets it. And the rule is enforced by the build, not by memory.