File & Folder Structure
File & Folder Structure

The canonical on-disk layout of an IOModel project: one tree for Studio, a local folder, and git.

An IOModel project is one file tree. The same paths appear in a local (file) project, in a git repository, and in a cloud project’s commit manifest — and that is the tree Studio shows. The layout is the source of truth: the cloud does not store paths that cannot exist on disk.

One layout, three places

Export a cloud project or import a git repository and you get the same document paths. Navigation files (meta.yaml) are written in Studio’s canonical form — one URL segment per pages: entry, with nested folders holding their own meta.yaml.

Project root

<project>/
├── project.yaml              # title, description, icon, features
├── models.yaml               # model index
├── tags.yaml                 # optional global tag taxonomy
├── models/                   # models, objects, workflows, declared attachments
├── specs/                    # documentation
└── schema/                   # optional custom object schemas & templates
FilePurpose
project.yamlProject-level title and settings.
models.yamlTop-level index that registers each model by key (models: { <key>: null }).
tags.yamlGlobal group:value tag taxonomy (optional; can also live inline in models.yaml).

Models

A model describes one product, system, or domain. Models are registered in models.yaml and split into per-object / per-workflow files for clean diffs.

models.yaml                                   # models: { deployment: null }
models/
└── deployment.yaml                           # model root: metadata + objects/workflows declarations
    │
    ├── objects/                              # object tree (one file per object)
    │   ├── iomodel.yaml                      #   object "iomodel"
    │   └── iomodel/                          #   folder = children + attachments of "iomodel"
    │       └── …
    │
    └── workflows/                            # workflow tree (one file per workflow/group)
        ├── sign-in.yaml                      #   workflow "sign-in" (type: wf)
        ├── sign-in/                          #   folder = attachments of "sign-in"
        │   └── …
        ├── billing.yaml                      #   group "billing" (type: group)
        └── billing/
            └── checkout.yaml                 #   nested workflow "billing.checkout"

Model index — models.yaml

models:
  deployment: # value is null; the body lives in models/deployment.yaml

Model root — models/<model>.yaml

Holds model metadata plus declarations of the first-level objects and workflows. Declarations are maps of key: null; each declared key points at a sibling file under objects/ or workflows/.

title: Deployment Environments
type: model
objects_schema: kubernetes # object vocabulary (c4, kubernetes, custom id, or URL)
objects:
  iomodel: null # → models/deployment/objects/iomodel.yaml
workflows:
  sign-in: null # → models/deployment/workflows/sign-in.yaml
  release-upgrade: null

Strict declarations

A file under objects/ or workflows/ is only loaded if its key is declared in the parent (objects: / workflows:). An undeclared file is ignored — there is no implicit pickup. This keeps the tree explicit and ordered (declaration order = display order).

