Portal JSON format
A portal can be built two ways — by hand in the composer, or from a single JSON document. Both produce an identical portal: the JSON pipeline drives the same primitives the composer does, so there is no behavioral difference between a hand-built portal and a JSON-built one.
- Import: Portals → Import JSON, paste a document, Build portal.
- Export: Portal settings → General → Export JSON (reference / round-trip).
- The format is versioned by the top-level
apifleet_portalmarker. - For the exhaustive field/type/enum reference, see PORTAL_JSON_COMPONENTS.md.
Minimal example
{
"apifleet_portal": 1,
"name": "Acme Docs",
"pages": [
{
"slug": "overview",
"title": "Overview",
"blocks": [
{ "type": "markdown", "config": { "source": "# Welcome to Acme" } }
]
}
]
}
Top-level fields
| Field | Required | Type | Notes |
|---|---|---|---|
apifleet_portal |
yes | integer | Format version. Must be 1. |
name |
yes | string | Display name. |
slug |
no | string | URL slug seed. Auto-generated from name, made unique per workspace. |
theme |
no | object | Theme tokens (see below). Omitted → default theme. |
specs |
no | string[] | Spec filenames to attach. Must already exist in the workspace (bundled, GitHub-synced, or pasted). |
pages |
yes | object[] | At least one page. Order in the array is the nav order. |
nav |
no | object | Sidebar structure (section labels, per-item icons, tabs, external links). Omit → auto-generated from the page list. Export always includes it so round-trips keep the exact navigation. References pages by slug. |
Pages
| Field | Required | Type | Notes |
|---|---|---|---|
slug |
yes | string | Unique within the document. |
title |
no | string | Defaults to slug. |
parent_slug |
no | string | Nests this page under a section. |
visible_to |
no | "anyone" | "authenticated" |
Default "anyone". "authenticated" gates the page to signed-in org members. |
blocks |
no | object[] | Content blocks, rendered top to bottom. |
Blocks
Every block is { "type": "...", "config": { ... } }. Unknown types are
rejected; unknown config keys are dropped. Any config field you omit falls
back to a sensible default.
markdown
{ "type": "markdown", "config": { "source": "## Heading\n\nBody text." } }
callout
{ "type": "callout", "config": { "variant": "info", "markdown": "Heads up." } }
variant: info | warning | success.
code
{ "type": "code", "config": { "language": "bash", "title": "Install", "source": "pip install acme" } }
image
{ "type": "image", "config": { "src": "https://…/d.png", "alt": "Diagram", "align": "center" } }
tabs
{ "type": "tabs", "config": { "tabs": [
{ "label": "cURL", "markdown": "```bash\ncurl …\n```" },
{ "label": "JS", "markdown": "```js\nfetch(…)\n```" }
] } }
card_grid
{ "type": "card_grid", "config": { "columns": 3, "cards": [
{ "title": "Quickstart", "description": "Get going fast.", "href": "/overview", "icon": "🚀" }
] } }
Card fields: title, description, href, icon (emoji), icon_url (small 24×24 image), image (hero). Priority when multiple set: icon_url → image → icon.
For href, use /<page-slug> for another portal page, #anchor for a heading
on the current page, or an absolute https:// URL for an external destination.
divider
{ "type": "divider", "config": {} }
Spec-driven blocks (endpoint, schema, endpoint_list)
These reference an attached spec by spec_file + address. The spec_file
must appear in the document's top-level specs.
{ "type": "endpoint", "config": {
"spec_file": "petstore.yaml",
"address": "op:GET /pets",
"show": { "params": true, "request_body": true, "responses": true,
"code_samples": true, "try_it": true },
"expand_depth": 2
} }
{ "type": "schema", "config": { "spec_file": "petstore.yaml", "address": "schema:Pet" } }
{ "type": "endpoint_list", "config": { "spec_file": "petstore.yaml", "title": "Endpoints" } }
address matches what the spec exposes — op:<operationId> or op:<METHOD> <path> for operations, schema:<Name> for schemas. Use Export JSON on a spec-driven portal to see exact addresses.
Theme
theme is an object of tokens. Common keys:
| Key | Example | Meaning |
|---|---|---|
primary / accent |
"#5b5bd6" |
Brand colors |
mode |
"light" | "dark" | "system" |
Color mode |
bg, surface, text, muted, border |
hex | Neutral palette |
font, font_heading, mono |
CSS font stack | Typography |
logo_url, logo_dark_url, favicon_url |
URL | Branding assets |
cta_label, cta_url |
string | Header call-to-action |
github_url, discord_url, twitter_url |
URL | Social links |
custom_css |
string | Freeform CSS (sanitized at render) |
Omitted keys use the portal default. See a live portal's Export JSON for a full, real theme object.
Unknown theme keys are not an extension mechanism and may have no visual
effect. Use the documented tokens or custom_css for supported overrides.
background (art direction)
theme.background is a declarative layer stack (color / gradient / mesh / grid
/ noise / image / decoration) that the renderer composes into one CSS
background — rich hero and page backgrounds with no custom CSS or JS, painted
identically in edit, preview, and publish. Layers are listed bottom → top;
colors can reference theme tokens so a background reskins with the theme.
"background": {
"scope": "hero",
"scrim": "auto",
"layers": [
{ "type": "color", "color": { "token": "bg" } },
{ "type": "gradient", "kind": "mesh", "blobs": [
{ "color": { "token": "brand" }, "x": 18, "y": 22, "radius": 55 },
{ "color": { "token": "accent" }, "x": 82, "y": 30, "radius": 48 }
] }
]
}
Full schema (every layer type, scrim, scope, animated, token names):
PORTAL_JSON_COMPONENTS.md §2.7.
Design rationale: ART_DIRECTION_SPEC.md.
Round-trip & guarantees
- Export → import → export yields an equivalent portal. Internal block IDs are regenerated on import (they're opaque), so the two are structurally identical, not byte-identical on those ids.
- Import always creates a new portal; it never edits an existing one.
- A referenced spec that isn't in the workspace fails the import with a clear message — attach or paste it first.
- Import respects the same permissions as the manual builder
(
portal.create). Publishing caps still apply at publish time, not import.