Skip to content

Surface Schema

This document defines every field in surface.json — the module’s view contract. Module authors read this to configure how their module appears in the REPL. Core reads surface.json at startup; no module process is required.


surface.json sits alongside manifest.json in the module directory. Its absence means the module is a util module — present in the capability surface but declaring no surface. Its presence gives the module one or more rows on the home page (REPL face today) — one per declared surface, each opening its own survey page.

A module may declare multiple surfaces — each surface is a distinct subject type (e.g., a fail2ban module may have a jail surface and a ban surface). Each surface becomes its own row on the home page.

Core itself has an embedded surface.json (in sdk/system) declaring three surfaces: module (the Inventory page), job (the Jobs page), and message (the Messages page), reached from the home page’s exit row. The job surface also drills into a job’s recorded message exchanges.


{
"surfaces": [
{ "subject": "...", "survey": { ... }, "focus": { ... } },
{ "subject": "...", "survey": { ... }, "focus": { ... } }
]
}

The surfaces array is ordered. The first surface keeps the module’s bare short name as its identity; each subsequent surface is suffixed with its subject (name:subject).


FieldTypeRequiredDescription
subjectstringyesSubject type this surface manages. Unique within the module. Keys the surface’s home-page row and survey page. Examples: domain, jail, ban, database, service, module, job.
namestringnoDisplay label override. Module surfaces normally omit this and inherit the module short name. Core surfaces (which have no enclosing manifest) declare their own.
descstringnoOne-line description of this surface. Used in operator help.
surveyobjectyesSurvey (list view) configuration. See below.
focusobjectyesFocus (detail view) configuration. See below.
drillsarraynoNested child surfaces reachable from this surface’s rows. Drill children never appear on the home page — position in the tree determines visibility. See Drills (nested surfaces) below.

There is no refresh field. Survey freshness is not configurable: faces always re-read live state on survey page entry, on return from focus, and on explicit refresh. Facet chip toggles re-filter the cached subject list locally — no round-trip to the module.


FieldTypeRequiredDescription
entrystringyesSurvey capability name. Convention: {module-short}.{subject}.survey. Must be declared in manifest.json with async: false.
columnsarraynoOrdered column declarations for the survey table. The name column is always implicit and first — do not declare it here.
facetsarraynoFacet chip declarations. When absent, no facet row is shown.
FieldTypeRequiredDescription
idstringyesColumn identifier. Must match the key in survey response subjects[].columns. Lowercase, no spaces.
labelstringyesColumn header text.
widthintegernoWidth cap in terminal character units. When absent or 0, core auto-sizes to max(len(label), max(len(value))) across all subjects. Declare only for unbounded free-text fields.
alignstringno"left" (default) or "right". Use "right" for numeric columns.
fromstringnoCapability name that fills this column’s cells — a whole-column data source, fired once per distinct from after the survey resolves (not once per row). Absent ⇒ the value comes inline from the survey response (subjects[].columns). Columns sharing one from fill together. Returns the Cell-Fill Capability Response Contract.
FieldTypeRequiredDescription
labelstringyesChip label. Unicode symbols allowed ("● active").
valuestringyesValue the module uses to tag matching subjects in its facets array in the survey response.

FieldTypeRequiredDescription
entrystringyesFocus capability name. Convention: {module-short}.{subject}.focus. Must be declared in manifest.json with async: false.

The focus handler computes and returns available actions per subject; a read-only module returns an empty actions array. There is no actions field in surface.json.


A surface may declare child surfaces in a drills[] array. Each drill defines a subject relationship reachable from a parent surface’s rows — a database surface drills into module and user surfaces, each scoped to the selected database row.

Only top-level surfaces — entries in the surfaces[] array — appear as rows on the home page. Drill children are reached only by selecting a row in a parent surface and choosing a drill chip. They are never home roots. Nesting is the sole visibility discriminant: no separate flag required.

A drill child has the same shape as a surface object, plus a label field:

FieldTypeRequiredDescription
subjectstringyesSubject type this drill manages. Unique within its parent.
labelstringnoChip label rendered on the parent surface’s rows as the drill entry. Defaults to subject when absent.
surveyobjectyesSurvey configuration. Same shape as a top-level surface survey.
focusobjectyesFocus configuration. Same shape as a top-level surface focus.
drillsarraynoGrandchild drill surfaces — recursively same shape. Depth is not capped by core.

