Spec format

Every field a spec may hold, every node kind, how flows play, sources, validation and the theme file, as archgram prints it.

As archgram prints it. archgram spec prints it for the version you have.

The input format: what a diagram is made of and the rules a spec must follow. Why the format looks like this, and every other fact about archgram, is in its repository's other documents; CONTRIBUTING.md (Each fact lives in one document) says which holds what.

A spec describes the system, never the drawing. It has no coordinates, no sizes and no colours. It names things, groups them and connects them; archgram decides where they go and how they look.

In brief#

One spec that uses each field most diagrams need, and a line for each other section. archgram spec --brief prints this section alone, and archgram spec --section <name> prints another, by the name below.

yaml
archgram: 1
title: shop
description: >-
  A customer places an order with the API, which queues it; a worker
  saves it to Postgres and mails a receipt through Resend.
direction: auto
nodes:
  - { id: browser, kind: browser, label: Customer }
  - { id: api, kind: service, label: API, tech: express, frame: app, source: ../../src/api.ts }
  - { id: queue, kind: queue, label: orders, tech: redis, source: ../../src/queue.ts }
  - { id: worker, kind: service, label: Worker, frame: app, source: ../../src/worker.ts }
  - { id: db, kind: database, label: Postgres, tech: postgresql, source: ../../src/db.ts }
  - { id: mail, kind: service, label: Resend, variant: external, source: ../../src/mail.ts }
frames:
  - { id: app, label: Our services }
edges:
  - { from: browser, to: api, label: POST /orders, source: '../../src/api.ts#app.post("/orders"' }
  - { from: api, to: queue, source: "../../src/api.ts#orders.add(" }
  - { from: queue, to: worker, source: "../../src/worker.ts#new Worker(" }
  - { from: worker, to: db, label: insert order, source: "../../src/worker.ts#saveOrder(" }
  - { from: worker, to: mail, label: receipt, style: dashed, source: "../../src/worker.ts#sendReceipt(" }
flows:
  - { name: an order, steps: [browser, api, queue, worker, [db, mail]] }

A writer that reads the code gives a source to every node a file backs and to every edge, as above, and quotes each source that holds a #.

  • formats: JSON or YAML, and the file name that draws <name>.svg.
  • top-level: every field above the lists, with its values and default, direction and how a flow is drawn among them.
  • nodes: a node's fields, and every kind by category.
  • frames: a frame's fields, and nesting one in another.
  • edges: an edge's fields, and what an edge means.
  • flows: steps, branches, a flow that stops, and how archgram times them.
  • hints: first, last, sameLayer and order.
  • sources: what a source may name, and how archgram looks it up.
  • validation: what makes a spec invalid.
  • theme-file: drawing in a project's own colours.
  • examples: complete specs, in JSON and YAML.

Formats#

The core reads JSON. The optional YAML module reads YAML 1.2 and produces the same spec, so every example below can be written either way; the command line tells them apart by the file's extension (.json, .yaml, .yml). Unknown fields are errors, not ignored, so a misspelt field is caught instead of silently changing the diagram. In YAML, a key written twice, a second document, a tag outside YAML's core schema, nesting deeper than 64 levels and aliases that expand to more than 10 000 values are errors too, and a plain word that reads as a number or a boolean (title: 2026) must be quoted to stay text.

A spec named <name>.archgram.yaml (or .archgram.yml, .archgram.json) draws <name>.svg beside it, so each spec sits next to its drawing and the drawing keeps a plain name; any other spec draws its own name with .svg. -o names another file, and creates its folder when it does not exist.

Top level#

