24. Library APIs
The CLI, editor extensions, and viewer are all built on published packages: @archlang/engine (lexer, parser, resolver, cascade, dependency graph, diff, description rendering, and the v0.10 selector/policy/view evaluation layer), @archlang/lsp (package loader, language server, source formatter), and @archlang/render (headless SVG → PNG rasterization). If you’re building something new — a custom validator, a CI bot, a documentation generator, a non-JS-platform integration — you’ll consume one or more of these directly.
This chapter is for tool authors. It assumes JavaScript / TypeScript and Node.js familiarity. If you only consume .arch files through the editor and CLI, skip to Chapter 25.
The packages
Section titled “The packages”| Package | What it gives you | Use when |
|---|---|---|
@archlang/engine | Source text → AST → resolved model → graph/diff/description/governance | You want the language front-to-back, from raw text to a governed, diffable model |
@archlang/lsp | Package loader + language server + format helper | You need filesystem loading, an editor server, or canonical formatting |
@archlang/render | Headless SVG → PNG rasterization (resvg + bundled fonts) | You need diagram images outside a browser — CI artifacts, doc builds, MCP tools |
lsp depends on engine. render is independent — it rasterizes SVG text that something else produced (the CLI, @archlang/scene’s toSVG, or the viewer); it never parses .arch itself. Pick the package that matches the job.
The package boundary is worth knowing: loadPackage lives in @archlang/lsp, not in engine. It handles filesystem walking and dependency resolution, which only matter when you have a workspace to read from. Pure analysis on already-parsed sources uses engine alone.
@archlang/engine
Section titled “@archlang/engine”npm install @archlang/engineengine is the whole language: syntax (lexer + parser) and semantics (resolver, cascade, graph, diff, description rendering, v0.10 governance evaluation) in one package. This is what every higher-level tool builds on.
Parsing source
Section titled “Parsing source”import { parse } from "@archlang/engine";
const source = ` service #p7f3qa Payments { aspect team: "Payments" repo.url: "https://github.com/acme/payments" spec.url: "https://specs.acme.internal/openapi/payments.yaml" // link out to the real contract rest_create authorize }`;
const result = parse(source, "/main.arch");// result.file — ParsedFile (declarations, uses, manifest if any)// result.tokens — Token[] (the full lexer stream)// result.errors — ParseError[] (syntax errors, each with an optional span + message)parse(source: string, file?: string) is a pure function from source text to a parsed-file AST plus its lex/parse errors. The optional file parameter is recorded on each AST span so downstream tooling can locate errors. No name resolution, no validation beyond syntactic well-formedness, no cross-file work.
Typical uses:
- Syntax highlighting in a non-LSP environment.
- Find-by-string tools walking source for declarations matching a pattern.
- Migration scripts that rewrite source text — parse to find positions, then patch the source directly.
The AST types are exported from the package: ModuleDecl, InterfaceDecl, SurfaceDecl, ProcessDecl, ViewDeclV2, PolicyDecl, TypeDecl, SubprocessDecl, and the supporting span / value / declaration types.
Resolving a package
Section titled “Resolving a package”import { resolvePackage, type PackageInput, type PackageMap } from "@archlang/engine";
const result = resolvePackage(input, deps);// result.model — ResolvedModel (canonical resolved view)// result.diagnostics — Diagnostic[] (resolution diagnostics)resolvePackage(pkg: PackageInput, deps: PackageMap) takes a parsed package (its manifest + parsed files) and a map of its dependencies. It applies type-template stamping (Chapter 15) and produces a ResolvedModel. Structural cascade (Chapter 18) is a separate, explicit pass — call applyCascade(model) afterward to get the fully-cascaded model that validation, the graph, and governance evaluation expect:
import { resolvePackage, applyCascade } from "@archlang/engine";
const { model } = resolvePackage(input, deps);const cascaded = applyCascade(model);A ResolvedModel exposes arrays of resolved modules, surfaces, interfaces, processes, and views:
import type { ResolvedModel, ResolvedModule } from "@archlang/engine";
function totalInterfaces(model: ResolvedModel): number { return model.modules.reduce((sum, m) => sum + m.interfaces.length, 0);}Validation
Section titled “Validation”import { validate } from "@archlang/engine";
const result = validate(cascaded);// result.diagnostics — array of validator diagnosticsvalidate produces every diagnostic the validator can emit: missing required blanks, invalid process step references, aspect invariant violations, cross-package reference failures. Codes are stable; severities can be overridden by a project’s config.
Building the dependency graph
Section titled “Building the dependency graph”import { buildGraph } from "@archlang/engine";
const graph = buildGraph(cascaded);// graph.edges — DependencyEdge[] — one per derived call// graph.modulesById — ReadonlyMap<StableId, ResolvedModule>// graph.modulesByPath — ReadonlyMap<string, ResolvedModule>Every edge in the graph comes from a process step. The graph is what views, blast-radius analysis, and impact reports run on.
Useful helpers in the same module — each takes the graph plus a ResolvedModule, not a bare ID string:
import { getDependents, getDependencies, computeBlastRadius, stableId } from "@archlang/engine";
const payments = graph.modulesById.get(stableId("p7f3qa"))!; // IDs are opaque + permanentconst dependents = getDependents(graph, payments);const dependencies = getDependencies(graph, payments);const blastRadius = computeBlastRadius(graph, payments);Computing diffs
Section titled “Computing diffs”import { diffModels, type ChangeSet, type ModuleDiff } from "@archlang/engine";
const delta: ChangeSet = diffModels(beforeModel, afterModel);// delta.modules — ReadonlyMap<ModuleId, ModuleDiff>// delta.processes — ReadonlyMap<ProcessId, ProcessDiff>// delta.views — ReadonlyMap<ViewId, ViewDiff>Each ModuleDiff carries a status (one of added | removed | modified | renamed | unchanged), a name (its current/final name), a nameChange: { from, to } when renamed, plus the per-field, per-aspect, per-surface, per-interface changes. Iterate:
for (const [id, mDiff] of delta.modules) { if (mDiff.status === "renamed" && mDiff.nameChange) { console.log(`Renamed: ${mDiff.nameChange.from} → ${mDiff.nameChange.to} (${id})`); } else if (mDiff.status === "added") { console.log(`Added: ${mDiff.name} (${id})`); } else if (mDiff.status === "removed") { console.log(`Removed: ${mDiff.name} (${id})`); }}This is the engine the viewer’s diff mode uses (Chapter 14). Pipelines that need machine-readable architecture deltas consume diffModels directly. Both models should be cascaded (applyCascade) before diffing, so field/aspect changes reflect the effective (post-propagation) values readers actually see.
Rendering descriptions
Section titled “Rendering descriptions”import { buildDescriptionIndex, makeDescriptionContext, renderDescription, aspectLookup, fieldLookup,} from "@archlang/engine";
const index = buildDescriptionIndex(cascaded); // build once per model, reuse per description
const ctx = makeDescriptionContext( index, aspectLookup(somePaymentsModule.body.aspects), // resolves bare `@@team` interpolation undefined, // referrerSpace — the space the description lives in fieldLookup(somePaymentsModule.body.fields), // resolves bare `@field` interpolation);
const raw = somePaymentsModule.body.descriptions.map((d) => d.text).join("\n\n");const rendered = renderDescription(raw, ctx);// rendered.markdown — refs (`[[X]]`) resolved to links, aspects/fields interpolated// rendered.issues — DescriptionIssue[] (unresolved refs, etc.)renderDescription is the same renderer the LSP uses for hover and the viewer uses for tooltips. buildDescriptionIndex walks a resolved model once, indexing every named declaration so [[ref]] cross-references and foreign getters ([[X]]@@team, [[X]]@field) resolve; makeDescriptionContext bundles that index with the owning node’s aspect/field accessors so bare @@/@ interpolation resolves correctly. Pass () => undefined for either accessor when a description has no bare interpolation to resolve (processes, subprocesses, and views store a flat description string with no aspects/fields of their own). Use these when building documentation pages that need parity with editor displays.
Selectors, policies, and views (v0.10 governance API)
Section titled “Selectors, policies, and views (v0.10 governance API)”The v0.10 query layer — view/policy/query selectors (Chapter 8, Chapter 28) — is implemented as a set of pure evaluators over a ResolvedModel. This is what archlang policy-check is built on; a tool author reaches for it directly when policy-check isn’t the right shape — a custom governance dashboard, a pre-merge bot that needs structured findings instead of exit codes, or an editor panel that highlights matched nodes live.
Every evaluator shares one context: a node universe (every element, keyed and searchable) plus the dependency graph and its distance closure (for >> / within selector arrows):
import { buildNodeUniverse, buildGraph, buildClosure, type EvalContext } from "@archlang/engine";
const graph = buildGraph(cascaded);const ctx: EvalContext = { universe: buildNodeUniverse(cascaded), graph, closure: buildClosure(graph),};Evaluating a selector. parseSelectorSource turns selector text into an AST; evalSelector runs it against the context:
import { parseSelectorSource, evalSelector } from "@archlang/engine";
const { selector, errors } = parseSelectorSource(`@@team:"Payments"`);if (errors.length) throw new Error(errors[0]!.message);
const result = evalSelector(selector, ctx);// result.sort === "node" → result.nodes is a Set<string> of matched element keys// result.sort === "edge" → result.edges is a Set<DependencyEdge>Evaluating policies. A policy declaration’s forbid/require rules compile down to Findings. Collect the policies out of the parsed files (module-body ones need their subtree scope stamped via collectScopedPolicies), then evaluate:
import { collectScopedPolicies, evalPolicies, activeFindings, type PolicyDecl } from "@archlang/engine";
const topLevel: PolicyDecl[] = [];for (const file of parsedFiles) { for (const d of file.declarations) if (d.kind === "policy") topLevel.push(d.decl);}const policies = collectScopedPolicies(cascaded, topLevel).policies;
const findings = evalPolicies(policies, ctx, { onError: (policy, err) => console.warn(`policy ${policy.name} skipped: ${err}`),});const active = activeFindings(findings); // waived findings filtered outEach Finding carries policy, ruleKind ("forbid" | "require"), sort ("node" | "edge"), anchorKey, severity, and the source spans for both the rule and the violation site — enough to render a CI annotation or an editor diagnostic. This is exactly the pipeline archlang policy-check runs; see packages/cli/src/commands/policy-check.ts for the full reference implementation, including --strict and waiver-expiry handling.
Change gates. when gates (when <subject> added { require review … }) fire on a base→head change rather than a single model, so they need both versions:
import { evalChangeGates } from "@archlang/engine";
const fired = evalChangeGates(policies, baseModel, headModel);// fired[].policy, .verb ("added"|"removed"|"changed"), .matched (element keys), .reviewsevalPoliciesAtGate (in gate-waivers.ts) is the two-model sibling of evalPolicies — it evaluates head-side state-rule findings while resolving waiver effectiveness against both base and head, the rule Studio’s accept-gate runs on a proposal before merge.
Views. planView(view, ctx, viewsByName?) projects a ViewDeclV2 (a show/hide/group/style declaration) into a ViewProjectionPlan — the set of visible nodes, groups, and style overrides a board renderer consumes. resolveViewInstance merges a view <Parent> <Name> { … } instance’s bindings into its parent before projection; resolveKnobBindings resolves a view’s knob parameters. These three are what the LSP and the board’s view pipeline use to turn a view declaration into pixels — reach for evalSelector directly instead if you just need a matched-node set, not a full render plan.
@archlang/lsp
Section titled “@archlang/lsp”npm install @archlang/lspThe LSP package serves three purposes:
- Loading packages from a filesystem-like source.
- Running the language server.
- Formatting source text canonically.
Loading a package
Section titled “Loading a package”import { loadPackage, type LoadedPackage } from "@archlang/lsp";import { ioNode } from "@archlang/lsp/node";import { pathToFileURL } from "node:url";
const pkg: LoadedPackage = await loadPackage(ioNode(), pathToFileURL("/path/to/package").toString());loadPackage(io, rootUri, options?) walks the package root, parses every .arch file, recursively loads dependencies, resolves the package, and returns a LoadedPackage. rootUri is a URI (file://… under Node — use pathToFileURL), not a bare filesystem path. The resolved model and its diagnostics are already on the result:
import { loadPackage, type LoadedPackage } from "@archlang/lsp";import { ioNode } from "@archlang/lsp/node";import { applyCascade, validate } from "@archlang/engine";import { pathToFileURL } from "node:url";
const loaded: LoadedPackage = await loadPackage(ioNode(), pathToFileURL("/path/to/package").toString());const cascaded = applyCascade(loaded.resolved.model);const validation = validate(cascaded);const allDiagnostics = [...loaded.resolved.diagnostics, ...validation.diagnostics];If you need the pre-resolve pieces instead — e.g. to call resolvePackage yourself, the way the incremental compiler does — discoverPackage(io, rootUri, options?) returns a DiscoveredPackage with .input and .deps (a PackageMap) instead of an already-resolved model.
The LspIO interface abstracts filesystem access. ioNode() (from @archlang/lsp/node) builds the Node fs-backed implementation; a browser host supplies its own (an in-memory VFS) satisfying the same interface.
Formatting source
Section titled “Formatting source”import { formatSource } from "@archlang/lsp";
const result = formatSource(originalSource);// result.text — formatted source// result.changed — boolean: did anything change?formatSource(input: string): FormatSourceResult is a pure function from source text to canonically-formatted source text. Useful for pre-commit hooks, format-on-save in custom editors, and code-generation tools that produce .arch source.
Running the language server
Section titled “Running the language server”// Node-side, stdio (what editor extensions use)import { startNodeServer } from "@archlang/lsp/node";startNodeServer();// Browser-side, web worker (what the hosted demo uses)import { startBrowserServer } from "@archlang/lsp/browser";startBrowserServer(virtualFileSystem);You only invoke these directly if you’re building a new editor integration. Existing extensions handle the wiring for you. The protocol surface is standard LSP — initialize, textDocument/completion, textDocument/hover, etc.
@archlang/render
Section titled “@archlang/render”npm install @archlang/renderimport { rasterizeSvg } from "@archlang/render";
const png: Buffer = await rasterizeSvg(svgText);rasterizeSvg(svg: string, opts?: RasterizeOptions): Promise<Buffer> turns an SVG string into a PNG buffer via resvg, with Inter fonts bundled in so text renders identically with no system font dependency — the same path the CLI’s archlang render command and the MCP server’s headless view/diff renders use. @archlang/render doesn’t parse or lay out .arch source itself; it’s the rasterization half of a pipeline that starts with @archlang/scene’s toSVG (pure pixel geometry + scene composition off the engine’s resolved model/graph) and ends here.
A worked example: a CI bot that comments on PR architecture deltas
Section titled “A worked example: a CI bot that comments on PR architecture deltas”import { loadPackage } from "@archlang/lsp";import { ioNode } from "@archlang/lsp/node";import { applyCascade, diffModels } from "@archlang/engine";import { pathToFileURL } from "node:url";
async function describeDelta(beforePath: string, afterPath: string): Promise<string> { const io = ioNode();
const beforeLoaded = await loadPackage(io, pathToFileURL(beforePath).toString()); const afterLoaded = await loadPackage(io, pathToFileURL(afterPath).toString());
const before = applyCascade(beforeLoaded.resolved.model); const after = applyCascade(afterLoaded.resolved.model);
const delta = diffModels(before, after);
const lines: string[] = []; for (const [id, m] of delta.modules) { if (m.status === "renamed" && m.nameChange) { lines.push(`- **Renamed:** \`${m.nameChange.from}\` → \`${m.nameChange.to}\``); } else if (m.status === "added") { lines.push(`- **Added module:** \`${m.name}\` (\`${id}\`)`); } else if (m.status === "removed") { lines.push(`- **Removed module:** \`${m.name}\` (\`${id}\`)`); } } return lines.join("\n");}About 30 lines of glue. The architecture-delta summary in PRs is now automatic.
Versioning
Section titled “Versioning”All published packages follow semantic versioning. Within a 0.x line, the LSP protocol surface and diagnostic codes are considered semi-stable: additions are minor; renames or removals are minor with explicit changelog notes.
The AST shape and the resolved-model shape are considered unstable within 0.x. Tool authors building against them should pin a specific version and re-test on each upgrade. The shapes will stabilize at 1.0.
What’s not in the libraries
Section titled “What’s not in the libraries”- Headless rendering is
@archlang/render’s job, and only half the pipeline. It rasterizes SVG → PNG; it doesn’t parse.archor lay out a diagram. The SVG itself comes from@archlang/scene’stoSVGover the engine’s resolved model + graph. The library APIs in this chapter give you the model and the graph; turning that into pixels is a separate concern layered on top. - No git integration.
loadPackagereads via theLspIOinterface. Git-aware tooling (committing snapshots, computing branch deltas) lives in the CLI and in user code. - No HTTP API. None of these packages opens a port. If you want a service surface, wrap the library in your own server.
Summary
Section titled “Summary”@archlang/engine— source → AST + lexer, resolved model + validation + graph + diff + description rendering + the v0.10 selector/policy/view evaluation API.@archlang/lsp— package loader (loadPackage,discoverPackage), language server (startNodeServer,startBrowserServer), source formatter (formatSource).@archlang/render— headless SVG → PNG rasterization (rasterizeSvg).loadPackagelives in@archlang/lspbecause it needsLspIO(filesystem); its result already carries a resolved model — runapplyCascadeon it before validating, graphing, or diffing.evalSelector/evalPolicies/evalChangeGates/planVieware the pure evaluators behindarchlang policy-checkand the board’s view pipeline — reach for them directly when you need structured findings or a matched-node set instead of a CLI exit code.- AST and resolved-model shapes are unstable within
0.x; LSP and diagnostic codes are semi-stable.
What’s next
Section titled “What’s next”Chapter 25: A SaaS Backend → — opens Part VI, six worked designs showing realistic systems modeled in ArchLang.