When the operator drills from a parent row into a child surface, core passes the selected row’s opaque id as a scope argument to the child’s survey capability. The survey receives:

{ "scope": "<parent-row-id>" }

The child survey reads scope, queries only rows belonging to that parent, and returns its standard SurveyResponse. Core does not interpret scope — it is opaque to core and meaningful only to the module that minted the parent id.

At deeper nesting levels, scope always contains the immediately selected parent row’s id. Lineage to higher ancestors is encoded inside the id strings the module mints — for example prod/sale where prod is the database id and sale is the module technical name. A single opaque string carries the full context without core needing an ancestor stack.


Each surface’s survey.entry and focus.entry point to capabilities declared in manifest.json:

{module-short}.{subject}.survey
{module-short}.{subject}.focus

For suctl-mod-fail2ban with a jail surface: fail2ban.jail.survey / fail2ban.jail.focus. For suctl-mod-fail2ban with a ban surface: fail2ban.ban.survey / fail2ban.ban.focus.

These names are a recommended convention, not a parsed rule — survey.entry and focus.entry may name any capability the module declares. The convention keeps names predictable across faces and tooling. The one hard requirement is that the capability each entry names is declared in manifest.json and implemented: a face invokes it by the name in entry, and a name that is not in the module’s active capability surface fails at invoke time.


  • subject is required on every surface and every drill child. Duplicate subjects within the same parent are a hard error.
  • survey.entry and focus.entry are required on every surface and every drill child.
  • All entry capabilities must be declared in manifest.json and implemented; a face invokes them by the name in entry, and a name not in the active capability surface fails at invoke time.
  • Columns declared in surface.json must be present in every subject returned by the survey response (validated by modtest conformance probes).
  • A drill child’s subject must be unique within its parent’s drills[] array. It need not be globally unique across the module’s top-level surfaces.
  • Cycles are structurally impossible — the schema is a tree and a child cannot reference a parent.
  • A module without surface.json is valid — it is a util module declaring no surface.

{
"surfaces": [
{
"subject": "domain",
"survey": {
"entry": "nginx.domain.survey",
"columns": [
{ "id": "status", "label": "status" },
{ "id": "ssl", "label": "ssl" },
{ "id": "rps", "label": "req/s", "align": "right" }
],
"facets": [
{ "label": "● active", "value": "active" },
{ "label": "ssl expiring", "value": "ssl-warn" },
{ "label": "suspended", "value": "suspended" }
]
},
"focus": {
"entry": "nginx.domain.focus"
}
}
]
}
{
"surfaces": [
{
"subject": "jail",
"survey": {
"entry": "fail2ban.jail.survey",
"columns": [
{ "id": "status", "label": "status" },
{ "id": "banned", "label": "banned", "align": "right" },
{ "id": "maxretry", "label": "max", "align": "right" }
],
"facets": [
{ "label": "active", "value": "active" },
{ "label": "inactive", "value": "inactive" }
]
},
"focus": { "entry": "fail2ban.jail.focus" }
},
{
"subject": "ban",
"survey": {
"entry": "fail2ban.ban.survey",
"columns": [
{ "id": "ip", "label": "ip" },
{ "id": "jail", "label": "jail" },
{ "id": "banned_at", "label": "since", "align": "right" }
]
},
"focus": { "entry": "fail2ban.ban.focus" }
}
]
}
{
"surfaces": [
{
"subject": "database",
"survey": {
"entry": "odoo.database.survey",
"columns": []
},
"focus": { "entry": "odoo.database.focus" },
"drills": [
{
"subject": "module",
"label": "modules",
"survey": {
"entry": "odoo.module.survey",
"columns": [
{ "id": "summary", "label": "summary" },
{ "id": "version", "label": "version" },
{ "id": "status", "label": "status" }
],
"facets": [
{ "label": "installed", "value": "installed" },
{ "label": "not installed", "value": "uninstalled" }
]
},
"focus": { "entry": "odoo.module.focus" }
},
{
"subject": "user",
"label": "users",
"survey": {
"entry": "odoo.user.survey",
"columns": [
{ "id": "login", "label": "login" },
{ "id": "status", "label": "status" }
]
},
"focus": { "entry": "odoo.user.focus" }
}
]
}
]
}