json
{
  "archgram": 1,
  "title": "linkshort",
  "description": "A redirect reads the cache and queues the click; a worker counts clicks into Postgres.",
  "direction": "right",
  "card": "horizontal",
  "logo": "corner",
  "palette": "mono",
  "legend": true,
  "signal": "wire",
  "still": "none",
  "border": "spark",
  "wait": "solid",
  "glow": true,
  "credit": true,
  "nodes": [],
  "frames": [],
  "edges": [],
  "flows": [],
  "hints": {}
}
FieldRequiredValuesDefaultMeaning
archgramyes1The spec format's version
titleyestextThe diagram's name; the SVG's <title>
descriptionyestextThe whole diagram in prose; the SVG's <desc>, read by screen readers
directionnoright, down, autorightThe direction of the flow. auto lets archgram choose: left to right while it keeps its text readable where the diagram is shown (shownWidth), and otherwise whichever of left to right and top to bottom is narrower. It is chosen from the spec alone, so the same spec draws the same bytes, and archgram build says which it chose
shownWidthnoa number of CSS pixels, 200 to 4,000a README on GitHub, 880How wide the diagram is shown where it is read. The widest drawing that keeps its text readable there is shownWidth × 1300 / 880: 1,300 px in a README, 996 px for 674. direction: auto chooses against it, and archgram build warns, still drawing, when a drawing is wider; width never fails check (docs/features/shown-width.md)
cardnohorizontal, verticalhorizontalThe card style for every node
logonocorner, inline, chip, iconcornerWhere technology logos go: the card's corner, before the note (or the technology's name), a chip on the icon, or in place of the icon
palettenoa palette namemonoThe palette; light and dark are chosen when rendering
legendnotrue, falsetrueWhether to draw the legend, when the diagram has one (DESIGN.md, Components: Legend)
signalnowire, spark, arc, comet, dot, pulse, currentwireHow a flow's signal is drawn along the lines (DESIGN.md, Components: Signal)
stillnonone, legend, numbersnoneWhat the still image shows of the flows, where nothing moves: nothing more, each flow in words under the legend, or each step's number on its lines
bordernospark, drain, ring, afterglowsparkHow a lit card's border is drawn from the arrow that reaches it: both ways round with a dot on each growing end, then draining toward the arrow that leaves, once round clockwise, or fading while the card is lit (DESIGN.md, Components: Signal)
waitnosolid, pendingsolidHow a refused card waits for a later flow to pass it: its refusal border as drawn, or marching round it as dashes (DESIGN.md, Components: Refusal)
glownotrue, falsetrueWhether a signal glows, faintly, in the styles that have a glow (wire, spark, arc); nothing else glows
creditnotrue, falsetrueWhether to write a small "by archgram" in the drawing's bottom-right corner, hidden from screen readers
nodesyeslistAt least one node
frames, edges, flowsnolistempty
hintsnoobjectemptyLayout hints, below

The lists are empty above only to show the shape; complete specs are under Examples.

Nodes#

json
{ "id": "api", "kind": "service", "label": "API", "note": "3 replicas",
  "tech": "fastapi", "variant": "multi", "frame": "vpc" }
FieldRequiredMeaning
idyesLowercase letters, digits and hyphens; unique among nodes and frames
kindyesOne of the kinds below; decides the icon and the category
labelyesThe card's title
notenoThe card's subtitle: one short line
technoA technology, by its Simple Icons slug (postgresql, redis, react); shows its logo. Lowercase letters, digits and _
variantnosingle (default), multi (several instances), external (not ours)
framenoThe id of the frame the node sits in
sourcenoThe code behind the node: a path, or a list of paths for a node that stands for several parts (Sources, below)

Kinds, by category:

CategoryKinds
Coreservice, database, queue, cache, storage, users
AI and LLMmodel, vector-store, tool, agent
Build and toolingfile, script, generated, check
Clientsbrowser, mobile, desktop

Frames#

json
{ "id": "vpc", "label": "VPC" }
{ "id": "private", "label": "Private subnet", "parent": "vpc" }

A frame is a boundary around nodes: a network, a trust zone, a team's service. parent nests one frame in another. A frame with no nodes and no child frames is an error.

Edges#

json
{ "from": "api", "to": "redis" }
{ "from": "api", "to": "postgres", "label": "on a miss", "style": "dashed" }
FieldRequiredMeaning
from, toyesNode ids; the edge points from from to to
labelnoA few words on the line
stylenosolid (default) or dashed, for a path taken only sometimes
sourcenoThe code that makes the edge, usually a path with the words of its line after # (Sources, below)

An edge means data or a call moving in the direction of the arrow. Two edges between the same pair in the same direction are an error; use one with a label.

Flows#

json
[
  { "name": "a visit", "steps": ["browser", "api", "redis", "worker", "postgres"] },
  { "name": "a question", "steps": ["chat", "llm", ["find", "count"], "index"] },
  { "name": "a commit with a raw colour", "steps": ["person", "precommit", "core", "checks"], "stop": "checks" }
]