Objects — models/<model>/objects/**

Each object is one YAML file; its folder of the same name holds its children and attachments.

models/<model>/objects/<slug>.yaml            # the object definition (declares its own children)
models/<model>/objects/<slug>/<child>.yaml    # nested child object
# models/deployment/objects/iomodel.yaml
title: IOModel
type: deployment
objects:
  api: null # → models/deployment/objects/iomodel/api.yaml

Workflows — models/<model>/workflows/**

Workflows mirror the object layout exactly. A leaf workflow is type: wf; a group is type: group and declares its children.

models/<model>/workflows/<slug>.yaml          # workflow (type: wf) or group (type: group)
models/<model>/workflows/<group>/<child>.yaml # nested workflow inside a group
# models/deployment/workflows/sign-in.yaml  (leaf)
type: wf
title: User sign-in
desc: How a browser request authenticates across the stack.
diagram:
  syntax: sequenceDiagram
  file: diagram.wf
# models/deployment/workflows/billing.yaml  (group)
type: group
title: Billing
workflows:
  checkout: null # → models/deployment/workflows/billing/checkout.yaml

Attachments

An attachment is any file that belongs to an entity and is not that entity’s own file: not <page>.mdx and not <object>.yaml. A screenshot on a spec page, a .draw diagram, an OpenAPI file, a protobuf schema, and an object handbook are all attachments. The test is ownership, not the file extension.

KindExamplesStored asIn a diff
Text attachment.draw, .proto, openapi.yaml, handbook MDXtext, editable in Studioline by line
Opaque attachment.png, .webp, .webm, .pdfbytes, verbatimadded / changed / deleted

Declare attachments on an object or workflow under attachments: — the old YAML key artifacts: is rejected (retired-yaml-key) and the object’s tabs disappear until you rename it.

Naming by attachment kind

KindYAML fieldOn-disk layout (inside the owner’s folder)
Workflow diagramdiagram.file<key>/diagram.wf
MDX handbook / specattachments.<mdxKey> (type: mdx)<key>/<mdxKey>/index.mdx (+ nested .mdx pages)
OpenAPIattachments.<key> (type: openapi)<key>/<artifactKey>.yaml
ERDattachments.<key> (type: erd)<key>/<artifactKey>.erd
Source textattachments.<key> (type: sql / json / yaml / xml / protobuf / bpmn)<key>/<artifactKey>.<ext>

Declared attachments stay next to the owner’s YAML, under their own names (mqtt_broker/api.openapi.yaml, mqtt_broker/guide/index.mdx). They do not go into _attachments — that directory is hidden from navigation, and object tabs must stay visible.

Embedded attachments (images, .draw inserted from a document) live in _attachments/ inside that document’s directory. The link is always ./_attachments/<file>.

Workflow attachments

models/<model>/workflows/<…>/<wf_key>.yaml        # workflow definition (type: wf)
models/<model>/workflows/<…>/<wf_key>/
├── diagram.wf                                     # the sequence diagram
└── <mdxKey>/
    ├── index.mdx                                  # an attached MDX specification
    └── _attachments/                              # images / .draw of that handbook page

For example, the billing.checkout workflow:

models/deployment/workflows/billing/checkout.yaml
models/deployment/workflows/billing/checkout/
├── diagram.wf
└── runbook/
    └── index.mdx

Object attachments

models/<model>/objects/<…>/<obj_key>.yaml         # object definition
models/<model>/objects/<…>/<obj_key>/
├── <openapiKey>.yaml                              # OpenAPI spec
├── <erdKey>.erd                                   # ERD
├── <sqlKey>.sql                                   # source-text attachment
└── <mdxKey>/
    └── index.mdx                                  # MDX handbook

Because every owner has its own folder, attachment keys never collide between sibling entities — the folder path already namespaces them.

`_assets` is gone

The attachments directory is _attachments. After migration the segment _assets has no meaning: import does not treat it as attachments, export does not write it, and it is not an alias. A leftover _assets folder is just a hidden _ directory — it will not appear in the sidebar and will not be owned as attachments. Bring an old tree to this layout with pnpm normalize:specs.

Specs (documentation)

Specs are MDX pages organized into groups. Each group is a top-level folder under specs/. Folders nest freely; ordering and titles come from meta.yaml.

specs/meta.yaml                   # group order + optional global labels
specs/<group>/meta.yaml           # group title, icon, page order
specs/<group>/**/*.mdx            # pages
specs/<page>/_attachments/*       # every embedded attachment of that page

A page exists in exactly one of two forms, and the form follows the page’s content — it is not a choice:

FormWhenExample
Flat filethe page has neither children nor attachmentsspecs/base/overview.mdx
Directory with index.mdxthe page gained children or attachmentsspecs/base/diff-and-merge/index.mdx
specs/base/
  meta.yaml
  overview.mdx                  ← most pages: nothing extra
  diff-and-merge/
    index.mdx                   ← this page has attachments
    _attachments/changes-diff.png
    _attachments/merge-flow.draw
    conflicts.mdx               ← and a child page

The URL does not change when a page is promoted: /specs/base/overview is the same for overview.mdx and overview/index.mdx.

Promotion is one-way. Deleting the last image or the last child does not collapse the directory back into a flat file. The user deleted content; they did not ask for a restructure.

Empty `meta.yaml`

A folder that exists only to hold attachments does not get an empty meta.yaml. That file appears when the page has children to order (pages: [limits]).

Adding a child to a flat page

Studio: context menu on the page → Add child → name. The tree grows a nested node; you never pick a form or see index.mdx.

On disk, in one commit:

before                        after
specs/base/                   specs/base/
  meta.yaml                     meta.yaml          ← unchanged: the page key is still overview
  overview.mdx                  overview/
                                  index.mdx        ← the same page
                                  limits.mdx       ← new
                                  meta.yaml        ← pages: [limits]

Doing it by hand:

mkdir specs/base/overview
git mv specs/base/overview.mdx specs/base/overview/index.mdx
printf 'pages:\n  - limits\n' > specs/base/overview/meta.yaml

If the page is already a directory (it already had attachments or children), there is no conversion: only limits.mdx is created and pages: is updated.

The child key index is reserved — that file is the parent page.

meta.yaml

Each pages: entry is one URL segment. A nested folder is listed by its own key; that folder’s meta.yaml lists its children. Do not write fumadocs nested references such as introduction/concepts.

# specs/base/getting-started/meta.yaml
title: Getting Started
pages:
  - introduction
  - quickstart

# specs/base/getting-started/introduction/meta.yaml
title: Introduction
pages:
  - concepts
File / folderPurpose
specs/<group>/A documentation group (top-level sidebar section).
meta.yamlFolder title + pages: list controlling order and inclusion.
index.mdxThe page itself when the page is a directory.
<page>.mdxA leaf page with no children and no attachments.
_attachments/Images, video, PDF, .draw owned by that page. Names starting with _ are never pages.

Pages vs. attachments

Any file or folder whose name starts with _ is a non-page resource and never appears in the sidebar. Images live in _attachments/ inside the page’s directory and are referenced as ![alt](/org/4/project/15/attachments/specs/base/reference/_attachments/%E2%80%A6). The retired folder name _assets and ?version=N on image URLs are not part of the contract — git, Studio, and published sites all use the same relative path.

Naming rules (summary)

Keys are slugs

Object, workflow, and spec keys use lowercase letters, digits, and dashes (device-onboarding). The key is the file name (minus extension) and becomes part of the URL/path.

Folder mirrors key

An entity at <key>.yaml owns the sibling folder <key>/ for its children and attachments. Groups use the same rule.

Declarations are the source of truth

The parent’s objects: / workflows: map decides which sibling files load and in what order. Keep it in sync when adding or removing files. Specs are the other way around: files are discovered, and meta.yaml only sets order.

Reserved names

diagram.wf (workflow diagram), index.mdx (folder/handbook landing), meta.yaml (ordering), and _-prefixed folders (attachments) are reserved. A child spec page cannot be named index.

Last updated on