Skip to content
arch-lab
Reference · the arch-lab text format

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.

.alab
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:

Indentation depths
IndentWhat lives there
0Header lines and @level diagram headers
2Diagram body: desc, view, ! lines, node lines, edge lines
4Node/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 .alab files, so this is one mistake you can stop making.
  • Blank lines are ignored.
  • // starts a full-line comment — at any indentation, even above the archlab line. 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 \", \n and \uXXXX escapes 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".
.alab
// 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."

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:

Diagram header parts
PartMaps toNotes
@container cnt-shop "Title"id, level, titleThe title may be omitted — it is then inferred from the owner node's name.
owner=<node-id>ownerNodeIdThe node this diagram details.
in=<diagram-id>parentDiagramIdMay 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 "…"descriptionA body line at indent 2.
view <zoom> <x> <y>viewportA 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.
.alab
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.

.alab
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:

Node type keywords
KeywordModel typeLegal at levels
personperson@context, @container
systemsoftwareSystem@context
externalexternalSystem@context, @container, @component
containercontainer@container
databasedatabase@container, @component
queuequeue@container, @component
componentcomponent@component
codecodeElement@code
Node attributes
AttributeExampleMaps toNotes
@slugapi:container "API" @golangiconIcon 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]technologyFree text up to "]". Quote when it contains one: ["odd ] tech"].
#tag / #"weird tag"api:container "API" #critical-path #"needs review"tagsRepeat for more tags.
>diagram-idapi:container "API" >cmp-apichildDiagramIdDrill-down to a child diagram declared in the same file.
>nullapi:container "API" >nullchildDiagramId: nullAn explicit null in the model (distinct from the key being absent).
>>"file"billing:container "Billing" >>"./billing.archlab.json"childRefReference to another model file; mutually exclusive with >child.
^diagram/nodeshop-ref:container ^ctx-root/shopexternalRefBoundary 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=internalframeIdPuts the node inside a frame declared on the same diagram. Name the INNERMOST frame; nesting is recorded on the frame itself.
pin / pin=falseapi:container "API" pinpinnedBare pin means pinned: true.
(x,y wxh)api:container "API" (656,616 176x88)position + sizeOmit it and the node gets a deterministic grid position and per-type default size.
desc "…" (indent 4)api:container "API" desc "Order lifecycle."descriptionA 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:

.alab
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 forms
ArrowDirectionStyleExample
->forwardsolid (no style key)web -> db : "Writes"
<->bidirectionalsolid (no style key)web <-> db : "Syncs"
--nonesolid (no style key)web -- db : "Peers with"
..>forwarddashedweb ..> db : "Writes, async"
<..>bidirectionaldashedweb <..> db : "Syncs, async"
..nonedashedweb .. db
Edge attributes
AttributeExampleMaps toNotes
: "label"web -> db : "Reads and writes orders"labelThe relationship's label.
[technology]web -> db [SQL/TCP (pgx)]technologySame quoting rule as on nodes.
#tagweb -> db #system-of-recordtagsRepeat for more tags.
~edge-idweb -> db ~e-cust-shoprealizesTraceability to the parent-level edge this one realizes.
id=<edge-id>web -> db id=e-orders-writeidOmitted when the id is the conventional e-<source>-<target>.
style=solidweb -> db style=solidstyle: "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)waypointsOne or more routing points.
! key : json (indent 4)web -> db : "Writes" ! x-edge-meta after label : trueunknown fieldsForward-compatible fields — see the ! lines section.

An edge line with everything on it:

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

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

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

.alab
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:

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

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

.alab
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 code

  • A 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, external

  • Indentation 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 diagram

  • A trailing comment — comments must be full lines

    .alab
    archlab 1.0
    title "Broken" // not allowed here
    

    Error: line 2, column 16: unexpected text after the "title" line

  • A string that is never closed

    .alab
    archlab 1.0
    title "Broken"
    
    @context ctx-root "Broken
    

    Error: 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:

sh
git clone https://github.com/raksitnongbua/arch-lab.git
ln -s "$PWD/arch-lab/editors/vscode" ~/.vscode/extensions/alab-syntax

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