Cross-app lookups
lookup is the app saying: other apps' admin UIs may resolve my ids to this label, search me by these columns, and see these extras.
"lookup": {
"label": "serial",
"search": ["serial"],
"columns": ["status", "sold_at"]
}
| Field | Required | Description |
|---|---|---|
label | Yes | A declared text column rendered as the human label. Must be text — the label is shown to a human and searched case-insensitively. |
search | No | Declared text columns the lookup searches. Defaults to [label]. |
columns | No | Extra declared scalar columns returned alongside id and label. |
Requires a uuid id column.
Why it exists
An app stores another app's identifier as a plain uuid — foreign keys stop at the app boundary. But a form asking an operator to type a product's UUID is not a form anybody can use. The relation field in a cockpit.json form needs to show names and let the operator search them.
lookup is what makes that possible without either app knowing about the other:
{ "name": "product_id", "label": { "en": "Product" }, "type": "relation",
"required": true, "relation": { "entity": "products", "label": "name" } }
The products app declared a lookup on its products entity; your form resolves ids through it. You declared nothing but the field.
This declaration is the privacy boundary
lookup is the only thing that ever leaves the app through the cross-app lookup API. The platform grants access to exactly these columns, and nothing else on the table is reachable from another app — not by guessing a column name, not by a filter, not at all.
That is why the allowed types are narrow. jsonb, json, bytea and the array types are deliberately not permitted in columns, because those are what would smuggle a whole document through what is supposed to be a label API.
So treat the block as a published interface, and think about it the way you would think about a public response schema:
- Do not put anything in
labelthat a neighbouring app's operator should not see. A customer app's contact label is a person's name, which is exactly what a picker needs — and also exactly the sort of thing worth a moment's thought before it appears in an unrelated app's dropdown. - Keep
columnsto what disambiguates. A status, a code, a date — the fields that tell two similar rows apart in a list. Not the whole record. - Omit
lookupentirely on an entity nobody should pick from. An audit log, a job queue, an internal mapping table: nolookup, no exposure.
What it is not
It is not an API for your own app. Inside your function you read your tables with the typed client, which sees every column.
It is not how one app reads another app's data. That is a capability grant and a call through the gateway. lookup resolves an id to a label for a UI; a capability answers a question.
It is not a search index. search names the columns the picker's type-ahead matches, which is a narrow substring search over a handful of text columns. Full-text search over your entity is search.json.
Next steps
- Relationships — why the id is a plain column in the first place.
- Projections —
search.jsonandanalytics.json. - How the Cockpit renders — where a
relationfield appears. - Calling another app — reading the record itself.