odoo.module.survey and odoo.user.survey each receive { "scope": "<db-id>" } and return all rows belonging to that database, each tagged with applicable facets. The database surface appears on the home page; module and user are drill-only.

See: manifest schema for manifest.json field reference. REPL frame for the REPL frame contract.


The capability named in survey.entry MUST return a JSON object with this exact shape:

{
"total": 8,
"status_summary": "ssl: 2 expiring",
"subjects": [
{
"id": "client-a.com",
"name": "client-a.com",
"columns": {
"status": { "value": "active", "color": "green" },
"ssl": { "value": "82d", "color": "ok" },
"php": { "value": "8.2", "color": null },
"rps": { "value": "4.3", "color": "cyan" },
"uptime": { "value": "99.98%", "color": null }
},
"facets": ["active"],
"inline_actions": [
{ "capability": "nginx.ssl.renew", "label": "renew" },
{ "capability": "nginx.domain.suspend", "label": "suspend" }
]
}
]
}
FieldTypeRequiredDescription
totalintegeryesTotal subject count. Invariant across all filter changes — always the full unfiltered count. Used as the M in the filter badge (N of M) and in the bare-count summary fallback.
status_summarystringnoOne short phrase rendered in the in-box summary row (amber). Falls back to "%d {subject}" in dim when empty/omitted and total > 0. Always draws the row.
subjectsarrayyesAll subjects, each tagged with applicable facet values. May be empty.
subjects[].idstringyesOpaque unique identifier. Passed back as subject param in focus call. Never displayed.
subjects[].namestringyesDisplay name. Shown in the always-first name column.
subjects[].facetsarraynoFacet values that apply to this row. Each value must be declared in surface.json survey.facets. Core uses these tags to filter rows locally when the operator activates chips — the module is never called again on chip toggle. Omit or leave empty for rows that match no declared facet.
subjects[].columnsobjectyesKeyed by column id from survey.columns. Missing column IDs render as .
subjects[].columns[id].valuestringyesDisplay string. Must be pre-formatted and safe for terminal output.
subjects[].columns[id].colorstring or nullnoSemantic color token, or null/omitted for default text color. See Color Tokens below.
subjects[].inline_actionsarraynoQuick-action buttons rendered on the subject row in survey. Computed per row from live state at survey time — there is no static declaration in surface.json (mirrors focus actions). May be absent or empty.
subjects[].inline_actions[].capabilitystringyesCapability name to invoke when the operator activates this inline action.
subjects[].inline_actions[].labelstringyesShort button label rendered as [label] on the subject row.
subjects[].inline_actions[].destructivebooleannoIf true, the face routes through a CONFIRM step before invoking. Default: false. Button color is owned by the face (destructive vs safe) — there is no color field on actions.

The module MUST return all subjects regardless of which facets the operator has active. Each subject carries a facets array listing every declared facet value that applies to that row — classification is the module’s responsibility; filter selection is core’s. total reflects the full unfiltered count and must never change based on filter state.

Go SDK: Use sdk/surface typed structs instead of raw map[string]interface{}. surface.SurveyResponse, surface.Subject, surface.Column, surface.Action. Column shorthand: surface.Col(value, color). Import path: github.com/solutionsunity/suctl/suctl/sdk/surface.


The capability named in focus.entry MUST return a JSON object with this exact shape:

{
"id": "client-a.com",
"name": "client-a.com",
"sections": [
{
"title": "runtime state",
"fields": [
{ "label": "status", "value": "active", "color": "green", "full_width": false },
{ "label": "ssl", "value": "valid · 82d remaining", "color": "green", "full_width": false },
{ "label": "vhost config", "value": "/etc/nginx/…", "color": "dim", "full_width": true }
]
}
],
"actions": [
{ "capability": "nginx.domain.suspend", "label": "suspend", "destructive": false },
{ "capability": "nginx.ssl.renew", "label": "renew ssl", "destructive": false },
{ "capability": "nginx.domain.delete", "label": "delete", "destructive": true }
]
}
FieldTypeRequiredDescription
idstringyesThe subject id echoed back from the focus call param.
namestringyesDisplay name shown in the focus header breadcrumb.
sectionsarrayyesOrdered list of KV sections. May be empty.
sections[].titlestringyesSection heading. Rendered uppercase.
sections[].fieldsarrayyesOrdered list of field objects.
fields[].labelstringyesField label. Rendered in dim uppercase.
fields[].valuestringyesField value. Pre-formatted for terminal.
fields[].colorstring or nullnoSemantic color token, or null/omitted for default. See Color Tokens below.
fields[].full_widthbooleannoIf true, field spans both grid columns. Default: false.
actionsarrayyesAvailable actions for this subject’s current state. May be empty.
actions[].capabilitystringyesCapability name. Must be declared in capabilities. Frame invokes this on selection.
actions[].labelstringyesButton label. Short, verb-first.
actions[].destructivebooleannoIf true, the frame routes through a CONFIRM step before invoking the capability. Default: false. Button color is owned by the face (destructive vs safe) — there is no color field on actions.

The module computes actions from live subject state in its focus handler. The frame renders exactly what the handler returns — availability logic belongs in the handler, not in a manifest expression.

Go SDK: Use sdk/surface typed structs instead of raw map[string]interface{}. surface.FocusResponse, surface.Section, surface.Field, surface.Action. Import path: github.com/solutionsunity/suctl/suctl/sdk/surface.


A column may declare a from capability (see survey.columns) that fills its cells after the survey resolves. Unlike survey and focus, a from is a whole-column data source: the face requests it once per distinct from — not once per row — and it returns values for every row at once, keyed by the opaque subject id minted in the survey response. Columns that share a from are filled by the same call. Fan-out is therefore O(distinct froms), independent of row count.

The capability named in a column’s from MUST return a JSON object with this exact shape:

{
"rows": {
"prod": { "columns": { "modules": { "value": "123/866", "color": null }, "users": { "value": "5/12", "color": "green" } } },
"staging": { "columns": { "modules": { "value": "40/866", "color": null }, "users": { "value": "2/3", "color": "green" } } }
}
}
FieldTypeRequiredDescription
rowsobjectyesKeyed by the opaque subject id from the survey response. A row id absent from the map leaves that row’s cells at .
rows[id].columnsobjectyesKeyed by column id. Only the columns this from fills need appear; mirrors the survey subjects[].columns shape.
rows[id].columns[col].valuestringyesDisplay string. Pre-formatted and safe for terminal output.
rows[id].columns[col].colorstring or nullnoSemantic color token, or null/omitted for default text color. See Color Tokens below.

The face fires each from after the survey resolves and paints cells progressively as each call’s result correlates back — partial success never aborts the load; a column whose from stalls or fails leaves its cells at until the next refresh.

Go SDK: Build the row-keyed map with surface.Column (the same cell shape as a survey column); the response is {"rows": {<id>: {"columns": {<col>: surface.Column}}}}. Import path: github.com/solutionsunity/suctl/suctl/sdk/surface.

Python SDK: Use surface.rows({id: {col: surface.col(value, color)}}) — it wraps the row id → column id → cell map in the {"rows": …} envelope.


Standard vocabulary for coloring values in survey columns and focus fields. Module capabilities use these token strings — never raw terminal color codes or ANSI escape sequences.

TokenAliasesMeaningUse
"ok""green"Healthy, active, success, within limitsSSL valid, service running, test passed
"warn""amber"Warning, expiring soon, degraded, approaching limitSSL expiring, disk at 75%, service restarting
"err""red"Error, expired, failed, exceeded limitSSL expired, service stopped, config test failed
"alert"Critical — unavailable, activation failed. Rendered as bold red with a glyph prefix (✗ unavailable) so it reads via shape + colour, not background colour. Use only for states that must be impossible to miss.Module unavailable
"blue""info"Metric, count, numeric measurementRequests/s, port number, worker count
"purple""accent"Memory, resident resource usageMemory MB, swap
"cyan""tip"Throughput, rate, speedReq/s, MB/s, connections/s
"dim""ghost", "muted"Path, secondary info, placeholder, muted labelFile path, config path, version string
nullDefault text — no color emphasisGeneric string values

An unknown token renders in the neutral body color — never an error.

Color tokens apply to values only — survey columns and focus fields. Action buttons are not module-colored: the face derives their appearance from the destructive flag (danger vs safe) and selection state.