A flow is a path through the diagram that archgram animates, in order. A step is a node id, or a list of ids reached at once: a branch. Every node of a step must be reached by an edge from a node of the step before it, and when a step has several nodes, each must have an edge to a node of the step after it. A spec without flows gives a still diagram. When several flows exist they play one after another, in the order listed.

A flow may stop: stop names the node that refuses it, which must be the flow's last step and a single node. A ✕ marks the line into it, and the refusal travels back along the flow to the node where it began (DESIGN.md, Components: Refusal). The refusing node stays marked until a later flow passes it, so a refused flow followed by one that passes the same node shows a retry.

archgram times the flows itself (DESIGN.md, Layout: Motion): a signal's speed is fixed, so a longer line takes longer; a branch's signals leave together; signals that meet at a node arrive together. A card is lit from the moment a signal reaches its arrowhead until every signal it sends has arrived; the last card of a flow stays lit until its border has closed. A refused flow's refusal travels back before the next flow starts. A screen reader hears each flow in words, after the description, a refused flow ending with the node that refused it.

Hints#

The layout needs no help, but it accepts some.

json
{
  "hints": {
    "first": ["browser"],
    "last": ["postgres"],
    "sameLayer": [["worker", "redis"]],
    "order": [["api", "worker"]]
  }
}
HintMeaning
first, lastThese nodes go in the first or last layer
sameLayerEach list shares one layer (one column when the flow runs right)
orderWithin a layer, each list keeps this order, top to bottom or left to right

A hint that contradicts the edges (a node placed before the node that feeds it) is an error, reported with both nodes named.

Sources#

A node or an edge may name the code behind it, so archgram can say when that code is gone. Sources are never drawn: a spec gives the same SVG with or without them. Why, and what the check cannot find, is in docs/features/sources.md.

yaml
nodes:
  - { id: worker, kind: service, label: Worker, source: ../../src/worker.rs }
  - { id: billing, kind: service, label: Billing, source: [../../src/charge.ts, ../../src/refund.ts] }
edges:
  - { from: worker, to: links, source: "../../src/worker.rs#links.insert(" }

A source is a path to a file or a folder, from the folder the spec is in, with its folders separated by / on every system and each name with the capitals it has on disk; it is never absolute, never only . and .., has .. only at its start, and never names .git or a file that commonly holds secrets rather than code (.env, .env.*, .npmrc, .pypirc, .netrc, .git-credentials, *.pem, *.key, *.p12, *.pfx, id_rsa, id_dsa, id_ecdsa, id_ed25519, or anything in .ssh, .aws, .gnupg or .docker). After the first # (so a path cannot hold one), it may carry a few words copied from one line of that file: the line that makes the edge, or that defines the part, at least 3 characters other than spaces. The words are looked for as written, anywhere in the file, so the source holds while the file changes around them; they are found in a comment too, so pick words from the code itself. source is one source or a list of them, never an empty list, and a spec names at most 1000 sources.

archgram check reports, as problems, each path with nothing there, each file that does not hold its source's words, and each folder given words to look for; archgram build warns of the same and still draws. Sources are looked up only under the project's folder: the nearest folder above the spec that holds .git, else the folder archgram runs in, and never a disk's root. A path that leads outside it is refused by its text alone, before anything on the disk is looked at. A symbolic link on the way is not followed. A file is read only to look for a source's words, once however many sources name it, and only a regular file of at most 4 MiB; nothing of it is printed or kept. A spec without sources looks nothing up (SECURITY.md, What archgram reads).

A writer that reads the code, a person or an agent such as the archgram skill, gives a source to every node a file backs (not to the people or systems outside the code, such as a browser) and to every edge: the file behind a node (the files, for a node that stands for several parts), and for an edge the file with a few words copied exactly from the line that makes it. From docs/diagrams/, the paths start with ../../.

In YAML, quote every source that holds a #: unquoted, # starts a comment, which drops the rest of the words, and : starts a key.

Validation#

