Importing from code and data
import_source turns a file you already have into a finished diagram in one call: a database schema, an API specification, infrastructure code, a container setup, a CI workflow, a table of numbers, a folder listing, or a git log. It reads the text you send and nothing else, so it runs no code, opens no file, and fetches nothing. It is for anyone, human or AI agent, who would rather draw the system from its source than from memory.
TL;DR
| Question | Answer |
|---|---|
| What can it read? | sql, prisma, drizzle, openapi, terraform, terraform-plan, bicep, arm, pulumi, cloudformation, cdk, compose, kubernetes, helm, kustomize, backstage, structurizr, asyncapi, github-actions, gitlab-ci, azure-pipelines, csv, json, tree, and git-log. An agent reads what each one takes with get_diagram_syntax and topic import-formats. |
| What does it draw? | A schema from SQL, Prisma, or Drizzle; an endpoint access matrix, a map of its endpoints by tag, or one operation as a sequence from OpenAPI; a flow from Terraform files, a Terraform plan or state, Bicep, an ARM template, Pulumi, CloudFormation, the AWS CDK, Compose, Kubernetes, Helm, Kustomize, a Backstage catalog, AsyncAPI documents, or a GitHub Actions, GitLab CI/CD, or Azure Pipelines pipeline; a C4 diagram from a Structurizr workspace; a chart from CSV or JSON; a tree from a listing; a git graph or a timeline from a log. |
| How do I call it? | With format and text, or files for a folder such as a Terraform stack with its modules and environments, and optionally title and options. Over HTTP it is POST /v1/import; on the command line, npx @buun_group/plotwork import-source <format> <file or folder>. |
| What comes back? | The Plotwork source, a diagnostic for everything left out with its line and column in your file, coverage (how much of the file the picture shows), the validation, an outline, and a viewUrl to open in a browser. A file too big for one picture also comes back in parts. |
| Does it look finished? | Yes. Boxes get types, product icons, and typed frames; arrows show their relationship with an icon and a line colour; every import has a title and a description; and every sample on this page draws with no warning. |
| What if the file is too big? | The hosted service reads up to 10 MiB in one call; the local server and the command line read far more. A file too big for one readable picture is split into several diagrams in parts, never cut; an answer holds 24 of them, and partsPage asks for the next. |
| What if it cannot read the file? | Nothing is imported: ok is false, and an I1xx diagnostic says where and why, with a hint. It never throws. |
| Is my file stored? | No. The text is worked on in memory and not kept. |
Ask your agent
Use Plotwork to draw our database from db/schema.sql. Import it with import_source, then fix anything the diagnostics say was left out.Use Plotwork to show which roles can call which endpoints in openapi.yaml, as a matrix. Do not guess a cell the spec does not state.Use Plotwork to draw our local stack from docker-compose.yml and our CI from .github/workflows/ci.yml, and put both pictures in docs/architecture.md.Use Plotwork to draw production from infra/terraform: send every .tf and .tfvars file under it with import_source, with options.root set to envs/prod.How an import works
The agent reads the file and sends its text with the format. The importer reads it, writes Plotwork source, and parses it; the tool then validates the source and outlines it, once each.
| Step | What happens |
|---|---|
| Read | One reader per format. It builds the boxes, tables, rows, entries, or commits, and reports what it skips or cannot resolve, with the line and column in your file. |
| Write | Labels are quoted and escaped, ids are made valid, and the caps are applied. Icons come from the bundled set only: a lookup table for each format, and the same rule lint uses to suggest an icon. |
| Check | The source is parsed, validated, and outlined. An import that would not parse returns nothing rather than broken source. |
| Count | coverage says how much of the file the picture shows, such as Drawn 79 of 79 resources. When something is left out, complete is false, the next sentence says so first, and I206 is reported when much is missing, so a picture of part of a file is never taken for the whole. |
A folder is sent as files, a list of path and text. A format that spans files (Terraform, Bicep, ARM, Pulumi, CloudFormation, the CDK, SQL, Prisma, Drizzle, Kubernetes, Helm, Kustomize, Backstage, AsyncAPI) reads every file with its endings as one text and passes over the rest with I201; a diagnostic names its file and its line in that file. Other formats read the first file.
Then call draw_diagram on the source to draw it, edit it first, or place it in a document with sync_markdown.
Arrows and frames
An imported arrow names its relationship twice: in its label, and with an icon in the label and a line colour. The same relationship looks the same whichever format it came from.
| Relationship | Label examples | Icon | Line |
|---|---|---|---|
| Reads or writes stored data | data, storage, cache, mounts | database, hard drive | Data (blue) |
| Reads its settings | reads (a ConfigMap) | eye | Plain |
| Sends or receives a message it does not wait for | messages, dead letters, an AsyncAPI message | send, inbox | Async (dotted) |
| Grants access, or uses a credential | a role, identity, policy, secret, reads (a Secret) | key | Identity (violet) |
| Reaches another over a private network | private link, VNet integration | lock | Private network (aqua) |
| Depends on, provides, or consumes | depends on, provides, consumes, a chart version | link, plug | Plain |
| Routes outside traffic | an Ingress host or path, public IP | globe | Plain |
| Starts another | triggers | lightning | Plain |
| Other relationships | firewall, logs, telemetry, images, builds on, includes, patches | shield, activity, download, layers, package, replace | Plain |
A line colour is given only where it tells lines apart: when every line of a picture would have the same one, as in an AsyncAPI document where every line is a message, the lines stay plain and the icons say the rest. A legend names each colour used. An arrow with no label, such as a Compose depends_on, has no icon. Structurizr relationships keep their own description and technology, since a C4 line takes no icon.
Every frame has a type, so it has its colour and icon: a Terraform resource drawn round its parts by what it is (a SQL server is data, a Service Bus namespace messaging, a vault security), modules, CDK constructs, and Pulumi components as resource groups, Kubernetes namespaces, Helm releases and charts, Kustomize overlays as environments, GitHub Actions jobs and pipeline stages as CI and CD, and Backstage domains, systems, and owner groups. A Structurizr boundary gets an icon and a tone.
Database schemas
sql reads CREATE TABLE and ALTER TABLE ... ADD from Postgres, MySQL, and SQLite: primary keys, foreign keys inline or at the table level, UNIQUE, and NOT NULL. A column is nullable unless it is NOT NULL or part of the primary key. A migration history can be sent whole: renames, dropped tables and columns, and tables created again are applied in order, so the picture is the schema the last migration leaves. A schema of more than six tables comes back with I204 and ready groups for options.tables (names, or prefixes such as org_*), to draw it in parts that stay readable. A schema of more than 20 tables is drawn whole in parts: an overview of the parts and the foreign keys between them, then the tables of each schema name, cut by the tables that share keys when one is too big. prisma reads schema.prisma models, with each relation labelled by its field. drizzle reads pgTable, sqliteTable, and mysqlTable calls as text; the code is never run.
schema "Database schema" desc="5 tables and the 4 foreign keys between them, imported from SQL."
table customers { id uuid pk email "varchar(255)" unique full_name text created_at timestamptz}
table products { id bigserial pk sku text unique name text price_cents integer tags "text[]" nullable}
table orders { id bigserial pk customer_id uuid fk status text placed_at timestamptz nullable}
table order_items { order_id bigint pk fk product_id bigint pk fk quantity integer unit_price_cents integer}
table payments { id bigserial pk order_id bigint fk provider_ref text nullable amount_cents integer}
orders.customer_id -> customers.idorder_items.order_id -> orders.idorder_items.product_id -> products.idpayments.order_id -> orders.idTables:
- customers
- id uuid (primary key)
- email varchar(255) (unique)
- full_name text
- created_at timestamptz
- products
- id bigserial (primary key)
- sku text (unique)
- name text
- price_cents integer
- tags text[] (nullable)
- orders
- id bigserial (primary key)
- customer_id uuid (foreign key)
- status text
- placed_at timestamptz (nullable)
- order_items
- order_id bigint (primary key, foreign key)
- product_id bigint (primary key, foreign key)
- quantity integer
- unit_price_cents integer
- payments
- id bigserial (primary key)
- order_id bigint (foreign key)
- provider_ref text (nullable)
- amount_cents integer
Relations:
- orders.customer_id references customers.id (many to one)
- order_items.order_id references orders.id (one to one)
- order_items.product_id references products.id (one to one)
- payments.order_id references orders.id (many to one)
API specifications
openapi reads an OpenAPI 3 document in JSON or YAML. The default view, endpoints, is a matrix of each operation against the security schemes, OAuth scopes, and x-roles values the document names. A cell is yes when the operation's security grants it, no when its security is stated and does not, and na when the document does not say: the importer never guesses. A No sign-in column appears when an operation is public through security: []. With options.view set to sequence and options.operationId set to an operation, it draws that one call: the client, the identity provider when OAuth secures it, the API, and each response as a branch.
matrix "Orders API access" desc="Who may call each of the 6 endpoints of the Orders API, by security scheme, scope, and role, imported from its OpenAPI document."
columns "No sign-in" "orders:read" "partnerKey" "orders:write" "customer" "support"
row "GET /health" sub="Check the service is up" : yes, yes, yes, yes, yes, yesrow "GET /orders" sub="List orders" : no, yes, yes, no, yes, yesrow "POST /orders" sub="Place an order" : no, no, no, yes, yes, norow "GET /orders/{orderId}" sub="Read one order" : no, yes, no, no, yes, yesrow "DELETE /orders/{orderId}" sub="Cancel an order before it ships" : no, no, no, yes, yes, yesrow "POST /orders/{orderId}/refunds" sub="Refund an order" : no, no, no, yes, no, yes
legend yes="Allowed" no="Denied"Columns: No sign-in, orders:read, partnerKey, orders:write, customer, support.
- GET /health (Check the service is up): No sign-in: Allowed; orders:read: Allowed; partnerKey: Allowed; orders:write: Allowed; customer: Allowed; support: Allowed.
- GET /orders (List orders): No sign-in: Denied; orders:read: Allowed; partnerKey: Allowed; orders:write: Denied; customer: Allowed; support: Allowed.
- POST /orders (Place an order): No sign-in: Denied; orders:read: Denied; partnerKey: Denied; orders:write: Allowed; customer: Allowed; support: Denied.
- GET /orders/{orderId} (Read one order): No sign-in: Denied; orders:read: Allowed; partnerKey: Denied; orders:write: Denied; customer: Allowed; support: Allowed.
- DELETE /orders/{orderId} (Cancel an order before it ships): No sign-in: Denied; orders:read: Denied; partnerKey: Denied; orders:write: Allowed; customer: Allowed; support: Allowed.
- POST /orders/{orderId}/refunds (Refund an order): No sign-in: Denied; orders:read: Denied; partnerKey: Denied; orders:write: Allowed; customer: Denied; support: Allowed.
With options.view set to map, the endpoints are drawn as a tree: a folder for each tag, in the order the document's tags list gives them, and a row for each endpoint with its summary. The path every endpoint of a tag shares is named once on its folder, so long paths stay readable. An endpoint with several tags is drawn once, under its first. A document whose operations state no security and no x-roles has nothing for the columns of a matrix, so the default view draws the map instead and says so with I203.
A big API is never cut short. A matrix of more than 20 endpoints, or a map of more than 60 rows, is split by tag into several diagrams, returned in parts with I204; whole tags stay together, and a tag too big for one diagram is cut into even pieces titled (1 of 2). options.tags draws only the operations with one of the tags, and options.paths only those under one of the path prefixes, such as /orders; what they leave out is counted in coverage and named with I201.
Only what the pictures need is read: paths, operations, tags, security schemes and requirements, and x-roles. In YAML the bodies of components.schemas and components.examples are skipped before parsing, and a $ref is followed only for the one operation a sequence draws, so a document of several megabytes imports in well under a second on the local server.
Infrastructure as code
terraform reads the .tf, .tf.json, and .tfvars files of a stack, best sent whole in files, with its modules and its environments. Every resource and module block is drawn, nothing is summed into a count, and coverage says Drawn N of N resources:
| What the stack says | What is drawn |
|---|---|
A resource names its resource group, network, subnet, plan, or environment (resource_group_name, subnet_id, service_plan_id) | It sits in that frame, the most specific one: a plan or an environment, then a subnet, a network, a resource group, and the account of the provider block. A frame named by a variable, such as subnet_id = var.endpoint_subnet_id, is drawn with the variable's name. |
A resource names the one that holds it (key_vault_id, server_id, storage_account_name, an API in its API Management) | It sits in a frame around its holder, so a vault holds its secrets and a server its databases. |
| A role assignment or an IAM member | An arrow from the principal to the scope, labelled with the role. Several roles between the same two are one arrow, 3 roles. A role given to principals the stack does not declare is a box named by the role, inside its scope. |
| A private endpoint | A box in its subnet, with a private link arrow to what it reaches. |
| A diagnostic setting, a DNS link, a listener, an association | An arrow labelled diagnostics, DNS link, the protocol and port, or drawn by nesting, such as a security group in the subnet it secures. |
| Any other reference | An arrow labelled by its attribute (identity, storage, logs) or by what it reaches (secret, data, messages). |
Types of AWS, Azure, and Google Cloud get their product icons from a table, and any other type of those providers is read from its name, so a new resource type still gets a fitting icon. A resource of another provider, such as random_password, is a plain box. Send the .tfvars files too, and names are drawn as they become: name = "kv-${local.base}" is drawn kv-shop-dev, and a subnet named by var.endpoint_subnet_id is drawn with the name at the end of its id. Variable defaults, terraform.tfvars, and every *.auto.tfvars apply as Terraform applies them; an environment's file, such as env/dev.tfvars, applies when it is the only one sent, or when options.varFiles names it (--var-file on the command line). A variable marked sensitive is never drawn. A variable that is named but never declared frames nothing: the resource sits in the account, with I202 on it. With options.groupBy set to file, resources are framed by the file that declares them instead. When three or more boxes of one frame have the same arrow to one box, such as five jobs pulling images from one registry, they are drawn as one arrow from the frame's own box, images (5). A stack of more than 30 boxes comes back in parts: first an overview with one box for each resource group and one counted arrow for everything between two groups, then one diagram for each group with everything in it. An arrow to a box of another part ends on a dashed box that names the part.
flow "Azure infrastructure" direction=right provider=azure desc="12 resources on Azure, nested in the groups, networks, and subnets that hold them, imported from Terraform. An arrow means one resource refers to another, and its label says how: a role, a private link, logs, or a secret."
group azure_account "Azure subscription" type=account {
group app "orders-prod" type=resource-group sub="westeurope" { node main_4 storage "ordersfuncstore" sub="Storage account Standard" icon=azure:storage-accounts node main_5 secret "orders-kv" sub="Key Vault standard" icon=azure:key-vault
group main "orders-vnet" type=network sub="10.20.0.0/16" {
group apps "apps" type=subnet sub="10.20.1.0/24" { node apps_subnet service "Subnet" icon=azure:subnet } }
group main_2 "orders-plan" type=service-plan sub="P1v3 Linux" { node api service "orders-api" sub="App Service" icon=azure:app-service node worker function "orders-worker" sub="Function app" icon=azure:functions }
group main_group "orders-sql" icon=azure:sql-server { node main_3 database "SQL server" icon=azure:sql-server node orders database "orders" sub="SQL database S1" icon=azure:sql-database }
group main_group_2 "orders-bus" icon=azure:service-bus sub="Service Bus namespace, Standard" { node orders_2 queue "orders" sub="Service Bus queue" icon=azure:service-bus } }}
api -> apps_subnet : "VNet integration"api -> main_3 : "data"api -> orders : "data"api -> orders_2 : "messages"api -> main_5 : "secret"worker -> main_4 : "storage"worker -> orders_2 : "messages"Nodes:
- Account: Azure subscription
- Resource group: orders-prod (westeurope)
- ordersfuncstore (Storage account Standard), storage
- orders-kv (Key Vault standard), secret
- Virtual network: orders-vnet (10.20.0.0/16)
- Subnet: apps (10.20.1.0/24)
- Subnet, service
- Subnet: apps (10.20.1.0/24)
- Service plan: orders-plan (P1v3 Linux)
- orders-api (App Service), service
- orders-worker (Function app), function
- Group: orders-sql
- SQL server, database
- orders (SQL database S1), database
- Group: orders-bus (Service Bus namespace, Standard)
- orders (Service Bus queue), queue
- Resource group: orders-prod (westeurope)
Connections:
- orders-api to Subnet: VNet integration
- orders-api to SQL server: data
- orders-api to orders: data
- orders-api to orders: messages
- orders-api to orders-kv: secret
- orders-worker to ordersfuncstore: storage
- orders-worker to orders: messages
Terraform in folders
A stack laid out as enterprises keep one, modules/network, modules/data, and envs/dev and envs/prod that call them, is read as Terraform plans it:
| What the folder holds | What is drawn |
|---|---|
module "network" { source = "../../modules/network" }, with the module's files sent | A frame of the module around its resources, named as Terraform names them, module.network.azurerm_subnet.this["app"]. The module's variables take what the call passes, and module.network.subnet_ids["app"] is followed through the output to the subnet. A module from a registry or git, or one whose files were not sent, is one box. |
Several root modules, such as envs/dev and envs/prod | One diagram for each, in parts, each with the terraform.tfvars of its own folder. options.root (--root on the command line) draws one: envs/prod, or prod. |
for_each or count whose keys are in the text: a map or set literal, a variable's value, a local, or a number | One box for each copy, aws_subnet.private["a"], named with each.key and count.index. A reference with an index points at that copy; one without points at every copy. Keys from outside the text, or more than 12, are one box marked for each. A block that makes no copy, such as count = var.enabled ? 1 : 0 with enabled false, is left out with I201. |
format(), join(), lookup(), merge(), replace(), and conditionals in names and tags | Resolved when their inputs are in the text: format("kv-%s-%s", local.org, var.env) is kv-acme-prod. A part that is not is kept by name, kv-{prefix}-shared. |
depends_on | A dashed depends on arrow, only where nothing else joins the two. |
Provider blocks with alias in different regions | A region frame for each inside the account. |
options.groupBy set to network nests by network alone, without module frames. A frame of the network module sits in the resource group its resources name, and a database of the data module placed in a subnet of the network module points at that subnet with a subnet arrow.
Terraform plans and state
terraform-plan reads what Terraform itself writes, so values that come from outside the .tf files, such as a registry module's resources or a name a data source returns, are drawn as Terraform resolved them:
| What you send | What is drawn |
|---|---|
The output of terraform show -json plan.out, after terraform plan -out=plan.out | The stack as the plan leaves it. Each box the plan changes ends its second line with a word, in a tone: create green, update amber, replace orange, destroy red, import and move aqua, forget violet. A box that is created, updated, replaced, or destroyed also carries the change= mark, a badge with +, ~, ±, or − and an outline in its tone, so the change shows on a box with a vendor icon too. The title counts the changes as Terraform does, AWS infrastructure: 3 to add, 4 to change, 3 to destroy. A resource the plan destroys is drawn from the plan's prior state. A resource changed outside Terraform since the last apply says drifted. |
The output of terraform show -json, or a terraform.tfstate file | The stack as it was last applied, with no tone. |
Every resource keeps the address the terraform reader gives it, such as module.network.aws_subnet.private["eu-west-1a"], so the overrides of a diagram made from the .tf files carry over to one made from a plan. A plan's configuration gives the arrows, followed through module outputs and the variables a call passes; when a reference names every copy of a block, the known value chooses the copy, so each machine sits in its own subnet. A state has no configuration: a value that holds another resource's id is the arrow, and a dependency no value shows is a dashed depends on arrow, left out when another arrow already leads there. The plan's JSON does not expand local values, so an arrow made through a local is left out with I202. A sensitive value or variable is never drawn.
Bicep and ARM templates
bicep reads a deployment: main.bicep, the module files it uses (sent in files, at their paths), and its .bicepparam values. Every resource is drawn as the Terraform resource that makes the same thing, so Microsoft.KeyVault/vaults is the same Key Vault box, with the same frames and arrow labels, as azurerm_key_vault. A module whose file is sent is a frame in the resource group its scope: names; resources nested in a parent, or with parent:, sit in it; subnets written inline in a network are frames; existing resources say so. Names are drawn as they become, with format('{0}-{1}-api', prefix, env) and '${prefix}-web' resolved and uniqueString() kept by name. A loop over a known list is one box for each item, and a condition the values make false leaves the resource out with I201. A .bicepparam file applies when it is the only one sent, or when options.varFiles names it.
arm reads an ARM template, as az bicep build or the portal writes it, with its parameters file, and draws it as the Bicep reader does: parameters(), variables(), copy loops, condition, dependsOn, and child resources named parent/child.
flow "Invoicing on Azure" direction=down provider=azure desc="8 resources on Azure, nested in the groups, networks, and subnets that hold them, imported from Bicep. An arrow means one resource refers to another, and its label says how: a role, a private link, logs, or a secret."
group azure_account "Azure subscription" type=account {
group resource_group "Resource group" type=resource-group { node workspace service "log-ledger-prod" sub="Log Analytics workspace" icon=azure:log-analytics-workspaces node insights service "appi-ledger-prod" sub="Application Insights" icon=azure:application-insights node vault secret "kv-ledger-prod" sub="Key Vault" icon=azure:key-vault
group storage_group "stledgerprod{uniqueString}" icon=azure:storage-accounts { node storage storage "Storage account" icon=azure:storage-accounts node invoices queue "invoices" sub="Storage queue" icon=azure:storage-queue }
group plan "asp-ledger-prod" type=service-plan sub="Y1" { node job function "func-ledger-prod-invoices" sub="Function app" icon=azure:functions } }}
insights -> workspace : "logs"job -> storage : "storage"job -> insights : "telemetry"job -> vault : "Key Vault Secrets User, secret"invoices -> storage : "part of"Nodes:
- Account: Azure subscription
- Resource group: Resource group
- log-ledger-prod (Log Analytics workspace), service
- appi-ledger-prod (Application Insights), service
- kv-ledger-prod (Key Vault), secret
- Group: stledgerprod{uniqueString}
- Storage account, storage
- invoices (Storage queue), queue
- Service plan: asp-ledger-prod (Y1)
- func-ledger-prod-invoices (Function app), function
- Resource group: Resource group
Connections:
- appi-ledger-prod to log-ledger-prod: logs
- func-ledger-prod-invoices to Storage account: storage
- func-ledger-prod-invoices to appi-ledger-prod: telemetry
- func-ledger-prod-invoices to kv-ledger-prod: Key Vault Secrets User, secret
- invoices to Storage account: part of
Pulumi
pulumi reads three things, and draws each resource as the Terraform resource that makes the same thing, so AWS, Azure, and Google Cloud look the same whichever tool wrote them:
| What you send | What is read |
|---|---|
Pulumi.yaml of a YAML program, with its Pulumi.<stack>.yaml | Resources by type token, ${resource.property} references, variables, configuration, fn::join, fn::select, and fn::split, and options.dependsOn and options.parent. |
The output of pulumi stack export | Every resource of a program in TypeScript, Python, Go, C#, or Java, without reading the code: its parent, its dependencies by property, and its components as frames. |
The output of pulumi preview --json | The stack as the update will leave it; a step that deletes is counted with I201, not drawn. |
A resource is named by its logical name, or by its URN when two share one. A secret is never drawn. With no component to frame it, the network is a layer of its own: an app or a database placed in a subnet sits beside the network with a subnet arrow, as a network module draws it in Terraform and Bicep.
CloudFormation and SAM
cloudformation reads a template in YAML, with its short tags, or JSON. !Ref, !GetAtt, and !Sub are arrows named by the property that holds them, names are drawn as !Sub, !Join, and parameter defaults make them, a Condition the defaults make false leaves the resource out with I201, and DependsOn is a dashed arrow. A SAM function's API event is an arrow from the API labelled with its route, POST /orders, and a queue or stream event a triggers arrow from its source. A resource is named by its logical id.
AWS CDK
cdk reads the cloud assembly cdk synth writes: send the cdk.out folder (manifest.json, tree.json, each stack's template, and its nested stacks' templates), or one template cdk synth prints. Each resource is read as cloudformation reads it, so a bucket made with the CDK is the same box as one written in CloudFormation or Terraform.
| What | How it is drawn |
|---|---|
| Stacks | Frames, when the app has several, with arrows across them through Fn::ImportValue. options.root draws one stack alone, titled by its name. |
| Nested stacks | Frames inside their parent, with the parameters it passes and the outputs it reads followed both ways. One whose template was not sent is a box, with I202. |
| Your own constructs | Frames, by construct path. A construct that wraps a resource of its own, such as a Function or a Vpc, is that resource's box, and what it makes besides (its role and policy, its subnets) sits beside it in the same frame. |
| Names | Construct paths: OrdersApi/Ordering/Handler for a box, OrdersApi/Ordering for a frame. |
AWS::CDK::Metadata and the asset manifests are passed over, and a stack the manifest names whose template was not sent is I201.
Containers
compose reads a Docker Compose file. Each service is a box whose type and brand mark come from its image, so postgres:16 is a database with the Postgres mark. An arrow goes from a service to each service it depends on, through depends_on, links, or an address in its environment such as postgres://app@db:5432, and a service that shares another's network through network_mode: "service:db" sits in its frame with a shares network arrow. Every network the file declares becomes a frame, and named volumes become storage boxes. Variables are read as Compose reads them with nothing set: ${PORT:-8080} and ${PORT-8080} read as their default, ${FLAG:+on} as nothing, and a variable with no default (${TAG}, $TAG) stays as $TAG, so the box shows the value is set elsewhere. kubernetes reads multi-document manifests the same way: workloads, Ingresses, ConfigMaps, Secrets, and claims become boxes, namespaces become frames, and routes, selectors, addresses, and mounts become arrows.
helm reads what helm template writes and draws it as kubernetes would, framed by the labels Helm adds: the release (app.kubernetes.io/instance), and, in a release of several charts such as an umbrella chart and its subcharts, the chart (helm.sh/chart); app.kubernetes.io/part-of frames the releases of one application. Sent a chart folder instead, it draws the charts (Chart.yaml, and those in charts/) and what each depends on, each arrow labelled with the version Chart.lock resolved, and a dependency values.yaml switches off dashed. The templates themselves are not rendered, and I201 says so.
kustomize reads what kustomize build writes and draws it as kubernetes would, with the hash a generator adds to a ConfigMap's or Secret's name taken off, so the box keeps its name when its data changes. Sent a folder of kustomization.yaml files instead, it draws each overlay, base, and component as a frame of the files, patches, and generators it lists, with arrows for what an overlay builds on and includes, and what each patch patches.
flow "shop services" direction=down desc="The 6 services of a Docker Compose file in 2 networks, with 1 named volume. Each arrow points from a service to one it depends on."
node db_data_volume storage "db-data" sub="Named volume"
group edge_net "edge" type=network { node proxy gateway "proxy" sub="nginx:1.27-alpine, ports 80, 443" icon=simple:nginx node web service "web" sub="build ./web" node api service "api" sub="build ./api"}
group data_net "data" type=network { node worker service "worker" sub="build ./api, 2 replicas" icon=lucide:server node db_1 database "db" sub="postgres:16-alpine" icon=simple:postgresql node cache cache "cache" sub="redis:7" icon=simple:redis}
proxy -> webproxy -> apiweb -> apiapi -> db_1api -> cacheworker -> db_1worker -> cachedb_1 -> db_data_volume : "mounts" role=dataNodes:
- db-data (Named volume), storage
- Virtual network: edge
- proxy (nginx:1.27-alpine, ports 80, 443), gateway
- web (build ./web), service
- api (build ./api), service
- Virtual network: data
- worker (build ./api, 2 replicas), service
- db (postgres:16-alpine), database
- cache (redis:7), cache
Connections:
- proxy to web
- proxy to api
- web to api
- api to db
- api to cache
- worker to db
- worker to cache
- db to db-data (stored data line): mounts
Software catalogs
backstage reads Backstage catalog descriptors: one catalog-info.yaml of many documents, or every descriptor of a folder. Components, APIs, and resources are boxes typed by what they are (a website is a client, an openapi API carries the OpenAPI mark, a database resource is a database), systems are frames, and domains are frames around their systems. An arrow says a component provides or consumes an API, or depends on another entity, and a subcomponent is a dashed part of. Each box names its owner on its second line; with options.groupBy set to owner, the groups that own the entities are the frames instead, nested by their parent group, with their members named on the tab. Everything is named by its entity reference, such as component:default/orders-api. A Location is not followed, a kind such as Template is left out with I201, and a relation to an entity the files do not describe is I202.
Architecture models
structurizr reads a Structurizr workspace, written in its DSL (workspace.dsl) or exported as JSON, and draws one view of it as a C4 diagram. The view is the first systemContext, container, or component view the workspace defines, or the containers of its first system that has some; options.view (context, container, or component) and options.root (an identifier or a name, such as banking.api) choose another. A view holds what Structurizr's include * holds: the elements it is about and every person, system, or container a relationship joins to them, with a relationship of a component drawn as one of its container at the container level.
| What | How it is drawn |
|---|---|
| People, software systems, containers, components | C4 elements, with their description and technology. A container or component tagged Database, styled as a cylinder, or built on a database technology is a database. |
| The system or container the view is about | A boundary round its children. What a card holds is counted on it: Holds 5 containers and 3 components. |
| Groups and the enterprise | group and enterprise boundaries. A system inside the enterprise is the company's own; one outside it, or tagged External, is external. |
| Relationships | Lines with their description and technology, dashed when tagged Asynchronous, each drawn once. |
| Identifiers | Flat or !identifiers hierarchical, with this, -> x inside an element's block, !element, and !const read. They are the stable names: banking.api. |
autolayout tb or lr of the view sets the direction. An element the view does not reach is I201, with how to draw the view that holds it; a deployment environment is I201, and an !include or workspace extends is named, not fetched.
c4 "Online banking: containers" view=container direction=down desc="The containers of Online banking and what they talk to, imported from the Structurizr workspace Harbour Bank."
person customer "Personal customer" desc="A customer with a current or savings account."person agent "Support agent" desc="Answers customers in the contact centre."
boundary banking "Online banking" kind=system { container web "Web app" tech="React" desc="The banking pages in the browser." container api "Banking API" tech="Kotlin, Spring Boot" desc="Serves accounts, payments, and statements as JSON. Holds 3 components." db store "Banking database" tech="PostgreSQL" desc="Customers, sessions, and audit events."
boundary payments "Payments" kind=group { container worker "Payment worker" tech="Go" desc="Sends payment orders to the card scheme in batches." container orders "Payment orders" tech="RabbitMQ" desc="Holds payment orders until they are sent." }}
system ledger "Core ledger" desc="The bank's system of record for every account." external=onsystem cards "Card scheme" desc="Clears card and account-to-account payments." external=on
customer -> web : "Signs in and pays" tech="HTTPS"agent -> web : "Looks up a customer" tech="HTTPS"web -> api : "Calls" tech="JSON/HTTPS"api -> store : "Reads sessions" tech="JDBC"api -> orders : "Queues payment orders" tech="AMQP"api -> ledger : "Reads balances and posts entries" tech="MQ"worker -> orders : "Takes payment orders" tech="AMQP"worker -> cards : "Sends payments" tech="ISO 20022"Elements:
- Personal customer, person (A customer with a current or savings account.)
- Support agent, person (Answers customers in the contact centre.)
- Core ledger, external system (The bank's system of record for every account.)
- Card scheme, external system (Clears card and account-to-account payments.)
- Boundary: Online banking, software system
- Web app, container, React (The banking pages in the browser.)
- Banking API, container, Kotlin, Spring Boot (Serves accounts, payments, and statements as JSON. Holds 3 components.)
- Banking database, database, PostgreSQL (Customers, sessions, and audit events.)
- Boundary: Payments, group
- Payment worker, container, Go (Sends payment orders to the card scheme in batches.)
- Payment orders, container, RabbitMQ (Holds payment orders until they are sent.)
Relationships:
- Personal customer to Web app: Signs in and pays, HTTPS
- Support agent to Web app: Looks up a customer, HTTPS
- Web app to Banking API: Calls, JSON/HTTPS
- Banking API to Banking database: Reads sessions, JDBC
- Banking API to Payment orders: Queues payment orders, AMQP
- Banking API to Core ledger: Reads balances and posts entries, MQ
- Payment worker to Payment orders: Takes payment orders, AMQP
- Payment worker to Card scheme: Sends payments, ISO 20022
Event-driven systems
asyncapi reads AsyncAPI 2.x and 3.x documents in YAML or JSON, one application each. Send one, or every document of a folder in files: channels of one address are one box, so the picture shows where the producers and consumers of an event-driven system meet. Each application is a service, each channel a queue in the frame of its broker, and each operation an arrow labelled with its messages: into the channel for what the application sends, out of it for what it receives.
| What | How it is read |
|---|---|
| 3.x operations | action: send and action: receive, on the channel they reference, with their messages, or every message of the channel. |
| 2.x operations | Inverted, as the specification words them from the other side: subscribe is what the application sends, and publish is what it receives. |
| Servers | A frame for each kind of broker (kafka and kafka-secure are Kafka), with its host; a channel sits in it when its servers are of that kind. The picture runs down from what is sent to what reads it, so a broker is drawn once for each step that holds its channels, and a channel on a loop (an application that reads what its own sends lead to) stands outside the frame with the broker's mark. No frame holds both a channel an application sends to and one it reads, so no arrow runs round a frame. The Kafka, Redis, Pulsar, Amazon SNS and SQS, and Google Pub/Sub marks are drawn from the protocol; another mark only when the server's name names the product. |
| Names | app: and the title, channel: and the address, broker: and the protocol. |
An operation whose channel is not in its document is I202, a server that holds no drawn channel is I201, and a $ref into another file is read for its name only.
flow "Event flows" direction=down desc="4 applications and the 5 channels where they meet: an arrow into a channel sends to it, an arrow out of one receives from it, labelled with the messages. Imported from AsyncAPI."
node checkout_service service "Checkout service" sub="Version 2.4.0"node orders_placed_v1 queue "orders.placed.v1" sub="One event per order that was paid for." icon=simple:apachekafkanode orders_cancelled_v1 queue "orders.cancelled.v1" sub="An order the customer or support cancelled." icon=simple:apachekafkanode payments_settled_v1 queue "payments.settled.v1" icon=simple:apachekafkanode stock_reserved_v1 queue "stock.reserved.v1" icon=simple:apachekafkanode payments_service service "Payments service" sub="Version 1.12.3"node warehouse_service service "Warehouse service" sub="Version 3.1.0"node notification_service service "Notification service" sub="Version 0.20.1"
group kafka "Kafka" type=messaging icon=simple:apachekafka sub="2 hosts" { node payments_refunds_v1 queue "payments.refunds.v1" sub="Refunds asked for by support."}
checkout_service -> orders_placed_v1 : "OrderPlaced"checkout_service -> orders_cancelled_v1 : "OrderCancelled"payments_settled_v1 -> checkout_service : "PaymentSettled"stock_reserved_v1 -> checkout_service : "StockReserved"orders_placed_v1 -> notification_service : "OrderPlaced"payments_settled_v1 -> notification_service : "PaymentSettled, PaymentFailed"orders_placed_v1 -> payments_service : "OrderPlaced"payments_service -> payments_settled_v1 : "PaymentSettled, PaymentFailed"payments_refunds_v1 -> payments_service : "RefundRequested"orders_placed_v1 -> warehouse_service : "OrderPlaced"orders_cancelled_v1 -> warehouse_service : "OrderCancelled"warehouse_service -> stock_reserved_v1 : "StockReserved"Nodes:
- Checkout service (Version 2.4.0), service
- orders.placed.v1 (One event per order that was paid for.), queue
- orders.cancelled.v1 (An order the customer or support cancelled.), queue
- payments.settled.v1, queue
- stock.reserved.v1, queue
- Payments service (Version 1.12.3), service
- Warehouse service (Version 3.1.0), service
- Notification service (Version 0.20.1), service
- Messaging: Kafka (2 hosts)
- payments.refunds.v1 (Refunds asked for by support.), queue
Connections:
- Checkout service to orders.placed.v1: OrderPlaced
- Checkout service to orders.cancelled.v1: OrderCancelled
- payments.settled.v1 to Checkout service: PaymentSettled
- stock.reserved.v1 to Checkout service: StockReserved
- orders.placed.v1 to Notification service: OrderPlaced
- payments.settled.v1 to Notification service: PaymentSettled, PaymentFailed
- orders.placed.v1 to Payments service: OrderPlaced
- Payments service to payments.settled.v1: PaymentSettled, PaymentFailed
- payments.refunds.v1 to Payments service: RefundRequested
- orders.placed.v1 to Warehouse service: OrderPlaced
- orders.cancelled.v1 to Warehouse service: OrderCancelled
- Warehouse service to stock.reserved.v1: StockReserved
Pipelines
github-actions reads a workflow file. What starts it (push, pull_request, a schedule, or a manual run) is the first box, and each job follows in the order its needs set. When the steps fit, each job is a frame of its steps; otherwise a job is one box that says how many steps it runs.
flow "CI" direction=right desc="4 jobs of the CI workflow, started by a push to main or a pull request to main, in the order their needs set, imported from GitHub Actions."
node on terminal "Push or pull request"node lint step "Lint and type check" sub="ubuntu-latest, 6 steps" icon=simple:pnpmnode test step "Unit tests" sub="4 matrix runs, 6 steps" icon=simple:pnpmnode e2e step "Browser tests" sub="ubuntu-latest, 5 steps" icon=simple:pnpmnode build step "Build image" sub="ubuntu-latest, 3 steps" icon=simple:docker
on -> linton -> testlint -> e2etest -> builde2e -> buildNodes:
- Push or pull request, terminal
- Lint and type check (ubuntu-latest, 6 steps), step
- Unit tests (4 matrix runs, 6 steps), step
- Browser tests (ubuntu-latest, 5 steps), step
- Build image (ubuntu-latest, 3 steps), step
Connections:
- Push or pull request to Lint and type check
- Push or pull request to Unit tests
- Lint and type check to Browser tests
- Unit tests to Build image
- Browser tests to Build image
gitlab-ci reads a .gitlab-ci.yml. Its stages are frames down the page in the order of stages (GitLab's .pre, build, test, deploy, .post when it lists none), and its jobs sit inside them. A job with needs is reached from each job it needs, and needs: [] from the start; a job without is reached from every job of the stage before, which is when GitLab starts it. A job that runs by hand, by its rules, or only on some refs is reached by dashed arrows and says so. extends, hidden .jobs, and anchors are resolved and not drawn; parallel and parallel: matrix are counted; a trigger: job points at the child or downstream pipeline it starts. Each include: is named in I201, not fetched.
azure-pipelines reads an azure-pipelines.yml: stages as frames, jobs and deployment jobs inside, a deployment naming its environment, and arrows for dependsOn, a name or a list, at both levels. A stage with no dependsOn waits for the one before it, and dependsOn: [] starts with the pipeline; a condition: makes the arrows into a job dashed. A file of only jobs draws without frames, and a file of only steps as one chain. Templates, in a list or as extends: template:, are named in I201, not fetched.
Neither pipeline is told apart from other YAML by its text: name the file (.gitlab-ci.yml, azure-pipelines.yml) or set format.
Tables of data
csv reads comma, tab, or semicolon separated text with quoted fields, and json reads an array of records, rows with a header, an object of names and numbers, or nested objects of numbers. With options.chart left at auto, the shape of the table chooses the chart. A table too big for one chart is drawn as several in parts, never cut: the places of an XY chart in pages of up to 60 and its series in groups of six, a matrix in pages of 20 rows and 8 columns, a pie of more than 30 slices in pages that each keep one slice for all the others, so every pie is of the whole, flows on several sankeys (a flow back to an earlier node on a sankey of its own, since a sankey only goes forward), and tiles cut along their groups.
| Shape of the table | Chart |
|---|---|
| A source, a target, and a value column | sankey |
A path column (a/b/c), or two or more text columns and one number; nested JSON objects of numbers | treemap |
| Names, then columns of yes and no | matrix |
| One category and one number that are shares of a whole | pie |
| A category and one or more number columns | xychart, with lines when the categories are times |
Currency signs, thousands separators, and percent signs are read, and the unit goes on the chart.
xychart "P50 ms and p95 ms by endpoint" direction=horizontal desc="p50 ms and p95 ms for each endpoint, 5 in all, imported from a table."
x "Endpoint" ["GET /products" "GET /products/{id}" "POST /cart/items" "POST /checkout" "GET /orders"]
bar "p50 ms" [42 31 58 145 67]bar "p95 ms" [118 96 171 388 204]X axis, Endpoint: GET /products, GET /products/{id}, POST /cart/items, POST /checkout, GET /orders.
Y axis: 0 to 400.
Drawn with the places down the side.
Series:
- p50 ms (bar): GET /products 42, GET /products/{id} 31, POST /cart/items 58, POST /checkout 145, GET /orders 67
- p95 ms (bar): GET /products 118, GET /products/{id} 96, POST /cart/items 171, POST /checkout 388, GET /orders 204
Folder listings
tree reads the output of the tree command, a list of paths such as the output of git ls-files, or an indented outline. Files and folders get their icons; an outline of plain words becomes an outline tree. A listing of more than 60 rows, or deeper than six levels, is drawn as several trees in parts, cut along its folders: each part names the path of the folder it is in (src/lib), and no entry is left out.
tree "docs-site" desc="The folders and files of docs-site, 18 entries, imported from a tree listing."
dir "content" { dir "guides" { file "install.md" file "quick-start.md" } dir "reference" { file "api.md" }}dir "public" { file "favicon.svg" file "logo.png"}dir "src" { dir "components" { file "Nav.tsx" file "Page.tsx" } file "main.tsx" file "styles.css"}file "README.md"file "tsconfig.json"file "vite.config.ts"Git history
git-log reads git log --oneline --graph --decorate, plain git log, or --oneline. A log with a graph or decorations becomes a git graph: lanes from the graph, branch names from decorations and merge messages, and tags. A plain log with dates becomes a timeline of commits by day, or by month when it spans more than about twelve days. options.view chooses either. A longer history is drawn in pages of up to 40 consecutive commits, newest first, each a git graph of its own; a timeline draws a busy day or month as several periods of five commits, and a long one in pages. No commit is left out.
git "Repository history" desc="8 commits on 3 branches, with 2 merges and 2 tags, imported from git log."commit "Initial commit"commit "Add sign in with email" id=c9d0e1f2branch fix-login from=c9d0e1f2commit "Keep the session after a refresh"checkout mainmerge fix-login "Pull request #12" id=c5a6b7c8 tag="v1.0.0"commit "Fix a typo in the README"branch "feature/search" from=c5a6b7c8commit "Add a search box to the header"commit "Index the docs pages"checkout mainmerge "feature/search" tag="v1.1.0"Branches, in lane order:
- main is the default branch
- fix-login was made from main at "Add sign in with email"
- feature/search was made from main at "Pull request #12"
History, oldest first:
1. On main: commit "Initial commit"
2. On main: commit "Add sign in with email", id c9d0e1f2
- fix-login was made from main at "Add sign in with email"
3. On fix-login: commit "Keep the session after a refresh"
4. On main: merge of fix-login into main ("Pull request #12"), id c5a6b7c8, tagged v1.0.0
5. On main: commit "Fix a typo in the README"
- feature/search was made from main at "Pull request #12"
6. On feature/search: commit "Add a search box to the header"
7. On feature/search: commit "Index the docs pages"
8. On main: merge of feature/search into main, tagged v1.1.0
Limits
| Limit | Value | Past it |
|---|---|---|
| Text on the hosted service | 10 MiB in one call (10,485,760 characters), 200 files | Refused with I102 and a hint, as data; a request body over 10 MiB is refused with 413 before it is read. |
| Text on the local server and the command line | 100 million characters, 20 thousand files | A guard on memory, not a cap a real project meets. |
| Diagrams in one answer | 24 | Each grows a little first; past that the answer pages them, and partsPage asks for the next page. Nothing is left out. |
| Boxes in a Terraform flow | none | A stack of more than 30 boxes also comes back in parts. |
| Elements of a C4 view from Structurizr | 80, and 160 relationships | Left out past them with I200; draw a smaller view with options.view and options.root. |
| Boxes in another flow (Compose, Kubernetes, a workflow) | 40 a picture | Split into parts along its frames, with an overview of its groups when it has some, I204. |
| Tables in a schema | 20 a picture | Split into parts by schema name, then by tables that share keys, after an overview of the parts and the foreign keys between them, I204. A key to a table of another part ends on a copy of that table. |
| Endpoints of an OpenAPI matrix or map | 20 matrix rows, 8 columns, 60 map rows a picture | Split by tag, and columns into pages, all in parts, I204. |
| Rows and columns of another matrix | 20 rows, 8 columns a picture | Split into pages in parts, I204. |
| Places, slices, flows, or tiles of a chart | 60 a picture | Split into pages in parts, I204; a pie keeps the rest as one slice on each page. |
| Series of an XY chart | 6 a picture | Split into groups of series in parts, I204. |
| Rows of a tree | 60 a picture | Split by folder into parts, I204. |
| Commits of a git graph, events of a timeline | 40 a picture | Split into consecutive pages in parts, newest first, I204. |
| Messages of a sequence | 30 a picture | Split into sequences in parts, each with the request, I204. |
Nothing in a file is left out by a picture's size: past it, the import comes back in parts, each made a little bigger before there are more. An answer holds 24 parts; partsTotal says how many there are and partsPage asks for the next 24. A part can still be dense: validation then warns with W200.
Import codes
Every import diagnostic points into the file you sent.
| Code | Means | What to do |
|---|---|---|
I100 | The text is empty. | Send the content of the file. |
I101 | The text cannot be read as that format, such as YAML indented with a tab or a quote that never closes. | Fix the line it names, or check that format matches the file. |
I102 | The call is over the limits: characters that are not white space or a comment line, all characters, or files. | Send fewer files or one part, or use the local server, which reads far more. |
I103 | The importer wrote source that does not parse. | A mistake in the importer, not in your file: report it, and draw by hand meanwhile. |
I104 | An option names something the file does not have, such as an operationId. | Use one the hint lists. |
I105 | Nothing in the file can be drawn, such as SQL with no CREATE TABLE. | Check the format, or that the file holds what it should. |
I200 | A cap left items out. | Import a smaller part, or accept the summary. |
I201 | A construct or a file was skipped, such as a file of another format in a folder. | Read the hint; add it by hand if it matters. |
I202 | A reference points at something the file does not define, such as a foreign key to a missing table. | Include the target in the text, or ignore it if it lives elsewhere. |
I203 | Something was changed to fit, such as a short CSV row padded with empty cells or a matrix cell that is not yes or no turned into na. | Check the row it names, and fix the file if the change is wrong. |
I204 | A schema has more than six tables, so its text will be small; or an API was split into parts by tag. | Draw a schema in parts with options.tables, using the groups the hint names, such as ["users", "org_*"]; show every part of a split API. |
I206 | Much of the file is not in the picture; coverage says how much. | Say so when you show it, read the other diagnostics, and import a smaller part or choose with the options. |
See also
| Doc | Why |
|---|---|
| import_source | The tool's arguments, result, and limits |
| Import source over HTTP | POST /v1/import |
| Migrating from Mermaid | The other importer: Mermaid diagrams to Plotwork |
| Writing with an agent | The fix-and-check loop after an import |
| Diagnostic codes | Every code, including the import codes |
| Diagrams as code | Keep an imported diagram in step with its files in CI |
| Matrices, XY charts, Tree diagrams, Git graphs, C4 diagrams | The kinds an import writes, to edit the result |