Skip to content

Module admin UI

A module’s admin UI is declarative. resources declares the data, admin declares the screens, and functions with "trigger": "admin" back the interactive parts. The dashboard renders it — a module never ships UI code. See Module manifest for the rest of the package.

Everything on this page requires "palette": 2. With palette 1 the publish endpoint rejects the newer field types, the field rules, the timeline/track/results layouts, the table/chart/ alert/results widgets, UI hooks and detail children.

"admin": {
"palette": 2,
"title": "UI gallery",
"icon": "layout-dashboard",
"order": 90,
"pages": [],
"dashboard": [],
"player": []
}
KeyMeaning
palette2.
titleModule name in the sidebar and page subtitles.
iconLucide icon name, e.g. sword, sliders-horizontal, layout-dashboard.
orderSort order inside the MODULES section of the sidebar.
pagesScreens, in sidebar order.
dashboardBlocks on the project dashboard. Only stat, action-button, table, chart, alert.
playerBlocks on the player detail page. A resource-table there must be player-scoped.

samples sits next to admin at the top level of the manifest.

Every field in resources.*.fields and in a widget input accepts:

KeyApplies toMeaning
typeallRequired, one of the types below.
requiredallMarks the editor with * and rejects empty values.
label, description, placeholderallEditor label, help text, placeholder.
defaultallFilled on create and for absent fields on update, recursively into objects and list items.
visibleIffields of an object, including the top level{ "field": "kind", "eq": "json" } or { "field": "kind", "in": ["a", "b"] }. Exactly one of eq/in, the named field must be a sibling. A hidden field is not required and its value is dropped on save.
uniquetop-level string, number, enum, refUnique per resource.
min, maxnumber value, string length, list item countInclusive, non-negative for string and list.
stepnumberPositive; the value must be a multiple of it, counted from min (or 0).
patternstringRE2 regular expression the whole value must match.
slugstring{ "from": "name" }; implies unique and ^[a-z0-9_]+$.
display, summary, indexLabellistSee list below.

The dashboard applies the rules while editing; the backend enforces them on every row write, from REST, from resources.create/update inside a module, and when a sample is created. Cross-field and business rules belong in the validate hook.

Text input. min/max bound the length, pattern constrains the value.

string field

{ "type": "string", "required": true, "slug": { "from": "name" } }

Filled from the source field while the row is new, then locked.

slug field

min, max, step.

number field

bool field

enum is required. The list page shows the value as a badge and can filter by it.

enum field

ISO-8601 string, date and time picker.

datetime field

Duration string such as 24h: a number plus a unit.

duration field

Write and Preview tabs.

markdown field

markdown preview

URL string. Upload (PNG, JPEG, WebP, GIF up to 2 MB) or paste a URL. A field named in detail.image moves to the side column of the detail page.

image field

Free-form object in a JSON editor. The example below also uses visibleIf, so it only shows up when kind is json.

json field

{ "type": "object", "fields": {
"mode": { "type": "enum", "enum": ["default", "custom"] },
"threshold": { "type": "number", "visibleIf": { "field": "mode", "eq": "custom" } } } }

object field

items is required and can be any field, including object. Items drag to reorder and collapse.

