Skip to content

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.

PackageWhat it gives youUse when
@archlang/engineSource text → AST → resolved model → graph/diff/description/governanceYou want the language front-to-back, from raw text to a governed, diffable model
@archlang/lspPackage loader + language server + format helperYou need filesystem loading, an editor server, or canonical formatting
@archlang/renderHeadless 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.

Terminal window
npm install @archlang/engine

engine 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.

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.

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);
}
import { validate } from "@archlang/engine";
const result = validate(cascaded);
// result.diagnostics — array of validator diagnostics

validate 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.

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 + permanent
const dependents = getDependents(graph, payments);
const dependencies = getDependencies(graph, payments);
const blastRadius = computeBlastRadius(graph, payments);
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.

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 out

Each 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), .reviews

evalPoliciesAtGate (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.

Terminal window
npm install @archlang/lsp

The LSP package serves three purposes:

  1. Loading packages from a filesystem-like source.
  2. Running the language server.
  3. Formatting source text canonically.
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.

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.

// 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.

Terminal window
npm install @archlang/render
import { 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.

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.

  • Headless rendering is @archlang/render’s job, and only half the pipeline. It rasterizes SVG → PNG; it doesn’t parse .arch or lay out a diagram. The SVG itself comes from @archlang/scene’s toSVG over 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. loadPackage reads via the LspIO interface. 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.
  • @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).
  • loadPackage lives in @archlang/lsp because it needs LspIO (filesystem); its result already carries a resolved model — run applyCascade on it before validating, graphing, or diffing.
  • evalSelector/evalPolicies/evalChangeGates/planView are the pure evaluators behind archlang policy-check and 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.

Chapter 25: A SaaS Backend → — opens Part VI, six worked designs showing realistic systems modeled in ArchLang.