How the Cockpit renders your app
cockpit.json declares the admin UI your app adds to the Cockpit. If your app is a pure API service with no operator screens, you do not need this file.
The Cockpit UI is fully declarative. An app never ships UI code. There are no Vue components, no bundles, no component names to export. You declare a view's shape with a type — list, detail or form — and the Cockpit dispatches to a built-in renderer that reads and writes your entity for you. What you configure is which columns, which fields, which filters and which actions that renderer should show.
The authoritative schema is at https://schemas.revenexx.com/cockpit.schema.json. It is large and precise; point your editor at it.
The four top-level keys
{
"$schema": "https://schemas.revenexx.com/cockpit.schema.json",
"navigation": [ ],
"views": [ ],
"widgets": [ ],
"action_buttons": [ ]
}
| Key | What it adds |
|---|---|
navigation | Sidebar entries in the studio that hosts the app |
views | Page-level routes, each a list, detail or form |
widgets | Cards on the Cockpit dashboard |
action_buttons | Buttons injected into another app's entity views |
Most apps use navigation and views, and add a couple of widgets. action_buttons exists in the schema but no shipped app uses it — treat it as untested ground rather than a documented feature.
Navigation
"navigation": [
{
"id": "serials.registry",
"label": { "en": "Serials", "de": "Seriennummern" },
"icon": "hashtag",
"route": "/serials",
"position": 40,
"group": "pim"
},
{
"parent": "commerce.configuration",
"label": { "en": "Serial settings", "de": "Seriennummern-Einstellungen" },
"icon": "settings",
"route": "/serials/settings",
"position": 60,
"group": "configuration"
}
]
| Field | Required | Description |
|---|---|---|
label | Yes | Sidebar label. String or locale map. Keep it to one or two words. |
icon | Yes | Icon identifier from the Studio design system icon set. |
route | No | Route registered in Cockpit, must start with /. Omit it to declare a pure container that only expands its children. |
position | No | Sort order. Lower numbers first. |
id | No | Stable anchor this entry claims, e.g. serials.registry. Another app targets it with parent. |
parent | No | Anchor id this entry attaches to, so a view can sit under another app's heading. |
group | No | Sidebar section key, e.g. pim, crm, order-management, configuration. Entries sharing a key cluster under one section header. |
children | No | Nested sub-navigation items, same shape. |
tabs | No | "hosted" turns the entry into a tab strip over the children other apps attach by parent. |
badge | No | { type: "count" | "dot", source } — an indicator fed by an endpoint. |
The field names are route and position — not path and order.
Routes are mounted under the studio that hosts the app, typically /commerce, so "/serials" becomes /commerce/serials in the browser. Declare the studio-relative path.
An entry with neither a route nor children has nothing to show and is dropped.
Attaching to another app's heading
id and parent are how apps compose a sidebar rather than each claiming their own top-level entry. The app that claims an id owns that entry's label, icon, route and position; another app names the same id in parent to contribute a child under it.
Naming an anchor rather than matching on a label means renaming or translating a label cannot silently break the link. If no installed app claims the id, the item stays top-level rather than disappearing. Two apps claiming the same id is a conflict the Cockpit reports rather than resolves.
Icons
Icons are Solar icon names, written kebab-case. The Cockpit resolves a name by PascalCasing it — map-point becomes MapPoint, folder-tree becomes FolderTree — and falls back to a generic box icon when the name does not resolve, so a typo shows up as the wrong icon rather than a blank sidebar.
These are not Lucide names. shopping-cart happens to exist in both sets; most do not. The names in use across the platform's own apps are listed with the cockpit.json reference.
Views
Each view declares a route, a type and the entity it works on. The route uses :param placeholders — /serials/:id — which is a different convention from a capability's {braces} route in manifest.capabilities.json.
"views": [
{ "route": "/serials", "type": "list", "entity": "device_serials", "columns": [] },
{ "route": "/serials/:id", "type": "detail", "entity": "device_serials" },
{ "route": "/serials/new", "type": "form", "entity": "device_serials", "mode": "create" },
{ "route": "/serials/:id/edit", "type": "form", "entity": "device_serials", "mode": "edit" }
]
That four-view set — list, detail, create form, edit form — is what every shipped app declares per entity.
type | The renderer gives you |
|---|---|
list | A paged, sortable, filterable table over the entity, with row and bulk actions |
detail | One record, in titled sections of fields, with actions and optional nested lists |
form | A create or edit form (mode), with validation, from declared fields |
Between them, those three renderers accept a large amount of configuration — dozens of column types, field types, filter shapes, action kinds and widget options. That surface is documented in Commerce Studio, along with which fields each view type requires. Read it there; what follows here is only what is specific to your app declaring rather than the renderer rendering.
Every view can be gated
"permissions": ["device_serials.read"]
The convention is <entity>.read for a view that only reads and <entity>.write for one that writes. It gates whether a Cockpit user sees the view at all.
This is a UI gate on the operator, not a grant to your app — that is manifest.permissions — and it is not an API check either. A hidden view is not a protected route. Gate the route too, with provides_permissions.
Where writes go
By default the renderer reads and writes the entity directly. That is the right default for an admin surface: a position column, a label, a boolean flag gain nothing from a round trip through your function that would write the same column back.
Declare write when the database cannot do the job on its own — a contact that must also become a platform user, a stock movement that has to leave a ledger entry behind it:
"write": { "method": "POST", "endpoint": "/serials" }
| Field | Required | Description |
|---|---|---|
endpoint | Yes | Your app's own route, :param substituted from the route params and the record id. |
method | No | Defaults to what the surface is doing — POST for a create, PATCH for an edit, DELETE for a delete. Use PUT if you model an update as a full replacement. |
The endpoint is resolved against your app's own capability routes under /v1, so "/serials" reaches the capability whose route is POST /serials. That capability has to exist: the Cockpit calls the same public route a partner integration would. The same is true of an api action's endpoint.
That is the one thing about cockpit.json most worth remembering as an app developer. Your admin UI is a client of your own API. A view that writes through an endpoint you never declared as a capability is a 404 in the browser, and a capability whose request schema is stricter than the form is a 400 an operator cannot explain. See Capabilities.
action_buttons
The schema defines action_buttons as buttons your app injects into another app's detail and list views: you name a target_entity, and when the Cockpit renders that entity's view your button appears in the toolbar alongside its own actions. Required fields are label, target_entity and kind (navigate, api or link).
No shipped app uses it. The mechanism is declared but unexercised, so there is no production example to point at and no behaviour we can describe with confidence. If cross-app UI is what you need, treat this as something to try and verify rather than as a documented feature.
How composition works
The Cockpit reads every installed app's cockpit.json and merges the declarations into one interface. Sidebar entries from all apps are ordered together by group and position; every views entry becomes a live route; every widgets entry becomes available on the dashboard.
That is why you declare where things go rather than building the shell: your app's screens and a dozen other apps' screens have to end up in one coherent sidebar, and no app is in a position to arrange that on its own.
It is also why there is no component to ship. The renderers are the Cockpit's, they follow the Studio design system, and they get authentication, tenant context, permissions, pagination, loading states and error states right in one place instead of thirteen.
Practical guidance
Claim one top-level entry, not one per page. A nav entry is the way into your app. Detail and form views are reached from the list, not from the sidebar. The sidebar is a shared resource — every extra entry is one an operator scans past to reach somebody else's app.
Declare sort on anything called "recent". A list without an explicit sort shows whatever the database returned first, which is not the same as newest.
Put vocabulary on badge columns whose values a merchant can extend. A hardcoded options list cannot offer a value the merchant created after you wrote the file. See Vocabularies.
Let the renderer write, unless it genuinely cannot. Reach for write only when the change has a side effect the database cannot produce on its own.
Preview before you deploy. A wrong column name is a broken screen in a merchant's Cockpit and a five-second fix on your laptop. See Preview locally.
Next steps
- Commerce Studio: build an app — the full per-view-type
cockpit.jsonreference. - Settings — the other file a merchant interacts with.
- CRUD — the routes a
writeor anapiaction calls. - Schema reference — the entities views render.