A spec is rejected, with every problem listed at once and each located by its JSON pointer (or its line and column in YAML), when:

  • a required field is missing, a field is unknown, or a value is not one of those allowed;
  • shownWidth is not a number from 200 to 4,000;
  • a text (the title, the description, a label, a note or a flow's name) holds a character XML does not allow, such as a control character other than tab, line feed and carriage return: the SVG would not open;
  • an id is duplicated, or an edge, flow, frame or hint names an id that does not exist;
  • frames nest in a cycle, or a frame is empty;
  • the nodes of an order hint do not share a frame (a frame keeps its nodes together, so a hint cannot sort them among others);
  • a hint contradicts the edges: a sameLayer group whose nodes an edge joins, or a path of edges leads from one to another (through other groups too), a first node an edge leads into, or a last node an edge leads out of;
  • a flow step is not reached by an edge from the step before it, a node of a branching step has no edge to the step after it, a step lists no nodes, or a step lists a node twice;
  • a flow's stop names no node, or a node that is not its last step, or its last step is a branch;
  • tech names a logo archgram does not carry (the error suggests the nearest slugs);
  • a source is empty, absolute, only . and .., has a .. after a name, names .git or a file that commonly holds secrets (Sources), holds \, has fewer than 3 characters after its # or more than one line there, or is an empty list; or the spec names more than 1000 sources.

archgram check then holds each source to the code (Sources).

Theme file#

A project with its own design tokens can draw in its own colours: a mapping file, archgram.theme.json by convention, passed with --theme-file (and checked with archgram theme check), names the project's resolver and which token fills each of archgram's colour roles.

json
{
  "version": 1,
  "resolver": "design-system/tokens/design.resolver.json",
  "roles": {
    "canvas": "color.surface.page",
    "card": "color.surface.raised",
    "text": "color.text.primary",
    "icon-ai": "{color.accent.violet}"
  },
  "themes": {
    "light": { "inputs": { "theme": "light" } },
    "dark": { "inputs": { "theme": "dark" }, "roles": { "card-edge": "color.border.strong" } }
  }
}
FieldRequiredMeaning
versionyesThe mapping format's version. Only 1 exists
resolveryesThe project's resolver document (Design Tokens 2025.10), relative to this file, in its folder or below
rolesnoEach archgram role's token, by its dotted id, with or without braces
themes.light, themes.darkyesinputs: the resolver's input for each modifier, to pick that theme; roles: roles this theme maps differently

The roles are badge, canvas, card, card-edge, connector, frame, icon-ai, icon-build, icon-client, icon-core, signal-core, signal-pass, signal-refusal, text and text-muted (DESIGN.md, Colors). A role the file leaves out keeps the mono palette's colour. The file replaces the spec's palette.

archgram reads the resolver's sets, modifiers (an input, else the modifier's default), its resolution order (later sources win), $ref to files beside it and to its own sets, inline tokens, {alias} values and $ref JSON Pointers inside values, and $type from the groups above a token. A colour may be in srgb, srgb-linear, hsl, hwb, oklab, oklch or display-p3; in another space it needs a hex to fall back on. Channels outside sRGB are clipped. A role's colour is opaque.

An import is refused, with every problem listed and each located by file and JSON pointer, when the mapping or the resolver breaks its format; a role is not archgram's; an input names no modifier or no context; a modifier without a default gets no input; a file cannot be read, is a remote reference, or is outside the mapping file's folder (by its real path, so neither .. nor a symlink leads out); a token does not exist (the nearest are suggested), is not a colour, or aliases in a loop; a group uses $extends; a colour cannot be read; or a pair of roles falls below the contrast DESIGN.md requires, in either theme.

Examples#

linkshort (JSON)#

json
{
  "archgram": 1,
  "title": "linkshort",
  "description": "The API creates short links and redirects. A redirect reads the URL from the Redis cache, falls back to Postgres on a miss, adds the click to a Redis stream and returns. A worker drains the stream in batches and adds the counts to Postgres, where the stats route reads them.",
  "nodes": [
    { "id": "browser", "kind": "browser", "label": "Visitor" },
    { "id": "api", "kind": "service", "label": "API", "note": "FastAPI, port 8000", "tech": "fastapi" },
    { "id": "cache", "kind": "cache", "label": "URL cache", "note": "1-day TTL", "tech": "redis", "frame": "redis" },
    { "id": "clicks", "kind": "queue", "label": "Click stream", "tech": "redis", "frame": "redis" },
    { "id": "worker", "kind": "service", "label": "Worker", "note": "up to 500 per read" },
    { "id": "postgres", "kind": "database", "label": "links", "note": "source of truth", "tech": "postgresql" }
  ],
  "frames": [ { "id": "redis", "label": "Redis" } ],
  "edges": [
    { "from": "browser", "to": "api" },
    { "from": "api", "to": "cache" },
    { "from": "api", "to": "postgres", "label": "on a miss", "style": "dashed" },
    { "from": "api", "to": "clicks" },
    { "from": "clicks", "to": "worker" },
    { "from": "worker", "to": "postgres" }
  ],
  "flows": [ { "name": "a visit", "steps": ["browser", "api", "clicks", "worker", "postgres"] } ]
}

