Give your App a Cockpit screen
The Locations App from Build your first App answers API calls, and the first question from the client is "where do I see it?". Operators, the merchant's staff, work in Cockpit, the admin UI, and its commerce section is called Commerce Studio. At the end of this guide an operator finds Locations in the Commerce Studio sidebar, scans a list, opens a location, creates and edits one, and presses a button that seeds the main warehouse through your own route. You write one file, cockpit.json, and no UI code.
This is for developers who have the Locations App from that guide. You need its directory, the revenexx CLI signed in with revenexx login, a tenant slug to deploy to, a login to Cockpit to see the result, and an X-Revenexx-Api-Key (see create an API key). A development key with full access (*) is simplest for the deploy; calling the finished App's routes needs locations.* (which scope covers which app). Scaffolded Apps already contain a cockpit.json with one list view, and you replace it here. The snippets use Locations' columns (code, name, type, priority, enabled), so for another App swap in the names from your own schema.json.
/commerce 500, can change (as of 2026-10).How a screen gets there
cockpit.json is declarative. Commerce Studio reads it, merges it with the files of every other installed App, and draws what it declares: your navigation entries land in the sidebar and each view becomes a page under /commerce. The file is stored with your App's version and read when a page is drawn, so no code of yours runs to draw a screen.
Two words are used for paths, so keep them apart. A page route is where a screen lives in Cockpit, such as /locations, written relative to the studio. An API route is one your App serves under /v1 and declares in manifest.capabilities.json; that declaration is a capability. A button or a form that saves through your own code calls an API route, and it works only once that capability exists (From manifest to installed).
Four top-level keys exist: navigation, views, widgets and action_buttons. This guide uses the first two. The public schema gives completion and validation in your editor when you reference it with $schema, as the file below does.
1. Add a sidebar entry and a list
Replace the contents of cockpit.json. A navigation entry needs a label, an icon (a Solar icon name, kebab-cased) and a page route. A list view needs route, type, entity and columns, where entity is the table name from your schema.json:
{
"$schema": "https://schemas.revenexx.com/cockpit.schema.json",
"navigation": [
{
"id": "order-management.locations",
"label": { "en": "Locations" },
"icon": "delivery",
"route": "/locations",
"position": 40,
"group": "order-management"
}
],
"views": [
{
"route": "/locations",
"type": "list",
"entity": "locations",
"title": { "en": "Locations" },
"columns": [
{ "name": "code", "label": "Code", "type": "text", "sortable": true },
{ "name": "name", "label": "Name", "type": "text" },
{ "name": "type", "label": "Type", "type": "text" },
{ "name": "priority", "label": "Priority", "type": "number", "sortable": true },
{ "name": "enabled", "label": "Enabled", "type": "boolean" }
],
"filters": [
{ "name": "q", "label": "Search", "type": "text", "columns": ["code", "name"] },
{ "name": "enabled", "label": "Enabled", "type": "boolean", "column": "enabled" }
],
"sort": { "column": "priority", "direction": "asc" },
"page_size": 50,
"row_action": "/locations/:id",
"permissions": ["locations.read"]
}
]
}
group picks the sidebar section: pim, crm, marketing, order-management, procurement or configuration. id is optional and names the entry so another App can attach a page under it. It is lowercase words joined by dots, and the group prefix is a naming convention, not a rule: group alone decides the section. row_action makes a row open its detail view, which comes in step 3. The permissions line restricts nothing today; step 6 explains. Misconfigurations here fail quietly rather than loudly (see Troubleshooting).
2. Preview it on your machine
Render the file in the real Commerce Studio, against mock rows generated from your schema.json, before you deploy anything. Run it in the App's own directory, with cockpit.json and schema.json side by side; without schema.json the list shows "Unknown entity" and no rows.
npm i -D @revenexx/studio-commerce-devkit
npx commerce-studio
Open http://localhost:3030/commerce/locations, the page route of your list view with /commerce in front, and Locations is highlighted in the sidebar. Do not start at http://localhost:3030/commerce: its dashboard fails to load in the current devkit (0.1.11) with "does not provide an export named 'default'", and the browser shows a 500. Reload the page after every edit in the steps below; the files are read again on each request, so there is no rebuild. Creates and edits live in memory for the session. The preview creates a .commerce-studio/ folder, so add it to your .gitignore. Preview locally covers the variables and what the preview leaves out.
3. Add a detail view
A detail view shows one record. :id in the page route is the record's id, and the content comes from sections. Add this object to the views array, after the list view and separated from it by a comma:
{
"route": "/locations/:id",
"type": "detail",
"entity": "locations",
"title": { "en": "Location" },
"layout": "main-aside",
"sections": [
{
"title": { "en": "Identity" },
"fields": [
{ "name": "code", "label": "Code", "type": "text", "readonly": true },
{ "name": "name", "label": "Name", "type": "text" },
{ "name": "type", "label": "Type", "type": "text" }
]
},
{
"title": { "en": "Settings" },
"fields": [
{ "name": "priority", "label": "Priority", "type": "number" },
{ "name": "enabled", "label": "Enabled", "type": "boolean" }
]
},
{
"title": { "en": "Timestamps" },
"column": "aside",
"fields": [
{ "name": "created_at", "label": "Created", "type": "datetime", "readonly": true },
{ "name": "updated_at", "label": "Updated", "type": "datetime", "readonly": true }
]
}
],
"actions": [
{ "label": { "en": "Edit" }, "icon": "pen", "kind": "navigate", "to": "/locations/:id/edit" }
],
"permissions": ["locations.read"]
}
"layout": "main-aside" is what moves the section marked "column": "aside" into a right-hand rail; without it every section stacks in the main column. Reload the preview and open a row. The Edit button points at a page you add next.
4. Add a form for create and edit
A form view writes one record: mode is create (the default) or edit. A create form has a static page route and an edit form has :id. Add both of these to views, after the detail view. The New location button in step 5 opens the first:
{
"route": "/locations/new",
"type": "form",
"entity": "locations",
"mode": "create",
"title": { "en": "New location" },
"fields": [
{ "name": "code", "label": "Code", "type": "text", "required": true,
"transform": "lowercase", "validation": { "pattern": "^[a-z0-9_-]{2,40}$" } },
{ "name": "name", "label": "Name", "type": "text", "required": true },
{ "name": "type", "label": "Type", "type": "select", "default": "warehouse",
"options": [
{ "value": "warehouse", "label": "Warehouse" },
{ "value": "store", "label": "Store" },
{ "value": "dropship", "label": "Dropship" },
{ "value": "virtual", "label": "Virtual" }
] },
{ "name": "priority", "label": "Priority", "type": "number", "default": 0 },
{ "name": "enabled", "label": "Enabled", "type": "boolean", "default": true }
],
"submit_label": { "en": "Create" },
"on_success": "/locations/:id",
"permissions": ["locations.write"]
}
{
"route": "/locations/:id/edit",
"type": "form",
"entity": "locations",
"mode": "edit",
"title": { "en": "Edit location" },
"fields": [
{ "name": "code", "label": "Code", "type": "text", "write_once": true },
{ "name": "name", "label": "Name", "type": "text", "required": true },
{ "name": "type", "label": "Type", "type": "select",
"options": [
{ "value": "warehouse", "label": "Warehouse" },
{ "value": "store", "label": "Store" },
{ "value": "dropship", "label": "Dropship" },
{ "value": "virtual", "label": "Virtual" }
] },
{ "name": "priority", "label": "Priority", "type": "number" },
{ "name": "enabled", "label": "Enabled", "type": "boolean" }
],
"submit_label": { "en": "Save" },
"on_success": "/locations/:id",
"permissions": ["locations.write"]
}
write_once lets a field be set on create and fixes it afterwards. By default a form writes the entity itself: a create inserts a row in the entity's table and an edit updates it, with no route of yours in between, which suits a plain admin surface. Nothing of yours can refuse such a save. What can is the check constraint in schema.json, the tenant and the install check; nothing looks at the operator's role (step 6). To run your own code on save, for a side effect the database cannot produce on its own, declare write with an API route (Actions and writes). A select takes options as value and label pairs, or a vocabulary, a named list of values your App publishes. The check constraint in schema.json stays the real validation; the form's required and pattern only help the person typing.
5. Add a button that calls your own route
An api action puts a button on a screen that calls an API route of your App. Locations already has one: POST /locations/defaults seeds the main warehouse and, as the first-app guide builds it, is safe to repeat. Add this member to the list view object, after its permissions line, and put a comma at the end of that line:
"actions": [
{ "label": { "en": "New location" }, "icon": "add-circle", "kind": "navigate",
"to": "/locations/new", "variant": "primary" },
{ "label": { "en": "Seed main warehouse" }, "kind": "api", "method": "POST",
"endpoint": "/locations/defaults",
"confirm": { "title": { "en": "Seed main warehouse" },
"message": { "en": "Creates the main warehouse if it does not exist yet." } },
"success_message": { "en": "Main warehouse is in place." },
"note": { "en": "Does not change locations that already exist." } }
]
The file now holds one navigation entry and four views: the list at /locations, the detail at /locations/:id, and the forms at /locations/new and /locations/:id/edit. Reload the preview to see the buttons.
The endpoint resolves against your App's own API routes, so the button calls POST https://api.revenexx.com/v1/locations/defaults with the operator's Authorization: Bearer token plus X-Revenexx-Tenant and X-Revenexx-Market headers (Actions and writes). It is the same public route a partner integration would call, with no privileged path. The route has to be declared in manifest.capabilities.json, which the first-app guide's capabilities script already does for locations.defaults; an endpoint with no declaration answers 404 after the click. The note is a sentence beside the button saying what it does not do. Your App owns that claim, and Cockpit never invents one.
Commerce Studio checks no role or permission before it sends the click, and the first-app route rejects only a request without a tenant, so any signed-in operator of the tenant can press the button. How a route learns an operator's role from that token is not documented (as of 2026-10), so treat a button as open to every operator of the tenant.
If a button needs input, add fields and Cockpit opens a dialog whose values become the JSON body. In the preview, /functions lists your API routes and lets you send one to your function. Of the eleven action kinds, navigate, api, create, delete and bulk-edit are in production use. The other six, link, edit-dialog, bulk-adjust, inline-create, assign and refresh, are supported but used by no shipped App, so prefer the first five.
6. Decide what a view's permissions key does
Every view accepts a permissions array, and the snippets above already carry one. The convention in the shipped commerce Apps that declare view permissions is <entity>.read for a view that reads and <entity>.write for one that writes, so follow the spelling.
Commerce Studio does not read this key when it draws a view or sends a click (as of 2026-10), so today it changes nothing an operator can see or do. That includes the default lists, details and forms in this guide, which read and write your App's data directly with no route of yours in front. The platform accepts such a request when the operator's token is valid for the tenant and your App is installed there, and it limits the rows to that tenant. It does not check permissions (as of 2026-10), so any signed-in operator of the tenant can read, create and edit locations through those views, and the locations.write on the forms above protects nothing. The only places your own code can run before a write are a form's write route and a button's API route, and what those can learn about the operator is not documented (see step 5). Declare the keys anyway and keep the spelling: Permissions and scope says an operator's role is configured against them, and where that is done is not documented yet. The key is also not a grant to your App, which is declared in manifest.json (App permissions), and not a buyer-side grant, which an App publishes with provides_permissions.
7. Deploy it and see it in Cockpit
deploy app publishes by default, and publishing lists an App for every tenant, so any tenant can install it. If you already deployed the first-app guide with its defaults, Locations is installable by other tenants. revenexx apps unpublish --function-id <APP_ID> (find the id with revenexx apps list; the name won't work) takes it off the Marketplace so nobody new can install it, but it does not uninstall it from tenants that already have it (Publish and install). Deploy this guide with --no-publish: it builds the App and installs it on your tenant without listing it. For an App only your own tenant should ever run, also set type to private in manifest.json; publishing a private App is refused, so --no-publish is then required (From manifest to installed).
Deploy from the App's directory. login saves the key but not the tenant, so in a new shell export the tenant slug as well, as in the first-app guide. The key is your development key and the tenant is your tenant slug:
export REVENEXX_API_KEY="rvxk_..."
export REVENEXX_TENANT="<TENANT_SLUG>"
revenexx login --token "$REVENEXX_API_KEY" --tenant "$REVENEXX_TENANT"
revenexx deploy app --no-publish
One command registers the version, builds it and installs it on your tenant; without --no-publish it would also publish it. Sign in to Cockpit (an admin of your organization can grant access), choose Commerce Studio in the navigation rail, and Locations appears in the Order management section of the sidebar. The screens change as soon as the deployment is active, because cockpit.json is read when a page is drawn. A new API route behind a button exists only after the install step has run for that version, which deploy app does by default, with or without --no-publish; a deploy with --no-install gives an operator a button that answers 404.
Limits to plan for
- Widgets. A
widgetsentry puts a card on the dashboard, and the dashboard draws only the renderers that exist: a count, a list and an App panel. Any othercomponentvalue renders as a count card, so a typo shows up as a number rather than an error; a placeholder appears only once the widget's App is uninstalled (Widgets). action_buttonsis in the schema and no shipped App uses it. For a page under another App's heading, give your navigation entry aparentnaming that heading'sid.- The preview serves mock data and a simulated gateway, and its media picker is inert, so it proves the layout and not authorisation or your deployed routes.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
The browser shows a 500 at /commerce | The devkit's dashboard page fails to load | Open a view's page directly, such as /commerce/locations |
| The entry is missing from the sidebar | It has neither a page route nor children, or the file has a syntax error | Add a route; open the file in an editor that reads the $schema |
| The entry is in the wrong section | group is not one of the six keys, so it is filed under "More" | Use pim, crm, marketing, order-management, procurement or configuration |
| The list renders and is empty | entity does not match a table in schema.json, or the table has no rows | Check the entity name and create a row in the preview |
| The preview says "Unknown entity" | There is no schema.json next to cockpit.json | Run npx commerce-studio in the App's directory, where both files are |
A button answers 404 | The endpoint has no capability in manifest.capabilities.json, or the version was deployed without install | Declare the route and deploy with install (step 7) |
| A route works in the preview and not in Cockpit | The preview serves mock data and a simulated gateway | Test the deployed route with curl as in the first-app guide |
| An icon is a generic box | The Solar icon name did not resolve | Fix the spelling |
Where to go next
Add a second view of your own, such as a list of the records that reference a location. Then read:
- cockpit.json reference and the pages for list views, detail views and forms: every option.
- Add a data model: a second entity and a guarded action route to put a screen on.
- Publish a business event from your App: announce a change from the same App.
- Preview locally: the loop in full.