How the Cockpit renders your app

cockpit.json declares your admin UI — the four top-level keys, how apps compose one sidebar, where writes go, and why an app never ships a component.

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 typelist, 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 per-view-type reference lives with Commerce Studio. Commerce Studio is the shell that renders these views, so the renderer's own surface — every column type, field type, filter type, action kind and widget option, and the required fields per view type — is documented there and deliberately not duplicated here. This page is the App Studio half: what your app declares, and how it composes with everybody else's.

The four top-level keys

cockpit.json
{
  "$schema": "https://schemas.revenexx.com/cockpit.schema.json",
  "navigation": [ ],
  "views": [ ],
  "widgets": [ ],
  "action_buttons": [ ]
}
KeyWhat it adds
navigationSidebar entries in the studio that hosts the app
viewsPage-level routes, each a list, detail or form
widgetsCards on the Cockpit dashboard
action_buttonsButtons 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.

cockpit.json
"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"
  }
]
FieldRequiredDescription
labelYesSidebar label. String or locale map. Keep it to one or two words.
iconYesIcon identifier from the Studio design system icon set.
routeNoRoute registered in Cockpit, must start with /. Omit it to declare a pure container that only expands its children.
positionNoSort order. Lower numbers first.
idNoStable anchor this entry claims, e.g. serials.registry. Another app targets it with parent.
parentNoAnchor id this entry attaches to, so a view can sit under another app's heading.
groupNoSidebar section key, e.g. pim, crm, order-management, configuration. Entries sharing a key cluster under one section header.
childrenNoNested sub-navigation items, same shape.
tabsNo"hosted" turns the entry into a tab strip over the children other apps attach by parent.
badgeNo{ 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.

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

typeThe renderer gives you
listA paged, sortable, filterable table over the entity, with row and bulk actions
detailOne record, in titled sections of fields, with actions and optional nested lists
formA 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

cockpit.json
"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:

cockpit.json
"write": { "method": "POST", "endpoint": "/serials" }
FieldRequiredDescription
endpointYesYour app's own route, :param substituted from the route params and the record id.
methodNoDefaults 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

Was this page helpful?