linkshort (YAML, the same spec)#

yaml
archgram: 1
title: linkshort
description: >-
  The API creates short links and redirects. A redirect reads the URL from
  the Redis cache, falls back to Postgres on a miss, adds the click to a
  Redis stream and returns. A worker drains the stream in batches and adds
  the counts to Postgres, where the stats route reads them.
nodes:
  - { id: browser, kind: browser, label: Visitor }
  - { id: api, kind: service, label: API, note: "FastAPI, port 8000", tech: fastapi }
  - { id: cache, kind: cache, label: URL cache, note: 1-day TTL, tech: redis, frame: redis }
  - { id: clicks, kind: queue, label: Click stream, tech: redis, frame: redis }
  - { id: worker, kind: service, label: Worker, note: up to 500 per read }
  - { id: postgres, kind: database, label: links, note: source of truth, tech: postgresql }
frames:
  - { id: redis, label: Redis }
edges:
  - { from: browser, to: api }
  - { from: api, to: cache }
  - { from: api, to: postgres, label: on a miss, style: dashed }
  - { from: api, to: clicks }
  - { from: clicks, to: worker }
  - { from: worker, to: postgres }
flows:
  - { name: a visit, steps: [browser, api, clicks, worker, postgres] }

An agentic RAG pipeline (frames, AI kinds, external variants)#

json
{
  "archgram": 1,
  "title": "How an answer comes to be",
  "description": "Before use, the index script has a model extract a profile from each CV, checks every field against the text and embeds each chunk. At question time the chat sends the question to the ask route; the model calls tools over the index and the vectors, ends with present, and the app checks every candidate and page before the answer streams back.",
  "direction": "right",
  "nodes": [
    { "id": "cvs", "kind": "file", "label": "CV PDFs", "note": "30 files", "frame": "prep" },
    { "id": "extract", "kind": "model", "label": "Gemini", "note": "extracts profile", "tech": "googlegemini", "variant": "external", "frame": "prep" },
    { "id": "fieldcheck", "kind": "check", "label": "Field check", "note": "page per fact", "frame": "prep" },
    { "id": "embed", "kind": "model", "label": "Embedding", "note": "each chunk", "variant": "external", "frame": "prep" },
    { "id": "index", "kind": "storage", "label": "data/index", "note": "JSON, in memory" },
    { "id": "vectors", "kind": "vector-store", "label": "Pinecone", "note": "chunk vectors", "variant": "external" },
    { "id": "chat", "kind": "browser", "label": "Chat screen", "frame": "ask" },
    { "id": "route", "kind": "service", "label": "POST /api/ask", "note": "no CV text in the prompt", "frame": "ask" },
    { "id": "llm", "kind": "model", "label": "Gemini", "note": "tool-calling loop", "tech": "googlegemini", "variant": "external", "frame": "ask" },
    { "id": "tools", "kind": "tool", "label": "Exact tools", "note": "find, count, get", "frame": "ask" },
    { "id": "search", "kind": "tool", "label": "search_cv_text", "note": "BM25 + vectors", "frame": "ask" },
    { "id": "answercheck", "kind": "check", "label": "Answer check", "note": "ids and pages", "frame": "ask" }
  ],
  "frames": [
    { "id": "prep", "label": "Before use" },
    { "id": "ask", "label": "At question time" }
  ],
  "edges": [
    { "from": "cvs", "to": "extract" },
    { "from": "extract", "to": "fieldcheck" },
    { "from": "fieldcheck", "to": "index" },
    { "from": "cvs", "to": "embed" },
    { "from": "embed", "to": "vectors" },
    { "from": "chat", "to": "route" },
    { "from": "route", "to": "llm" },
    { "from": "llm", "to": "tools" },
    { "from": "llm", "to": "search" },
    { "from": "tools", "to": "index" },
    { "from": "search", "to": "vectors" },
    { "from": "llm", "to": "answercheck", "label": "present" },
    { "from": "answercheck", "to": "chat", "label": "streams" }
  ],
  "flows": [
    { "name": "a question", "steps": ["chat", "route", "llm", "answercheck", "chat"] }
  ]
}