KeyMeaning
itemsThe item field.
min, maxItem count bounds; max disables Add.
summaryA field of the object items shown in the collapsed header.
indexLabelnumber (#1) or rank (1st).
displaytags, string items only.

rich list

With "display": "tags" the editor becomes a creatable multi-select whose suggestions come from the values other rows use for that field.

tags field

tags field open

{ "type": "ref", "ref": "items", "refKey": "slug" }

ref is the target resource, refKey the field stored instead of the row id. The target row lists incoming references under Used in.

ref field

Stored as { "currencies": {}, "items": {}, "counters": {} }. ref/refKey point the item picker at a resource.

reward field

{ "currency": "gems", "amount": 499 }, amount is non-negative.

price field

{ "counter": "matches_won", "operator": "gte", "value": 10 }. Operators: gte, gt, eq, lte, lt.

condition field

Typed value: { "type": "string" | "int" | "float" | "bool" | "json", "value": ... }.

value field

{ "type": "map", "keys": { "kind": "currency" }, "values": { "type": "number", "min": 0 } }

keys.kind is counter, currency, item or ref (with keys.ref and an optional keys.refKey). values is any field.

map field

{ "type": "ranges", "value": { "type": "reward", "ref": "items", "refKey": "slug" } }

Stored as [{ "from": 1, "to": 3, "value": ... }]. The backend rejects from > to; overlapping ranges are your module’s business rule.

ranges field

A rule builder with AND/OR groups and an Estimate button that counts matching players.

Attributes: player.region, player.lang, player.payer, player.platform, player.appVersion, player.daysSinceRegister, player.daysSinceLastSeen, counter.<slug>, wallet.<currency> and bucket (needs a salt). Operators: eq, neq, gt, gte, lt, lte, in, notIn, between. An empty segment matches every player.

segment field

{ "salt": "winter_test", "variants": [{ "key": "a", "from": 0, "to": 49 }, { "key": "b", "from": 50, "to": 99 }] }

Percentage sliders. Ranges outside 0–99, overlaps and totals other than 100 are rejected. A/B tests are not a separate entity: a variant is a row whose segment includes a bucket range.

split field

{ "start": "2026-12-01T00:00:00Z", "end": "2026-12-15T00:00:00Z",
"repeat": { "every": "weekly", "days": ["sat", "sun"], "from": "10:00", "to": "22:00" } }

end and repeat are optional, times are UTC. An absent schedule means “no time limit”. Row listing accepts <field>_active=true, and scheduled rows show up on the dashboard Calendar.

schedule field

{ "slug": "showcase", "title": "Showcase", "resource": "showcase", "layout": "table",
"columns": ["name", "kind", "count", "enabled", "window"], "sort": "name",
"filters": ["kind", "enabled", "tags", "itemRef"],
"bulk": [{ "title": "Grant bonus", "function": "grantBonus", "confirm": "Grant a bonus?" }],
"detail": {}, "hooks": {} }
KeyNotes
slugRequired, unique per module; settings is reserved.
titlePage title and sidebar entry.
resource / widgetsExactly one of the two. A widgets page has no rows and no hooks.
layouttable (default), cards, timeline, track, results.
columnsTable columns.
sortField name; -field sorts descending.
filtersOnly enum, bool, list and ref fields.
cardcards layout: image (an image field), title, subtitle, badges.
schedule, colortimeline only; schedule is required and must be a schedule field, color an enum field.
orderField, threshold, lanestrack only; orderField is a required number field.
score, order, playerresults only.
bulk{ title, function, confirm? }, the function needs trigger admin.
detail, hooksBelow.

Search, filters, sortable columns, row selection, per-row menu.

table layout

Selecting rows opens the bulk bar with the module’s bulk actions plus Delete.

bulk bar

{ "layout": "cards", "card": { "image": "icon", "title": "name", "subtitle": "slug", "badges": ["kind", "tier"] } }

cards layout

{ "layout": "timeline", "schedule": "window", "color": "kind" }

Week or month scale with a “today” marker, one bar per row. Clicking a bar opens the row.

timeline layout

{ "layout": "track", "orderField": "level", "threshold": "xp",
"lanes": [{ "title": "Free", "field": "freeReward" }, { "title": "Premium", "field": "premiumReward" }] }

Rows become columns of a ladder ordered by orderField. threshold is editable inline, each lane shows one field. Add, reorder and inline edit are supported.

track layout

{ "layout": "results", "score": "score", "order": "desc", "player": true }

Ranked list with badges for the top three and links to the players. order is asc, desc or { "from": "<field of the parent row>" } (detail only). player: true needs a player-scoped resource.

results layout

A page with widgets instead of resource renders widgets only.

widget page

"detail": {
"key": "slug",
"image": "icon",
"sections": [{ "title": "General", "fields": ["name", "slug", "kind"] }],
"widgets": [],
"children": [],
"hooks": {}
}
KeyNotes
keyField used in the URL instead of the row id. Must be unique or a slug field.
imageAn image field, rendered in the side column.
sections{ title, fields }; a two-column grid, wide editors span both columns.
widgetsRendered under the form. results widgets and match are allowed only here.
childrenEditable child rows, below.
hooksBelow.

The side column holds the image, the player picker for player-scoped rows, row metadata and Used in.

detail side column

A detail page with a preview panel and an options-driven select open:

detail page

Creating a row from a list page opens the same editors in a drawer.

row drawer

"children": [
{ "title": "Levels", "resource": "levels", "match": { "showcase": "$row.slug" },
"layout": "track", "orderField": "level", "threshold": "xp",
"lanes": [{ "title": "Free", "field": "freeReward" }, { "title": "Premium", "field": "premiumReward" }] },
{ "title": "Notes", "resource": "notes", "match": { "showcase": "$row.slug" }, "layout": "list" }
]
  • title, resource and match are required. match maps a child field to $row.id or $row.<field>; new child rows get those values filled in.
  • layout is table (default), list or track. track requires orderField.
  • Children accept validate, preview and options hooks, but not save.
  • Without a save hook the dashboard writes the row and its children in one transaction, at most 100 child operations, all or nothing.

track children

list children

"hooks": {
"validate": "validate",
"preview": "preview",
"options": { "tier": "tierOptions", "objectives.*.counter": "counterOptions" },
"save": "saveShowcase"
}

Every name must be a declared function with trigger admin. validate, preview and options run as a dry run — reads work, writes are rolled back. save runs committed, so one hook can write a row and its children in a single transaction.

HookCalledArgumentsReturns
validatebefore save and 500 ms after the last edit{ values, rowId, children }{ errors: { "<path>": "<message>" } }, paths use /, e.g. levels/3/xp
previewon open and 500 ms after the last edit{ values, rowId, children }{ widgets: [...] }, rendered in the side panel
optionswhen the select opens, and 300 ms after the search text changes{ field, search, values }{ options: [{ value, label, icon? }] }
saveon save, replaces the default write{ values, rowId, children }{ id } or a business error; invalid_parameters with { errors } maps back to fields

options keys are field paths where * matches a list index; the hook receives the concrete path in field. It applies to string, enum, ref and number fields.

An error returned by validate:

validate hook

A select filled by options:

options hook

The panel preview renders (stat, table, chart and alert widgets):

preview hook

TypeAllowed onKeysFunction result
statpage, detail, dashboard, playertitle, function, input{ value, hint? } or a scalar
tablepage, detail, dashboard, playertitle, function{ title?, columns, rows }
chartpage, detail, dashboard, playertitle, function{ title?, kind: "line" or "bar", series: [{ name, points: [{ x, y }] }] }
alertpage, detail, dashboard, playertitle, function{ level: "info", "warning" or "error", text }
markdownpage, detail, playertitle, bodystatic
tabspage, detail, playertitle, tabs: [{ title, widgets }]
action-buttonpage, detail, dashboard, playertitle, function, confirm, inputanything, shown as a toast
resource-tablepage, detail, playerresource, columns, sort, match
resultsdetail onlyresource, score, order, player, match

match ({ "<target field>": "<field of the detail row>" }) works only inside a detail. input is a nested field schema rendered as a form with the editors above. Every function must be declared with trigger admin.

stat widget

table widget

chart widget

alert widget

markdown widget

tabs widget

action button

An action-button with input opens a form modal:

action button modal

Dashboard blocks:

dashboard

Player page blocks:

player page

"samples": [
{ "key": "flash_sale", "title": "Flash sale",
"description": "48-hour offer: 100 gems for 500 gold, once per player",
"rows": { "offers": [{ "name": "Flash sale", "slug": "flash_sale",
"schedule": { "start": "$now", "end": "$now+2d" } }] } }
]

Sample rows are validated at publish against the resource schemas. Placeholders: $now and $now+<n>d|h; refs use the target’s refKey value. Creating a sample writes every row in one transaction, in the order the resources are listed. An empty page offers the module’s samples:

samples

Every module also gets the reserved Settings page: config schema form, enable or disable, update, eject, uninstall, download source.

module settings

  • admin.palette other than 1 or 2, or palette-2 features with palette 1.
  • A page slug that is empty, duplicated or settings.
  • A page with both or neither of resource and widgets, or hooks on a widgets page.
  • A missing resource or field, or a field of the wrong type: filters outside enum/bool/list/ ref, card.image not an image, schedule not a schedule, orderField, threshold and score not number, color not an enum.
  • schedule/color without timeline, threshold/lanes without track, score/order/player without results, match or a results widget outside a detail.
  • detail.key on a field that is neither unique nor a slug.
  • A child block without title, resource or match, a match source that is not $row.id or $row.<field>, a track child without orderField, or a child with a save hook.
  • A hook, bulk action or widget function that is missing or does not have trigger admin.
  • player: true or a player resource-table on a resource that is not player-scoped.