API toolkit
src/api/ is the transport-neutral toolkit — one in-memory implementation of every structured operation over a .ddd source (validate, generate, outline, applyPatches, plus the navigational/refactor family). Every surface — the CLI (src/cli/), the MCP server (packages/ddd-mcp via src/tools/), the LSP adapters (Monaco/VS Code), and the in-browser playground (web/) — is a thin adapter over it, so the operation set never drifts per surface. The wire shapes it returns are pinned once in src/diagnostics/contract.ts (D-API-TOOLKIT).
It is browser-safe by construction: every entry point parses in-memory on Langium's EmptyFileSystem (no langium/node, no fs, no Node-only imports), so web/ imports ../src/api straight into the browser worker.
Why it exists
The structured operations were growing inside src/cli/ (Node-bound), but the same operations are needed by at least four surfaces. Re-implementing per surface is exactly the drift the structured contract exists to prevent, and the patch/diagnostic format is Loom's own (ai-diagnostics-contract.md), not an editor/agent standard — so it needs one authoritative implementation plus thin adapters at the boundaries. The toolkit is that core; the CLI shrank to argv + stdout + exit-code wrappers, and applyPatches moving from NodeFileSystem to EmptyFileSystem restored the "src/language/ is browser-safe" invariant.
The entry points
All live in src/api/index.ts. Signatures (verified against source):
// Generative family — pure functions of source
validate(source: string, opts?: { path?: string }): Promise<ValidateReport>
generate(source: string, opts?: { path?: string }): Promise<GenerateReport>
outline(source: string): Promise<Outline>
applyPatches(source: string, patches: ModelPatch[]): Promise<PatchResult> // re-exported from src/language/model-patch.ts
// Navigational read family (src/api/navigate.ts) — address nodes by symbol name
findSymbol(source: string, symbol: string, kind?: string): Promise<FindSymbolResult>
references(source: string, symbol: string): Promise<ReferencesResult>
hover(source: string, symbol: string): Promise<HoverResult>
// Rewrite family (src/api/refactor.ts) — RETURN edits, never apply them
rename(source: string, symbol: string, newName: string): Promise<RenameResult>
quickfix(source: string, code: string, at?: string): Promise<QuickfixResult>
unfoldMacro(source: string, macro: string, on: string): Promise<UnfoldMacroResult>
validate runs the Langium phases (lex → parse → link → AST-validate) and the IR validator (lower → enrich → IR-validate), then returns the full ValidateReport. A parse/lex error short-circuits the IR phases — the AST is incomplete and lowering could throw — and any internal IR failure is surfaced as a single loom.ir-internal diagnostic rather than a thrown exception. generate adds the deployable manifest (name / platform / port) but writes no files — file emission stays in the CLI's generate system -o. outline is cheap (parse + walk, no IR phases) and always returns a valid object, even on a broken AST.
The diagnostic/outline serializers themselves live in src/api/report.ts (buildValidateReport, buildGenerateReport, langiumDiagnosticToJson, irDiagnosticToJson, sortDiagnostics) — pure, no Node imports. sortDiagnostics gives a total deterministic order (ranged diagnostics first by (line, character) — CST-backed ones plus every IR diagnostic whose check attached the IR node's origin, which irDiagnosticToJson resolves to the declaration's span — then rangeless IR diagnostics, tie-broken by (code, node)) so two runs over the same model are byte-identical. LOOM_VERSION ("0.1.0") is the single source for the report loomVersion field and the CLI .version().
The wire contract
src/diagnostics/contract.ts is pure types, zero imports — the shared shape both the CLI --json mode and the browser validate tool speak, so neither can drift. Key shapes:
ValidateReport — parse --json / validate --json:
interface ValidateReport {
loomVersion: string;
model: string; // the source path / "<source>"
ok: boolean; // true iff no diagnostic has severity "error"
summary: { errors: number; warnings: number; infos: number };
diagnostics: JsonDiagnostic[];
outline: Outline; // always present, even on a failing model
}
JsonDiagnostic — one diagnostic, normalized across all phases:
interface JsonDiagnostic {
code: string; // stable machine id, e.g. "loom.bare-aggregate-in-type"
severity: "error" | "warning" | "info";
phase: "parse" | "macro-expand" | "scope-link" | "ast-validate" | "ir-validate" | "generate";
message: string;
node?: string; // canonical address "<keyword> <Context>.<Decl>[.<member>]"
range?: JsonRange; // present for CST-backed diagnostics; absent for IR ones
sourceText?: string; // the offending source slice
fixHint?: JsonFixHint; // repair affordance in MODEL terms (carries a ModelPatch)
related?: JsonRelated[];
}
GenerateReport is ValidateReport minus outline, plus deployables: GenerateDeployable[] ({ name, platform, port? }). Outline is the node address book — { systems: OutlineSystem[]; contexts: OutlineContext[] }, each context listing its aggregates / valueObjects (each an OutlineDecl of { node, members[] }) plus workflows / enums / events / repositories, and each system additionally listing its uis (an OutlineDecl per ui, whose members are the pages / components / stores inside it) — the same address space node and ModelPatch.target use. An address qualifies a node by every named declaration enclosing it, so a page reads page <Ui>.<Area>.<Page> and an entity part's field reads entity <Context>.<Aggregate>.<Part>.<field>.
Addressing a node (ModelPatch.target)
Every patch names its target by the SAME canonical address the outline lists and a
diagnostic's node carries:
<keyword> <Context>.<Decl>[.<member>]
All three parts are load-bearing, and this is where hand-written patches go wrong:
| Written | Result |
|---|---|
Order.total |
not found — no keyword |
aggregate Order.total |
not found — no context qualifier |
operation Sales.Order.total |
not found — total is a property, so it reads under aggregate |
aggregate Sales.Order.total |
✅ resolves |
The keyword is the innermost enclosing DECLARATION's keyword, not the member's own
kind: a property, a derived, a page state field and an action all read under the
declaration that holds them (aggregate Sales.Order.total), while a declaration that
has its own keyword uses it (operation Sales.Order.bump, page Admin.Back.Board,
entity Sales.Order.Line.qty). A payload keeps the spelling the author used
(command Sales.PlaceOrder).
A member-level replace, end to end:
system Shop {
subdomain Sales {
context Orders {
aggregate Order {
total: int
}
repository Orders for Order { }
}
}
// … storage / resource / deployable …
}
[{ "op": "replace", "target": "aggregate Orders.Order.total", "source": "total: money" }]
$ ddd patch shop.ddd --patches bump.json # patched source on stdout
aggregate Order {
total: money
}
When a target doesn't resolve, the error is the address book rather than a bare "not found" — it prints the accepted shape, the nearest matching addresses, and the addresses the model actually declares:
$ ddd patch shop.ddd --patches typo.json
patch error [replace aggregate Order.total]: target 'aggregate Order.total' not found
addresses are `<keyword> <Context>.<Decl>[.<member>]` — the keyword and every
qualifying segment are required (`aggregate Sales.Order.total`, not `Order.total`);
`ddd parse <file> --json` prints the same address book as `outline`
did you mean: aggregate Orders.Order.total
address book (8):
aggregate Orders.Order
aggregate Orders.Order.total
…
applyPatches is atomic: if any patch in the batch fails to resolve (or two edits
overlap), nothing is applied and text comes back as the original source.
The contract also pins the navigational results: FindSymbolResult = NavSymbol | NavError, ReferencesResult, HoverResult, and the rewrite results RenameResult | QuickfixResult | UnfoldMacroResult (each an EditResult { edits: NavTextEdit[]; title? } or an EditError). An unresolved or ambiguous symbol returns NavError with candidate addresses — never a silent pick.
A concrete round trip
A .ddd source with a cross-aggregate bare type reference (which the scope provider forbids):
system Shop {
context Sales {
aggregate Order { line: Item }
aggregate Item { sku: string }
}
}
await validate(src) returns:
{
"loomVersion": "0.1.0",
"model": "<source>",
"ok": false,
"summary": { "errors": 1, "warnings": 0, "infos": 0 },
"diagnostics": [
{
"code": "loom.bare-aggregate-in-type",
"severity": "error",
"phase": "ast-validate",
"message": "...",
"node": "aggregate Sales.Order",
"range": { "start": { "line": 2, "character": 28 },
"end": { "line": 2, "character": 32 } },
"sourceText": "Item"
}
],
"outline": { "systems": [ { "name": "Shop", "contexts": [ /* … */ ], "deployables": [] } ], "contexts": [] }
}
(Exact message and ranges come from the live validator; the shape is what's pinned.)
Adapters, not re-implementations
| Surface | Home | What it does over the toolkit |
|---|---|---|
| CLI | src/cli/main.ts |
imports validate / generate / LOOM_VERSION; adds argv parsing, stdout, exit codes, and file emission. |
| Agent tools | src/tools/catalog.ts |
wraps each toolkit fn in a { name, inputSchema, handler } entry; callTool(name, args) dispatches to the handler. |
| MCP server | packages/ddd-mcp / src/mcp/ |
registers the src/tools/ catalog over the Model Context Protocol — transport wiring only, no tool logic. |
| LSP / editors | src/api/lsp.ts |
converts the contract shapes into what Monaco/VS Code speak: toLspDiagnostic(s), fixHintCodeActions (fixHint → CodeAction), ModelPatch → TextEdit[]. |
| Web playground | web/ |
imports the toolkit (and the src/tools/ catalog) straight from ../src — same code, in the browser worker. |
Relation to the agent-tool catalog
src/tools/ (documented in mcp.md) is a strictly thinner layer over this toolkit, not a parallel implementation. Each loom_* tool — loom_validate, loom_generate, loom_outline, loom_apply_patch, plus the navigational loom_find_symbol / loom_references / loom_hover / loom_rename / loom_quickfix / loom_unfold_macro — is a JSON-Schema-described wrapper whose handler just calls the matching src/api/ function and returns its contract shape. The catalog adds the agent-facing schema and the callTool dispatch; it adds no operation logic. The toolkit owns what an operation does and what it returns; the catalog owns how an agent names and invokes it. Both stay browser-safe, side-effect-free, and deterministic — file emission lives only in the CLI, never in a tool or a toolkit function.