The .alab syntax
.alab is a readable, Mermaid-like text form of the same model .archlab.json stores — lossless in both directions, so you can edit whichever you prefer and nothing is dropped. Text → model → text is byte-identical, and so is JSON → text → JSON; pnpm check:archtext proves the round trip on every run. Every snippet on this page is checked against the real parser by pnpm check:syntax-docs.
A complete example
A whole small model first, so the shape is obvious before the details: header lines, then one @context diagram and one @container diagram, each with nodes and edges. Geometry is omitted throughout — omitted positions get deterministic grid defaults, so terse files stay lossless.
archlab 1.0
title "ShopFlow Platform"
@context ctx-root "ShopFlow Platform"
customer:person "Customer" #shopper
shop:system "ShopFlow Platform" @nextjs >cnt-shop
stripe:external "Stripe"
customer -> shop : "Places an order" [HTTPS]
shop <-> stripe : "Authorises payment" [HTTPS/JSON]
@container cnt-shop owner=shop
web:container "Web App" @nextjs
db:database "Orders DB" @postgresql
web -> db : "Reads and writes" [SQL/TCP]
Indentation & comments
The format is line-structured with significant indentation — spaces only, never tabs — and the parser accepts exactly three depths:
| Indent | What lives there |
|---|---|
0 | Header lines and @level diagram headers |
2 | Diagram body: desc, view, ! lines, node lines, edge lines |
4 | Node/edge continuations: desc and ! lines |
- Any other indentation (3 spaces, a tab, …) is a parse error naming the line and column. The VS Code extension pins spaces-only indentation for
.alabfiles, so this is one mistake you can stop making. - Blank lines are ignored.
//starts a full-line comment — at any indentation, even above thearchlabline. Trailing comments after content are a parse error (see Errors). Comments are the one thing a round trip does not preserve — they are text-only sugar, not model data.- All quoted strings are JSON string literals, so
\",\nand\uXXXXescapes work exactly as in the JSON file. Ids, tags and icon slugs may be bare ([A-Za-z0-9_][A-Za-z0-9_.-]*) or JSON-quoted when they contain anything else:"weird id":person "Name".
// Full-line comments start with // — at any indentation, even line 1.
archlab 1.0
title "Layout rules"
// Blank lines are ignored. Comments are text-only sugar: they are the one
// thing a round trip does not preserve.
@context ctx-root "Layout rules"
api:system "API"
desc "Indent 4: a continuation of the node line above."
Header lines
Header lines sit at indent 0, before the first @ diagram (a header line after a diagram is a parse error). Only archlab and title are required. Each line maps to one field of the JSON model:
| Line | Example | Maps to | Notes |
|---|---|---|---|
archlab <version> | archlab 1.0 | version | Required; must be the first content line of the file. |
schema "<url>" | schema "https://arch-lab.dev/schema/v1/diagram.schema.json" | $schema | Optional JSON-schema URL. |
title "<text>" | title "ShopFlow Platform" | metadata.title | Required — a file without a title is refused. Keep it to 120 characters: longer still parses, but the checkers raise a review note, since the title becomes the export filename too. |
description "<text>" | description "Customer-facing commerce platform." | metadata.description | Optional. |
owner "<text>" | owner "Platform Team" | metadata.owner | Optional. |
tags #a #b | tags #commerce #payments | metadata.tags | At least one tag; quote odd names: #"needs review". |
created <timestamp> | created 2026-07-01T00:00:00Z | metadata.createdAt | Defaults to the fixed sentinel 1970-01-01T00:00:00Z when omitted. |
updated <timestamp> | updated 2026-07-27T00:00:00Z | metadata.updatedAt | Same default sentinel as created. |
reviewed <timestamp> | reviewed 2026-07-20T00:00:00Z | metadata.lastReviewedAt | Optional. |
tagcolor <tag> "<colour>" | tagcolor payments "#e11d48" | metadata.tagColors | One line per tag; repeat for more tags. |
customicon <slug> "<name>" "<svg>" | customicon warehouse "Warehouse" "<svg viewBox=\"0 0 24 24\"/>" | metadata.customIcons | One line per icon; the SVG is a JSON string, so quotes escape. |
generator "<name>" "<version>" | generator "arch-lab" "0.1.0" | metadata.generator | Optional tool fingerprint. |
root <diagram-id> | root ctx-root | rootDiagramId | May be omitted when exactly one parentless @context diagram exists — it is then the root. |
archlab 1.0
schema "https://arch-lab.dev/schema/v1/diagram.schema.json"
title "ShopFlow Platform"
description "Customer-facing commerce platform."
owner "Platform Team"
tags #commerce #payments
created 2026-07-01T00:00:00Z
updated 2026-07-27T00:00:00Z
reviewed 2026-07-20T00:00:00Z
tagcolor payments "#e11d48"
customicon warehouse "Warehouse" "<svg viewBox=\"0 0 24 24\"/>"
generator "arch-lab" "0.1.0"
root ctx-root
@context ctx-root "ShopFlow Platform"
customer:person "Customer"
Diagrams
A diagram opens with an @level line at indent 0 — @context, @container, @component or @code — followed by the diagram id, an optional quoted title, and optional attributes:
| Part | Maps to | Notes |
|---|---|---|
@container cnt-shop "Title" | id, level, title | The title may be omitted — it is then inferred from the owner node's name. |
owner=<node-id> | ownerNodeId | The node this diagram details. |
in=<diagram-id> | parentDiagramId | May be omitted when it equals the diagram containing the owner node; in=nullforces a parent-less diagram that still has an owner. Each level must sit exactly one level below its parent's. |
desc "…" | description | A body line at indent 2. |
view <zoom> <x> <y> | viewport | A body line at indent 2; three numbers. |
frame <id> "Label" | frames[] | A body line at indent 2, before the nodes. Add in=<frame> to nest one frame in another. |
archlab 1.0
title "ShopFlow Platform"
@context ctx-root "ShopFlow Platform"
shop:system "ShopFlow Platform" >cnt-shop
@container cnt-shop "ShopFlow — Containers" owner=shop in=ctx-root
desc "Deployable units inside the platform boundary."
view 0.75 -120 64
web:container "Web App"
A frame is a labelled rectangle drawn behind a group of nodes — the C4 boundary. Declare it in the diagram body, then put nodes in it with in=<frame> on the node line. Frame ids are unique within their diagram, not across the file.
Frames carry no geometry: the rectangle is derived from its members' bounding box, so it follows them when they move. A frame with no members is not drawn.
archlab 1.0
title "ShopFlow Platform"
@context ctx-root "ShopFlow Platform"
shop:system "ShopFlow Platform" >cnt-shop
@container cnt-shop "ShopFlow — Containers" owner=shop
frame internal "Internal"
frame storage "Data Layer" in=internal
web:container "Web App" in=internal
orders:container "Orders Service" [Go 1.22] in=internal
orders-db:database "Orders DB" [PostgreSQL 16] in=storage
ext-pay:external "Payment Provider"
web -> orders : "Submits the order"
orders -> orders-db : "Reads and writes orders"
orders -> ext-pay : "Authorises payment"
Nodes
One line per node at indent 2: <id>:<type> "Name" — no space around the : — followed by attributes in any order. Each node type keyword is only legal at certain diagram levels; the parser checks this at parse time:
| Keyword | Model type | Legal at levels |
|---|---|---|
person | person | @context, @container |
system | softwareSystem | @context |
external | externalSystem | @context, @container, @component |
container | container | @container |
database | database | @container, @component |
queue | queue | @container, @component |
component | component | @component |
code | codeElement | @code |
| Attribute | Example | Maps to | Notes |
|---|---|---|---|
@slug | api:container "API" @golang | icon | Icon slug. No marker: the model carries no iconSource. |
@slug! / @slug~ | api:container "API" @golang! | icon + iconSource | "!" = explicit, "~" = inferred iconSource. |
[technology] | api:container "API" [Go 1.22] | technology | Free text up to "]". Quote when it contains one: ["odd ] tech"]. |
#tag / #"weird tag" | api:container "API" #critical-path #"needs review" | tags | Repeat for more tags. |
>diagram-id | api:container "API" >cmp-api | childDiagramId | Drill-down to a child diagram declared in the same file. |
>null | api:container "API" >null | childDiagramId: null | An explicit null in the model (distinct from the key being absent). |
>>"file" | billing:container "Billing" >>"./billing.archlab.json" | childRef | Reference to another model file; mutually exclusive with >child. |
^diagram/node | shop-ref:container ^ctx-root/shop | externalRef | Boundary placeholder for a node that lives in another diagram. The name is omitted here because it is derived from the referenced node; give one ("Shop (boundary)") only to override it locally. |
in=<frame> | frame internal "Internal"
api:container "API" in=internal | frameId | Puts the node inside a frame declared on the same diagram. Name the INNERMOST frame; nesting is recorded on the frame itself. |
pin / pin=false | api:container "API" pin | pinned | Bare pin means pinned: true. |
(x,y wxh) | api:container "API" (656,616 176x88) | position + size | Omit it and the node gets a deterministic grid position and per-type default size. |
desc "…" (indent 4) | api:container "API"
desc "Order lifecycle." | description | A continuation line under the node, indented four spaces. |
The geometry separator between width and height is the ASCII letter x, as in (656,616 176x88). A node line with everything on it:
archlab 1.0
title "Orders"
@context ctx-root "Orders"
shop:system "Shop" >cnt-shop
@container cnt-shop owner=shop
orders:container "Orders Service" @golang! [Go 1.22] #critical-path >cmp-orders pin (656,616 176x88)
desc "Order lifecycle: create, pay, cancel, fulfil."
@component cmp-orders owner=orders
api:component "HTTP API"
Edges
One line per relationship at indent 2: source <arrow> target, then attributes in any order. Both endpoints must be nodes of the same diagram — cross-diagram edges are a parse error. Solid arrows write no style key at all; dashed arrows write "style": "dashed"; the rare explicit "style": "solid" is spelled style=solid so absent-vs-solid survives the round trip.
| Arrow | Direction | Style | Example |
|---|---|---|---|
-> | forward | solid (no style key) | web -> db : "Writes" |
<-> | bidirectional | solid (no style key) | web <-> db : "Syncs" |
-- | none | solid (no style key) | web -- db : "Peers with" |
..> | forward | dashed | web ..> db : "Writes, async" |
<..> | bidirectional | dashed | web <..> db : "Syncs, async" |
.. | none | dashed | web .. db |
| Attribute | Example | Maps to | Notes |
|---|---|---|---|
: "label" | web -> db : "Reads and writes orders" | label | The relationship's label. |
[technology] | web -> db [SQL/TCP (pgx)] | technology | Same quoting rule as on nodes. |
#tag | web -> db #system-of-record | tags | Repeat for more tags. |
~edge-id | web -> db ~e-cust-shop | realizes | Traceability to the parent-level edge this one realizes. |
id=<edge-id> | web -> db id=e-orders-write | id | Omitted when the id is the conventional e-<source>-<target>. |
style=solid | web -> db style=solid | style: "solid" | Carries the rare explicit "style": "solid" — a plain solid arrow writes no style key at all. |
via (x,y) (x,y) | web -> db via (640,700) (600,760) | waypoints | One or more routing points. |
! key : json (indent 4) | web -> db : "Writes"
! x-edge-meta after label : true | unknown fields | Forward-compatible fields — see the ! lines section. |
An edge line with everything on it:
archlab 1.0
title "Orders"
@context ctx-root "Orders"
cust:person "Customer"
shop:system "Shop" >cnt-shop
cust -> shop : "Places orders" [HTTPS]
@container cnt-shop owner=shop
web:container "Web App"
db:database "Orders DB"
web -> db : "Reads and writes orders" [SQL/TCP (pgx)] #system-of-record ~e-cust-shop id=e-ord-db via (640,700) (600,760)
Unknown fields — ! lines
Any model field the grammar has no sugar for — unknown keys from newer minor versions, or known optional keys carrying an unexpected shape — is written as a ! escape line, valid at every scope: ! <path> [after <key>] : <json>. The JSON value and the key's position (via the after anchor) both survive the round trip byte-for-byte. Bare path segments match [A-Za-z0-9_-]+; anything else is JSON-quoted. A field that has dedicated syntax (like title) cannot be set with a ! line — the parser refuses it.
archlab 1.0
title "Forward compatible"
! meta.x-review after updatedAt : {"cycle":30}
! x-pipeline : {"stage":"prod"}
@context ctx-root "Forward compatible"
! x-diagram-flag : true
api:system "API"
! x-node-meta after name : [1,2]
Sequence diagrams
Everything above describes a C4 model, opened by archlab 1.0. .alab reads a second, separate document kind: a sequence diagram — participants and messages over time — opened by archlab 1.0 sequence and parsed by its own grammar. The two never mix. A sequence document has no @contextlevels, a C4 model has no messages, and handing one to the other's parser fails on line 1. The body sits under a single @sequence block: participants first, then the flow in order.
archlab 1.0 sequence
title "Checkout"
@sequence
autonumber
cust:actor "Customer" @person
web "Storefront" @nextjs [Next.js]
api:participant "Order API" @golang [Go]
cust -> web : "Clicks Place order"
web ->+ api : "POST /orders" [HTTPS]
api ..>- web : "201 Created"
The label is introduced by : — a -> b : "Label" — and a message without one does not parse. Three arrows carry the kind: -> a synchronous call, ~> asynchronous, ..> a reply. Activation rides the arrow rather than sitting on its own line: ->+opens the receiver's bar and ..>-closes the sender's, so a call and its return read web ->+ api … api ..>- web. A participant's kind is optional and only two exist — a bare web "Storefront" is a participant, cust:actor draws the stick figure. A message from a participant to itself draws a self-loop, and autonumber numbers every step.
archlab 1.0 sequence
title "Message kinds"
@sequence
web "Storefront"
api:participant "Order API"
queue:participant "Events" [Kafka]
web -> api : "Call login API" [HTTPS]
desc "POST /api/v1/basic/verify\nbody { email, password }\n200 → { token } (15 min)\n401 → bad credentials"
api -> api : "Validates the cart"
api ~> queue : "order.created" [Avro]
queue ..> api : "ack"
note right api : "Retries are idempotent"
note over api queue : "Both sides are at-least-once"
Keep the label short and put the rest in a desc. Indented two spaces under its message — the same continuation a node or participant takes — it carries the endpoint, the payload, the failure modes: whatever would turn the arrow into a paragraph. Only the label is ever drawn on the wire, so a desc costs no width and widens no column; the playground marks a message that has one with a small dot and shows the text when you click the message. Notes are the exception — a note already is its text, so it takes no desc.
A desc is a JSON string, so \n puts the detail on separate lines — and the viewer renders it as a monospace block that keeps them. That is what makes a request worth reading: the method and path, the body, then one line per status code, instead of all of it welded into a paragraph. The escape keeps the source one line per desc, so the file stays canonical.
Because it is a JSON string, a desc can hold a whole runnable request— quotes, curl's line-continuation backslashes and a JSON body included. Escape " as \" and \ as \\, and the dock gives it back exactly as written:
archlab 1.0 sequence
title "Order intake"
@sequence
web "Storefront" [Next.js]
api:participant "Order API" [Go]
web ->+ api : "Place the order" [HTTPS]
desc "curl https://api.shopflow.dev/v1/orders \\\n --request POST \\\n --header 'Content-Type: application/json' \\\n --header 'Authorization: Bearer $SHOPFLOW_TOKEN' \\\n --data '{\n \"cart_id\": \"cart_8f21c3\",\n \"address_id\": 4102,\n \"coupon\": null\n }'\n\n201 → { order_id } 409 → the cart changed under us"
api ..>- web : "201 Created"
Escaping that by hand is miserable, so don't: get your editor or an agent to JSON.stringify the command and paste the result after desc. The desc budget is 500 characters, which is a request and its responses — not a tutorial.
Fragments — alt/else, par/and, critical/option, opt, loop, break — nest by indentation, with no end keyword. This is the one place the format departs sharply from Mermaid: what belongs to a fragment is whatever is indented under it.
archlab 1.0 sequence
title "Branching"
@sequence
web "Storefront"
api:participant "Order API"
pay:participant "Payments"
alt "card accepted"
api ->+ pay : "Create charge" [REST]
pay ..>- api : "charge.succeeded"
par "receipt"
api ~> web : "Emails the receipt"
and "audit"
api -> api : "Writes audit row"
else "card declined"
api ..> web : "402 Payment Required"
opt "first purchase"
web -> web : "Shows onboarding tips"
A participant takes the same @icon a C4 node does, in the same place on the line — after the name, before [technology] — and from the same set of slugs. One vocabulary across both document kinds, because a participant and a container are usually the same system drawn twice, and two icon namespaces would let them disagree about what to call one. There is no !/~ suffix here: nothing infers icons for a sequence document, so there is no inference to override.
Two more constructs say these belong together without saying anything about control flow. box brackets a run of lifelines and takes its members as the participant lines nested inside it — nesting is what keeps a box a contiguous run, which is the only shape a bracket can honestly be drawn around. rect highlights a run of steps instead, and takes a colour where the other fragments take a guard. Both accept tint=, in #rrggbb, rgb(…) or a common colour name; whichever you write is stored as one canonical spelling, and drawn as a wash so it reads in both themes.
archlab 1.0 sequence
title "Grouped and highlighted"
@sequence
box "Front of house" tint=#bfdfff
cust:actor "Customer"
web "Storefront"
box "Payments" tint=#ffe4e1
pay:participant "Payments"
ledger:participant "Ledger"
cust -> web : "Places the order"
rect tint=#bfdfff
web -> pay : "Create charge" [REST]
pay -> ledger : "Post entry"
critical "Capture the funds"
pay ..> web : "charge.succeeded"
option "gateway timeout"
pay ..> web : "retry scheduled"
break "card declined"
pay ..> web : "402 Payment Required"
Render any of these in the sequence playground, which also imports pasted Mermaid sequenceDiagram code one-way. These snippets carry no Open in view mode button on purpose: that button encodes a snippet into a share link, and share links exist only for C4 models so far — a sequence one would hand the text to the C4 playground, which would reject it.
Errors
Parsing is all-or-nothing: a broken file throws one error and applies nothing. Every message reads line <n>, column <n>: <what is wrong> — in the two-pane editor the offending line is quoted with a caret at that column. These snippets are deliberately broken; the check script asserts each one fails with exactly the message shown:
A node type the format does not know
.alab archlab 1.0 title "Broken" @context ctx-root "Broken" api:blob "API"Error:
line 5, column 7: "blob" is not a node type — expected person, system, external, container, database, queue, component or codeA node type that is real, but illegal at this level
.alab archlab 1.0 title "Broken" @context ctx-root "Broken" db:database "Orders DB"Error:
line 5, column 6: "database" is not valid at level "context" — valid types here: person, system, externalIndentation that is not 0, 2 or 4 spaces
.alab archlab 1.0 title "Broken" @context ctx-root "Broken" api:person "API"Error:
line 5, column 4: inconsistent indentation of 3 spaces — expected 0 (header or "@" diagram), 2 (diagram body) or 4 (node/edge continuation)An edge whose endpoint is not a node in this diagram
.alab archlab 1.0 title "Broken" @context ctx-root "Broken" cust:person "Customer" cust -> ghost : "Uses"Error:
line 7, column 11: the target "ghost" does not resolve to a node in this diagramA trailing comment — comments must be full lines
.alab archlab 1.0 title "Broken" // not allowed hereError:
line 2, column 16: unexpected text after the "title" lineA string that is never closed
.alab archlab 1.0 title "Broken" @context ctx-root "BrokenError:
line 4, column 19: the string for the diagram title opened here is never closed — expected a closing '"'A file without a title
.alab archlab 1.0 @context ctx-root "Untitled"Error:
line 1, column 1: the file has no title — add a line like: title "My System"
Editor support
Out of the box an editor sees .alab as an unknown extension and renders it as plain text — unhelpful for a format with significant indentation. The repo ships a VS Code extension in editors/vscode that highlights every construct on this page and, more usefully, makes the indentation rules impossible to break: spaces only, two at a time, with indent guides on and any tab shown as an error before you save.
It is not on the Marketplace yet. Symlink it into your extensions folder from a clone of the repo and reload the window:
git clone https://github.com/raksitnongbua/arch-lab.git
ln -s "$PWD/arch-lab/editors/vscode" ~/.vscode/extensions/alab-syntaxPrefer a package? Run npx @vscode/vsce package --out alab-syntax.vsix inside editors/vscode, then code --install-extension alab-syntax.vsix.
One workaround to avoid: associating .alab with YAML ("files.associations": { "*.alab": "yaml" }) is worse than plain text. YAML treats # as a comment, so tags like #critical-path grey out as comments while real // comments colour as content — wrong in both directions.
No extension for your editor yet? The grammar is a portable TextMate grammar (editors/vscode/syntaxes/alab.tmLanguage.json), which Neovim, Zed and Sublime can all consume.
Where to use it
The fastest way to learn the format is to write it live: view mode is a two-pane editor with .alab on one side and .archlab.jsonon the other — each pane regenerates the other as you type, and parse errors appear inline with the caret format above. The "Open in view mode" buttons on this page's complete examples carry the snippet there inside the link itself (nothing is uploaded); they only appear while the encoded link stays under the share codec's honest ~2000-character limit. For finished, read-only models, see the live demo.