Builder API reference
The TypeScript DSL exported by @diagc/core. Every method returns something chainable, and toJSON() validates.
Source of truth: packages/core/src/builder/, with each notation's builder in packages/core/src/notations/<notation>/builder.ts and the activity builder in packages/core/src/activity/builder.ts.
import { model } from '@diagc/core';
model(id, opts?) → ModelBuilder
| Param | Type | Notes |
|---|---|---|
id |
string |
The model id. |
opts.name |
string? |
Display name. Defaults to id. |
m.node(id, opts?) → NodeRef
Declares an entity and returns a handle for containment and relations.
| Option | Type | Notes |
|---|---|---|
name |
string? |
Defaults to id. |
type |
string? |
Free-form; see registry defaults. |
icon |
string? |
Free-form. |
image, shape |
string? |
Asset refs; see Model reference. |
color, textColor |
string? |
color also wins over a notation's fill (e.g. C4's solid palette). |
technology |
string? |
Composed into the type subtitle: [Container: Java, Spring Boot]. See Draw a C4 diagram. |
threats |
Threat[]? |
STRIDE findings, id and all. Prefer ref.threat(), which synthesizes the id. See Model reference. |
comments |
Comment[]? |
Remarks, id and all. Prefer ref.comment(), which synthesizes the id. See Model reference. |
links |
Link[]? |
Resources this node points at, listed in its bubble under the comments. Prefer ref.link(). See Model reference. |
description |
string? |
Detail panel only — never drawn on the canvas. |
rich |
TextRun[]? |
Bold/italic label runs. name must equal the concatenated run text, or validation fails with invalid-rich. |
textAlign |
'left' | 'center' | 'right'? |
Default left. |
fontScale |
'sm' | 'md' | 'lg'? |
Default md. |
metadata |
Record<string, unknown>? |
|
key |
string? |
Cross-diagram identity. |
include |
string? |
Compose another diagram under this node. |
includePlane |
string? |
Which of the included diagram's planes supplies the grafted structure (default: its default plane). Meaningful only beside include. See Compose diagrams. |
includePlanes |
boolean? |
Carry the included diagram's planes over too — namespaced, notation intact — so each stays viewable standalone via the plane switcher. Meaningful only beside include. See Compose diagrams. |
plane, layer |
string? |
Scope the node. |
columns |
Column[]? |
ER-table rows. |
Undefined options are pruned, so the emitted JSON has no key: undefined noise.
m.table(id, opts) → NodeRef
Shorthand for m.node(id, { type: 'db-table', ...opts }). columns is required.
const users = m.table('users', {
columns: [
{ name: 'id', type: 'uuid', pk: true },
{ name: 'email', type: 'text' },
],
});
ref.contains(...children, opts?) → ref
Nests nodes. Accepts any number of NodeRefs, optionally followed by an options object.
| Option | Type | Notes |
|---|---|---|
plane |
string? |
Assign these edges to a plane. Omit for the default plane. |
platform.contains(comms, identity, billing);
k8s.contains(mail, push, { plane: 'infra' });
Duplicate parent/child/plane triples are ignored, so calling it twice is safe. A node may be contained by several parents — containment is a DAG.
Omitting
planedoes not mean "every plane". An untagged edge belongs to the model's default plane — whichever you declared first. In a diagram with several planes, tag containment explicitly, or reordering yourm.plane(...)calls changes what the diagram means. See Use planes and layers.
m.relate(from, to, opts) → m
| Option | Type | Notes |
|---|---|---|
kind |
string |
Required. Free-form. |
id |
string? |
Explicit relation id. Default ${from}->${to}#${n}. The pair counter advances either way, so a later un-id'd relation on the same pair still gets the suffix it would have gotten without the override. |
label |
string? |
The arrow's text. Drawn in a chip that is ellipsised at about 24 characters (the hover title keeps the full text), so keep it to a phrase. |
labels |
EdgeLabel[]? |
Positioned edge labels; supersedes label when present. See Model reference. |
threats |
Threat[]? |
STRIDE findings, id and all. Prefer FlowRef.threat() — see ref.threat() — which synthesizes the id. See Model reference. |
comments |
Comment[]? |
Remarks, id and all. Prefer FlowRef.comment() — see ref.comment() — which synthesizes the id; a relation created bare through m.relate (no FlowRef) takes comments here. See Model reference. |
description |
string? |
|
layer |
string? |
Must match a declared layer. |
style |
RelationStyle? |
See Model reference. |
polarity, delay |
'+' | '-'?, boolean? |
Causal-loop diagrams — see Draw a causal-loop diagram. |
fromColumn, toColumn |
string? |
ER foreign keys — m.fk sets both. |
Relation ids are generated as from->to#n, with n counting per ordered pair — so two relations between the same nodes never collide. Pass id to name one explicitly instead.
m.fk(from, fromColumn, to, toColumn?, opts?) → m
A kind: 'fk' relation between two tables. toColumn defaults to the target's single primary-key column.
m.fk(orders, 'user_id', users);
Throws if toColumn is omitted and the target has zero or several primary-key columns. Declare the target table, with its PK, before calling.
It does not set fk: true on the source column — that flag only prints the FK marker, so set it on the column yourself. See Draw an ER diagram.
m.layer(id, opts?) → m
| Option | Type | Notes |
|---|---|---|
name |
string? |
Defaults to id. |
tint |
string? |
Colour for this layer's relations. |
m.plane(id, opts?) → m
The first plane declared is the default and owns untagged containment.
| Option | Type | Notes |
|---|---|---|
name |
string? |
Defaults to id. |
containmentOf |
string? |
Borrow another plane's structure. |
layers |
string[]? |
Layers on by default in this plane. A default, not a floor: hosts with a layer switch start from this (presetLayers) and can turn them off — an export, which has no switch, always draws them. |
baseRelations |
boolean? |
false hides untagged relations. |
notation |
NotationId? ('causal-loop' | 'git-graph' | 'c4' | 'second-order' | 'fishbone' | 'threat-model' | 'plan') |
Prefer m.gitGraph()/m.secondOrder()/m.fishbone()/m.threatModel()/m.plan() for git-graph/second-order/fishbone/threat-model/plan. Overrides m.notation() for this plane. |
hides |
string[]? |
Shared node ids to hide here, promoting their contents into their place. |
hidesTree |
string[]? |
Shared node ids to hide here together with their contents, however deep. A child another visible box also contains stays. |
m.gitGraph(opts?) → GitGraphBuilder
Declares the model a git branching diagram: a plane with the git-graph notation that must be the first plane declared (it owns the lanes' containment). Throws if a plane already exists or if called twice.
| Option | Type | Notes |
|---|---|---|
plane |
string? |
Plane id. Default git-graph. |
name |
string? |
Plane name. Default Git graph. |
g.branch(id, opts?) → BranchRef
A lane. Lanes are drawn top to bottom in declaration order. BranchRef is a NodeRef (type branch).
| Option | Type | Notes |
|---|---|---|
name |
string? |
Defaults to id. |
color |
string? |
The lane's colour; its commits and links inherit it. Default: a built-in palette by lane index. |
branch.commit(tag?) / branch.commit(opts) → CommitRef
A commit on the lane, linked from the lane's previous commit — or, with from, the first commit of a new run branched off a commit on another lane. CommitRef is a NodeRef (type commit) with a branch field.
| Option | Type | Notes |
|---|---|---|
id |
string? |
Default <branch>-<n>. |
tag |
string? |
The label above the circle (the node's name). Default none. |
from |
CommitRef? |
Start a new run from this commit (must be on another lane). |
gap |
number? |
Empty columns before this commit (metadata.gap). |
color |
string? |
Overrides the lane colour for this commit. |
branch.merge(src, opts?) → CommitRef
A commit on the lane that absorbs src (a commit on another lane): a merge link from src, plus the usual link from the lane's previous commit. Same options as commit minus from.
g.stage(id, opts) → NodeRef
A named frame across every lane, covering the columns from from's to to's (inclusive, either way round) — a phase of the history. A node of type git-stage with its span in metadata; declare it after the commits it names.
| Option | Type | Notes |
|---|---|---|
name |
string? |
The title at the top of the frame. Defaults to id. |
from |
CommitRef |
The commit whose column the frame starts at. |
to |
CommitRef? |
The commit whose column it ends at. Defaults to from. |
color |
string? |
Frame and title colour. |
m.activity(id, opts?) → ActivityBuilder
Declares an activity frame: a UML swimlane flow. Repeatable — each call is one frame, and frames are ordinary containers on whatever plane the model uses (no notation, no plane creation), so several can share a canvas. ActivityBuilder is the frame's NodeRef (type activity-frame).
| Option | Type | Notes |
|---|---|---|
name |
string? |
Defaults to id. |
act.lane(id, opts?) → LaneRef
A lane. Lanes are drawn top to bottom in declaration order. LaneRef is a NodeRef (type activity-lane).
| Option | Type | Notes |
|---|---|---|
name |
string? |
Defaults to id. |
color |
string? |
The lane's colour. |
lane.region(id?, name?) → RegionRef
An interruptible region: a dashed sub-area nested in the lane, hosting the same element helpers below. RegionRef is a NodeRef (type activity-region). id defaults to ${lane}-region, ${lane}-region-2, ….
Element helpers (LaneRef and RegionRef)
Both extend ActivityScope, so a lane and a region expose the same leaf-creating methods; every helper contains the new node in the scope it's called on and returns a plain NodeRef.
| Method | Type created | Notes |
|---|---|---|
.action(id, name, opts?) |
activity-action |
|
.object(id, name, opts?) |
activity-object |
|
.send(id, name, opts?) |
activity-send |
|
.receive(id, name, opts?) |
activity-receive |
|
.note(id, text) |
activity-note |
text becomes the node's name. |
.decision(id?, name?) |
activity-decision |
Auto-id <scope>-decision, -2, …; name defaults to ''. |
.bar(id?) |
activity-bar |
Fork/join. Auto-id <scope>-bar, -2, …; unnamed. |
.start(id?) |
activity-start |
Auto-id <scope>-start, -2, …; unnamed. |
.end(id?) |
activity-end |
Auto-id <scope>-end, -2, …; unnamed. |
action, object, send and receive take opts: { color?: string }.
act.flow(from, to, label?) → act
A kind: 'control' relation — the ordinary solid arrow between two elements. label is a plain relation label; a guard is just a label like [order accepted].
act.objectFlow(from, to, label?) → act
A kind: 'object-flow' relation — dashed, for data (an object) passing between actions.
act.interrupt(from, to, label?) → act
A kind: 'interrupt' relation — a zigzag jog at the midpoint, for a signal that interrupts a region.
act.noteLink(note, target) → act
A kind: 'note-link' relation from a note to what it annotates — dashed, no arrowhead.
const m = model('flow');
const act = m.activity('actors', { name: 'Actors' });
const orders = act.lane('orders', { name: 'Orders' });
const start = orders.start();
const submit = orders.action('submit', 'Submit order');
const note = orders.note('n1', 'Validated client-side first');
act.flow(start, submit).noteLink(note, submit);
m.secondOrder(opts?) → SecondOrderBuilder
Declares the model a second-order thinking diagram: a consequence tree read off ordinary nodes and a leads-to relation. Throws if called twice. With no plane, the notation is model-wide (nothing about it needs a plane — the notation is flat); name a plane to keep it beside other views of the same model.
| Option | Type | Notes |
|---|---|---|
plane |
string? |
Plane id. Omit to set the notation model-wide. |
name |
string? |
Plane name, when plane is given. Default Consequences. |
| Call | Returns | Notes |
|---|---|---|
so.decision(id, name?, opts?) |
ConsequenceRef |
The root of a tree. opts: description?, color?. Several decisions can share one set of bands. |
ref.then(id, name?, { valence?, label?, description?, color? }) |
ConsequenceRef |
What follows from ref: creates the consequence node and the leads-to relation that leads to it. valence is '+' | '-' | '0' (good/bad/neutral), default '0'; label labels the arrow. |
ref.leadsTo(other, { label? }) |
ref |
Joins two branches: ref also leads to a consequence declared elsewhere, with no new node. |
ConsequenceRef (returned by both decision and then) extends NodeRef, so it composes with the rest of the builder (relate, layer, contains).
const m = model('splitting-the-monolith');
const so = m.secondOrder();
const split = so.decision('split', 'Split the monolith');
const deploys = split.then('deploys', 'Teams deploy independently', { valence: '+' });
const oncall = split.then('oncall-risk', 'More on-call load', { valence: '-' });
deploys.leadsTo(oncall);
m.fishbone(id, name?, opts?) → FishboneBuilder
Declares the model a fishbone (Ishikawa) diagram: the effect at the head, cause categories as bones, causes and sub-causes hung on them. Throws if called twice ('fishbone() already declared'). With no plane, the notation is model-wide; name a plane to keep it beside other views of the same model — its name defaults to Causes.
| Option | Type | Notes |
|---|---|---|
plane |
string? |
Plane id. Omit to set the notation model-wide. |
planeName |
string? |
Plane name, when plane is given. Default Causes. |
description, color |
string? |
FishboneOpts, applied to the effect node itself. |
| Call | Returns | Notes |
|---|---|---|
fb.category(id, name?, opts?) |
CategoryRef |
A major bone, and the cause-of arrow from it to the effect. opts: FishboneOpts (description?, color?). |
fb.categories(preset) |
Record<string, CategoryRef> |
Seeds a whole preset at once — preset is 'Software' | '6M' | '4S'. Refs are keyed by slug id (presetId, e.g. 'Infrastructure' → infrastructure); index with ! under noUncheckedIndexedAccess. |
category.cause(id, name?, opts?) |
CauseRef |
A cause on that bone, and the arrow from it to the category. |
cause.cause(id, name?, opts?) |
CauseRef |
A sub-cause, and the arrow from it to the cause. Throws on a sub-cause — three levels below the effect (category, cause, sub-cause) is the limit. |
FishboneOpts { description?: string; color?: string } — the same shape at every level.
The three presets, in bone order:
| Preset | Categories |
|---|---|
Software (default first) |
People, Process, Requirements, Code, Infrastructure, Dependencies |
6M |
Man, Machine, Method, Material, Measurement, Environment |
4S |
Surroundings, Suppliers, Systems, Skills |
const m = model('checkout-outage');
const fb = m.fishbone('outage', 'Checkout outage on release day');
const { people, process } = fb.categories('Software');
people!.cause('on-call', 'On-call engineer new to checkout');
process!.cause('review', 'Migration merged without review').cause('single-approver', 'One approver for the whole repo');
m.threatModel(opts?) → ThreatModelBuilder
Declares a threat model: a STRIDE data-flow diagram of external entities, processes, data stores, flows and trust boundaries. Throws if called twice ('threatModel() already declared'). With no plane, the notation is model-wide; name a plane to threat-model an existing architecture beside its other views — that plane then holds its own boundary containment over the same nodes.
| Option | Type | Notes |
|---|---|---|
plane |
string? |
Plane id. Omit to set the notation model-wide. |
name |
string? |
Plane name, when plane is given. Default Threat model. |
| Call | Returns | Notes |
|---|---|---|
tm.entity(id, name?, opts?) |
NodeRef |
An external entity (tm-entity): a user, a third party, anything outside the system. |
tm.process(id, name?, opts?) |
NodeRef |
A process (tm-process) — something the system does with the data. |
tm.store(id, name?, opts?) |
NodeRef |
A data store (tm-store) — where the data rests. |
tm.boundary(id, name?, opts?) |
NodeRef |
A trust boundary (tm-boundary). Put elements in it with .contains(); it is a container, never an endpoint. |
tm.flow(from, to, label?) |
FlowRef |
A kind: 'data-flow' relation between two element refs. A string argument is the flow's label; pass an object instead for the rest of m.relate's options (minus kind). |
opts on the four element helpers is m.node's options minus type and name. Each hands back a plain NodeRef, so contains, relate, layer and the rest of the builder compose with them unchanged.
FlowRef { readonly id: string; threat(opts) } — the relation's id, and the same threat() a NodeRef has.
const m = model('checkout');
const tm = m.threatModel();
const customer = tm.entity('customer', 'Customer');
const web = tm.process('web', 'Web app');
tm.boundary('edge', 'Internet-facing').contains(web);
tm.flow(customer, web, 'HTTPS: cart, card details').threat({ category: 'S', title: 'Credential stuffing on login', severity: 'high' });
ref.threat(opts) → ref
Appends one STRIDE finding to a node or a flow. On every NodeRef, not just the four DFD ones, and on every FlowRef — threats are a generic field, so an existing C4 or ER diagram can be annotated where it stands.
| Option | Type | Notes |
|---|---|---|
category |
'S' | 'T' | 'R' | 'I' | 'D' | 'E' |
Required. |
title |
string |
Required. |
id |
string? |
Default t<n>, n being this element's threat count + 1. Unique within the element; a duplicate throws. |
description |
string? |
|
severity |
'low' | 'medium' | 'high' | 'critical'? |
Unset = unrated. |
status |
'open' | 'mitigated' | 'accepted' | 'not-applicable'? |
Unset = open. |
mitigation |
string? |
Chainable, so a second finding on the same element is another .threat(...). See Model reference and Draw a threat model.
ref.comment(text, opts?) → ref
A remark on the element, shown in its bubble. On every NodeRef and every FlowRef — the same generic-field pattern as ref.threat().
| Option | Type | Notes |
|---|---|---|
id |
string? |
Default the first free c<n> on this element, so removing c1 and adding another remark reuses c1. Unique within the element; a duplicate throws. |
by |
string? |
Author, free text. |
at |
string? |
YYYY-MM-DD. |
Chainable, so a second remark is another .comment(...). A plain relation with no FlowRef (an m.relate() call on its own) takes comments through RelateOpts.comments instead, or the public m.addComment({ relation: id }, text, opts). See Model reference.
ref.link(label, url) → ref
A resource this node points at — a ticket, a design doc — listed in its bubble under the comments. NodeRef only; a relation's resources go in a comment. See Link, which distinguishes this from the node's single navigation target.
m.plan(id?, opts?) → PlanBuilder
Declares a plan (schedule) plane — always a plane, id plan by default, name Plan — with notation: 'plan'. Throws if called twice ('plan() already declared'). It need not be the first plane: add it to an existing model and schedule that model's nodes inside its zones. Every node the plan builder creates is scoped to the plan plane (plane: <id>), so bars never appear on the other planes; nodes you contains() stay shared.
| Call | Returns | Notes |
|---|---|---|
plan.zone(id, opts) |
ZoneBuilder |
A top-level zone (plan-zone). opts = m.node's options minus type/plane/metadata, plus required start and end (YYYY-MM-DD, end inclusive). |
plan.event(id, opts) |
NodeRef |
A top-level event (plan-event); opts as above with required at. |
plan.person(id, name?, opts?) |
NodeRef |
A person node on the plan plane. |
plan.team(id, name?, opts?) |
NodeRef |
A team node on the plan plane — an actor like person, for a role that belongs to a group rather than one individual. |
zone.zone(id, opts) |
ZoneBuilder |
A nested zone, contained on the plan plane. |
zone.event(id, opts) |
NodeRef |
An event inside the zone. |
zone.contains(...refs) |
zone |
Schedules any nodes inside the zone (containment on the plan plane). |
zone.owner(ref) / zone.executor(ref) / zone.checker(ref) |
zone |
A role relation ref → zone of kind owns / executes / checks; ids follow the from->to#n scheme. |
A ZoneBuilder is a NodeRef, so .comment(), .link() and .threat() chain on it.
const m = model('launch');
const plan = m.plan();
const alice = plan.person('alice', 'Alice Ng');
plan.zone('build', { name: 'Build', start: '2026-01-26', end: '2026-03-06' })
.owner(alice)
.zone('api', { name: 'API', start: '2026-01-26', end: '2026-02-13' });
m.legend(opts?) → m
Opt this diagram into an on-canvas key. Calling it at all is the switch — a diagram that never does has no legend in its image, and on the canvas only the hidden, on-demand one that wordless shapes get. A bare m.legend() derives every row.
| Option | Type | Notes |
|---|---|---|
title |
string? |
Default Legend. |
position |
string? |
top-left, top-right, bottom-left, bottom-right. Default bottom-right. |
show |
string[]? |
Derived sections, exactly: layers, kinds, types, marks. Absent means layers, kinds, marks, plus the types whose shape carries no words. |
items |
LegendItem[]? |
Hand-written rows. One naming a derived kind/type recaptions that row in place. |
m.legend({ items: [{ label: 'Fire-and-forget', kind: 'flow' }] });
Calling it twice replaces the config. See Add a legend and DiagramLegend.
m.typeColors(map) → m
A colour convention for the whole diagram: a default accent colour per node type, with * as the fallback for every type without an entry.
m.typeColors({ 'c4-person': '#c62828', 'c4-container-spa': '#f9a825', '*': '#1565c0' });
This is the only way to colour nodes the diagram did not author — an umbrella that composes a dozen includes cannot reach into the grafted nodes, but it can say what a c4-container looks like here. Precedence, highest first: the node's own color, the entry for its type, *. An untyped node with no * entry keeps the registry look.
Successive calls merge (last wins per key). typeColors is presentation, so it does NOT travel through include: the host's convention is kept and every child's is dropped — the diagram you are looking at owns the look.
m.layerRules(rules) → m
Put relations that carry no layer of their own onto layers by class. Each rule names a kind and/or a color (matched against style.color) and the layer to assign; every field a rule names must match; first match wins.
m.layer('http', { name: 'HTTP', tint: '#ef6c00' });
m.layer('sql', { name: 'SQL', tint: '#2e7d32' });
m.layerRules([
{ color: '#ef6c00', layer: 'http' },
{ kind: 'sql', layer: 'sql' },
{ kind: 'fk', layer: 'sql' },
]);
This is the one way a composed diagram can layer relations an include brought in: the umbrella cannot edit grafted relations, but it can say "everything orange is HTTP here". A relation's own layer always beats the rules; a relation no rule matches stays on the base sheet. Combine with a plane's layers presets and baseRelations: false to draw exactly one class.
Successive calls append. Like typeColors, rules are presentation and do NOT travel through include: the host's are kept, every child's dropped. A rule naming an undeclared layer fails validation (unknown-layer).
m.notation(id) → m
Pin the whole diagram's visual language — the model-level fallback a plane's own notation overrides (see m.plane). The one way to opt a planeless diagram into a built-in look without declaring a plane purely to carry it.
m.notation('c4');
id must be one of BUILTIN_NOTATIONS; an unknown id fails validation with unknown-notation, same as an unknown plane.notation. Calling it twice replaces the value.
m.style(id) → m
Pin the model-level renderer style preset (see Model reference) — the
one way to make a diagram always render in a preset like hand-drawn, rather than following
whatever the viewer's own header picker is set to.
m.style('hand-drawn');
An unknown preset id is legal — the renderer falls back to the app-level preference. Calling it twice replaces the value.
m.toJSON() → DiagramModel
Validates and returns the model. Throws DiagramValidationError carrying the full issue list if anything is wrong.
You rarely call this — export default m and the compiler calls it for you.
Gotchas
m.relatetakesNodeRefs, not ids. Keep the handles thatm.nodereturns.m.nodeis not idempotent. Calling it twice with the same id creates two nodes and fails validation withduplicate-node.- A trailing plain object in
containsis always read as options, never as a child. That is only ambiguous if you try to pass something that is not aNodeRef. descriptiondoes not render on the canvas. Put anything the reader must see inname.
See also
- Author diagrams in TypeScript — worked examples.
- Model reference — the JSON this produces.