Skip to main content
A Druks app declares its screens in Python. It returns typed Page objects from pages.py. The shared dashboard renders them. It also resolves navigation, runs actions, and refreshes followed regions. Most apps write no JavaScript. An app that needs full control of its interface still ships an ESM frontend. See frontends for that path. This page is the V1 contract. It gives the exact Python fields and the exact JSON for every public model. The backend, the renderer, and the gallery implement this page. Nothing else names these shapes.

Terms

The contract uses eight terms. Each one has one meaning.

Import surface

Every public name comes from druks.ui:
druks.ui imports no app. It is a platform namespace like druks.workflows. It exports exactly these names:

Declare pages

Pages live in pages.py. Druks discovers that module the way it discovers routes.py.
peers and peer are two top-level pages. peer_history is a child of peer. A page path can hold as many segments as the app needs. The one-level rule counts child declarations, not path segments. A page function needs no return annotation. The route names Page as its response model, so writing it again on every function says nothing. Each page has a name. The name is the function name. Link and App.navigation reference a page by that name. Each page has a label. Druks derives the label from the name: underscores become spaces. peer_history becomes “peer history”. Pass label= to override it:

Rules

Druks checks these at boot. A break raises with the app name and the exact cause.
  • Exactly one page declares /. That page is the landing page.
  • @page declares a top-level page.
  • @parent.child declares a child page.
  • One child level is allowed. A child of a child is a boot error.
  • A child declaration can live in another module.
  • A child inherits every parameter of its parent route.
  • An extra child parameter must come from the relative child path.
  • A page function takes one parameter for each parameter of its route, and no others. Each one must be callable by name, so a positional-only or variadic parameter is a boot error.
  • A catch-all is the last segment of its route. A catch-all anywhere else would swallow every route under it, so it is a boot error.
  • A static child is a tab. The parent is the first tab.
  • A parameterized child is not a tab. A Link reaches it.
  • A parameterized detail page shows a link back to its parent.
  • Tab order is the parent, then the static children in declaration order.
  • Declaration order does not control route matching.
  • Two pages in one app with the same name are a boot error. Link and App.navigation both address a page by name.
The app roster at GET /api/apps carries the page table. The shell resolves a Link, a tab strip, and a parent link against it:
The table arrives in route-match order, so a renderer that mounts a route for each entry in turn gives a literal segment its win over a parameter. order is the page’s place in the app’s declarations, which is the order its tabs show in. Route matching sorts the table, so that order survives only here. path is the shell path, not the API path. The shell fills each {name} placeholder from the Link arguments and percent-encodes the value. arguments values are strings; FastAPI coerces each one to the type the page declares. A Link missing an argument reads as broken. The parent of a page is its parent entry when it has one. Otherwise it is the declared page whose path is the longest proper prefix of this page’s path, and the landing page when no other page is a prefix. A parameterized detail page links back to that parent. App.navigation is a flat, ordered list of page names:
Each entry names a static top-level page. The shell shows the page label. A navigation entry declares no second label. These are boot errors:
  • A name that no page declares.
  • A parameterized page.
  • A child page.
The app roster at GET /api/apps carries the resolved pairs, so the shell needs no second read:
An app that ships an ESM frontend declares its own tabs inside that frontend. App.navigation names declared pages and nothing else.

Routes

Druks builds the complete page route table before it registers any route with FastAPI. It sorts the table so that matching is global and not declaration ordered. A path segment has one of three kinds. The sort key of a segment is its kind: Druks compares two paths segment by segment. A literal segment wins over a parameter. A parameter wins over a catch-all. The rule holds at every depth, for top-level pages and for child pages. So /peers/new always matches before /peers/{peer_id}, whichever one the app declares first. Two page paths have equivalent parameter shapes when they are equal after Druks replaces every parameter name with a placeholder. /{id} and /{slug} are equivalent. That is a boot error. No request could tell them apart.

The page API

Druks mounts one route for each page under the app’s namespace:
The landing page drops the trailing slash:
The endpoint is the page function. FastAPI validates each path parameter against the declared signature. A value the declared type rejects answers 422. The route sits behind the dashboard identity gate, like every other /api/<app> route. The shell reads a page at /<app><page path>. It calls the matching page API route. pages is a reserved segment under /api/<app>, with transcripts and uploads. A subject type with one of those names is a boot error. An app router whose prefix is one of them is a boot error. Without the check, FastAPI would hide one of the two by registration order.

Page purity

A page function is a pure read-side projection. A page function can:
  • read Druks state,
  • read the app’s own data,
  • read a projection,
  • read a read-only external source.
A page function cannot:
  • write data,
  • start or enqueue work,
  • publish an event,
  • answer a gate,
  • cause an external effect,
  • depend on mutable process state.
Druks reruns a page function on initial load, on an event, on reconnect, on a manual refresh, and on a retry. The call count and the call order are not guaranteed. Write the function so that a repeat call is free.

Liveness

A Page or a named region declares what it watches:
A named region is a Section with a name. The name must be unique in the page. follows= takes the subject the page watches. Druks reads subject.identity and fills subject_type and subject_id:
A run is always about a subject, and the subject is what the stream carries, so a region that watches a run follows that run’s subject. The page function has already read that subject to render the page. A Section that follows a subject must have a name. The shell replaces the region by name, so an unnamed one could never be replaced. Druks reuses the per-subject event stream that every app already gets:
There is no second streaming system. follows= also takes the subject class. The page or the region then watches every subject of that type, subject_id is empty, and the shell reads the board stream:
A page that shows many subjects is live this way. On a snapshot event the shell reads the page again. It takes the named region from the new page and replaces that region in full. It sends no block diffs. The shell keeps scroll position, focus, and unsubmitted form values outside the region. The shell owns the EventSource, the reconnect, the retry, and the stale-response protection. A response from an older read never replaces a newer one. A follows= on the Page itself replaces the whole page body.

Gates

GateControls declares only the run:
The shell derives everything else from the parked run: the questions, the options, the recommended choice, the context, the controls, the note, and the artifact.
  • The shell reads GET /api/gates/{run}.
  • The shell submits POST /api/gates/{run}/answer.
  • The answer echoes parkedAt unchanged. A stale parkedAt is rejected.
  • Both routes use the signed-in dashboard session through current_account.
GateControls is not an Action. It never calls /api/runs/{run}/resume. A GateControls block must sit inside a Page, or inside a named Section, that follows a subject. Druks rejects a GateControls block with no such ancestor when it builds the page. Without the follow, an answered gate would stay on screen. When the run resumes, the followed region refreshes and the controls go away. An Action names an app-local operation:
An action can collect values before it runs:
The shell shows the action as a control. When the action has fields, the shell collects the values before it runs the operation. Use a Form when field entry is the primary task of the page. Use an Action with fields when field entry is a detour from the page task. The operation is the operation_id of one of the app’s own routes:
The shell resolves the operation to its method and URL. The author writes no URL. Druks indexes every route the app mounts by its operation_id at boot. Two routes in one app with the same operation_id are a boot error, and so is one route answering two methods under it. An Action exists only once a page function has run, so the reference is checked when Druks builds the page — the earliest moment it exists. Two failures answer with the page-read error, and each one names the operation:
  • No route carries that operation_id.
  • The route is a GET. A GET route is a read. It can never be an action.
The route takes its values the way the shell sends them: path parameters and a flat JSON body, each value under its own name — Body(embed=True) or a model, no aliases, no query parameters. A route shaped otherwise answers 422 when the action runs. Two routes of one app cannot share an operation_id, and one route cannot answer two methods under it. Both are boot errors: an action names one operation and calls one method. refresh: "region" needs a region. An action that asks for one and sits in no named Section is an error when Druks builds the page. So an action target declares path parameters and a flat JSON body only:
The app roster at GET /api/apps carries the table the renderer resolves against. It lists the app’s non-GET operations only:
App code never reads that table.

Request shape

The shell builds one JSON object. It takes the action arguments first. Then it adds the values from Action.fields or the enclosing Form. A field name that repeats an argument name is an error when Druks builds the page. The shell fills the operation’s path parameters from that object. It sends every remaining key as the JSON request body. Authentication, authorization, and request identity stay on the platform route.

Results

refresh: "region" needs an owner. The owner is the nearest named Section that encloses the action. An action with no such ancestor and refresh: "region" is a page-build error. While the request runs, the shell shows a pending state and blocks a second submission of the same action. A 422 answer carries the platform validation envelope. The shell maps each error whose loc ends with a field name to that field. It shows the others as form errors. A Link navigates and never calls an operation:
A Link sets page or url, never both and never neither. Druks rejects a Link that sets neither or both when it builds the page. A page names a declared page of the same app, and arguments fills that page’s route parameters. The shell resolves both against the page table. It shows a Link it cannot resolve as broken and names the page it wanted, and the rest of the page still renders. Link and Action are different public types. Both are blocks, so a page can hold one directly. Every container’s controls holds either one.

How to read the model listings

Each listing below gives every field a model carries and the JSON it sends. Four rules hold for every one of them. A listing is the whole shape, not the class body. Some models share a base: every field carries a name and a label, and a section, a card and a column all carry blocks. A listing shows what the model carries, whichever class declares it. A sample fixes the keys and their values, never their order. A discriminator carries its own literal as its default. Text.block is Literal["text"] = "text". The author writes Text("…") and never passes the discriminator. Wire names are camelCase. alternative_text serializes as alternativeText. Schema does that for every model here. Druks coerces author input to the wire type. Three fields take a friendlier input than they store:

The three unions

A payload whose discriminator is not in its union fails validation. The shell shows an app-scoped error and names the block.

Blocks

Every block carries a block discriminator. A page fills the screen it is given, and each block decides what to do with the room. A table, an image gallery, Columns, a timeline and a metric row take the width. A fact list is as wide as its facts. A chart and a progress bar grow with the page and stop where more width stops helping them. Prose keeps a line length, measured against the type size rather than the screen, so it stays readable however wide the display is. A block whose one required value is the thing it shows — its words, its content, its identity — takes that value positionally, and every other value by keyword:
A container that holds one list takes that list positionally too:
Metrics, Facts, List, Timeline, Files, ImageGallery, Stack, Columns, and TableRow all read that way. A block that holds more than one thing names every argument: Card, Section, Table, Chart, and Cards. Cards is on that list because its empty is content, not decoration. So is Form: its required value is the action that sends it, not something it shows, so action= is spelled out.

Text

Markdown

The shell renders the markdown. It strips raw HTML.

Quote

Someone else’s words, kept as they arrived — a message, a reply, an answer. Line breaks survive, and nothing is read as markup. Text closes the breaks up into a paragraph, and Markdown would rewrite the text.

Section

A section’s controls belong to that section. The shell chooses where to show them. An action in blocks stays with the body content. They sit inside the section region, so a region refresh replaces the heading, the controls, and the body together.

Card

Cards

One card for each of a set of things.
The shell arranges the cards. It fits as many across as the screen takes, so Cards sets no geometry of its own. With no cards, the shell shows the title and empty in their place. With no cards and no empty, it shows nothing. Table reads the same way. empty takes an EmptyState, not a line of text, because an empty page usually has to say what to do next.

Callout

Divider

EmptyState

A link sets exactly one destination: page for another page of this app, url for outside, or subject for the subject’s own platform page — the full story of what druks did about it, which no app page recomposes:

Action

arguments keys are the operation’s own parameter names. Druks serializes them unchanged. A route parameter keeps its Python spelling on the wire. An action with fields stays an action in the declaration and on the wire. The shell decides how to collect the fields. An action cannot set both fields and confirm. When a page function builds this action, Druks refuses it. Each option asks the operator before the action runs.

Form

Use a Form when the page exists to collect the values. Use an Action with fields when this collection is a short detour from another task. A form keeps all its fields on the form. When a form action also has fields, Druks refuses the form when the page function builds it.

Timeline

at must name an offset, so items from different sources order against each other. Druks orders the items oldest first, where the stamps keep their full precision, and items that share a moment keep their declared order. A snapshot arrives in the order it is shown.

Progress

completed is a meaningful optional value. It carries three shapes: Giving both completed and steps is a validation error, and so is a completed above total or a value that is not a number.
A determinate or indeterminate shape reads as text and as an ARIA progress bar, so a screen reader gets the same state as the eye. Staged work has no measurable value, so it reads as a named group in which each step announces its own state.

Image

alternative_text is required, and text that is only whitespace is a validation error. When the image does not load, the shell shows the alternative text in its place.

Files

files accepts druks.files.File objects. Druks reads the name, media type, and size from the file record.
The shell previews an image. Every file gets a download through /api/files/{id}, which keeps the platform’s own authentication.

GateControls

The name is GateControls. druks.ui has no type named Gate. Gate is the workflow-side declaration in druks.workflows.

Chart

Every series must have one point for each category, and every point must be a number JSON can carry. The shell renders a table of the same numbers, titled with the chart’s own title and value label, for a screen reader.

ImageGallery

Metrics

Facts

Facts is the label-and-value list. The contract has no type named KeyValue.

Table

Every row must have one cell for each column. With no rows the shell shows empty_text, and nothing of its own. A wide table scrolls inside its own container, on a narrow screen as well: a stacked row would lose the header each cell belongs to. A row’s detail is the sentence it has no room for — the failure behind a status, the reason behind a verdict. The shell keeps it folded and the reader opens it, so twenty rows that stopped for one reason do not cost twenty page loads to find that out. It is text, not blocks.

List

Stack

Columns

Each child block is one column. The columns share the width. On a narrow screen they stack. Stack and Columns hold every V1 block, including each other. They have no special cases. Columns is geometry. Each child is one column, however many there are. For a collection of cards, use Cards: the shell chooses how many fit across.

Values

Every value carries a value discriminator. A value renders the same way in Facts, Metrics, List, and Table.

TextValue

link is how a table cell, a fact, or a list item reaches another page. description says what the thing is, on a quieter second line under what it is called, so a name needs no column of its own to explain it.

NumberValue

A tone colours a count that is itself the warning — how many need attention. A figure that is only a figure stays neutral.

StatusValue

The app writes the word. The tone selects the presentation. The contract has no type named Status. active reads as work in flight, so a settled fact takes another tone.

TimeValue

when must name an offset. The shell shows a relative time, and the exact time in the title attribute.

Fields

Every field carries a field discriminator, name, label, help_text, and is_required. name is the key the shell sends. UploadField, MultiUploadField, and SecretField have no starting value.

TextField

TextAreaField

NumberField

SelectField

MultiSelectField

RadioField

CheckboxField

UploadField

One file, and no starting value: nothing the server sends could put a file back into a file input. accept goes straight into the file picker’s own filter, in its own syntax — "image/*", ".csv,.tsv". It narrows what the operator can pick. It is not a promise about the bytes, and the platform does not check it. An operation that needs certainty opens the file and looks. On submit the shell sends the bytes to POST /api/<app>/uploads, which stores them and answers with a FileSummary. The shell then submits id as the field’s value, so the operation takes a plain string:
A FileField column takes the id and keeps a real foreign key, so an id naming no file is refused by the database rather than stored. The upload is filed under the app whose page holds the form and under the operator who sent it, both taken from the request rather than from the client. A file over the platform’s upload cap is refused, and the shell puts the refusal on that field. A file whose form is never submitted stays stored with nothing pointing at it.

MultiUploadField

MultiUploadField lets the operator select several files in one native file dialog. The shell shows their names and a Remove button for each file before submit. Another selection replaces the list. The field has no value, so a page cannot put stored files into the picker. accept has the same file-dialog filter syntax as UploadField. It does not validate the bytes. is_required=True requires at least one selected file. An empty optional field submits []. The field works in Action.fields and in Form.fields. For example, this action collects photos before it calls the operation:
On submit, the shell uploads each file through POST /api/<app>/uploads. The existing MAX_UPLOAD_BYTES limit applies to each file. After all uploads succeed, the shell sends {"photos": ["file-id-1", "file-id-2"]} to the operation. Its parameter is photos: Annotated[list[str], Body(embed=True)]. If an upload fails, the shell marks the field in error and does not call the operation. It keeps the selected files for correction or another attempt. Files from successful uploads remain stored even if a later upload fails. After a successful submit, the shell clears the selection.

SecretField

One secret the operator hands over: a token, a key. It has no value. A file input cannot be seeded, and a secret must not be. A field with nowhere to put one cannot send a stored secret back to the browser. The shell masks it and keeps it from the browser’s password managers. A successful submit leaves it empty. Masking protects the screen. It does not protect the stored secret. A refusal never repeats the server’s words on a secret field. The shell shows a fixed line, because a validation message can carry the submitted value back. It shows a fixed line on the form too, for a refusal that names no field on screen. Every other field keeps the server’s own words.

Page and Follows

A page’s controls belong to that page. The shell chooses where to show them. An action in blocks stays with the body content. Page, Section, Card and EmptyState all take controls the same way: a list of Action and Link, in the order the app wants them read. An Action calls one of the app’s operations; a Link navigates. Both are things an operator presses, so they share the row.

Errors

The shell keeps a failure inside the app surface. It never breaks the dashboard.

Demand-pulled

These are agreed, named, and not built for V1. Druks adds each one when an app needs it:
  • MoneyValue
  • PercentValue
  • DurationValue
  • date and time input fields

Not in V1

V1 has no Tabs block, no accordion, no inline reveal form, and no general client-state API. A table row folds its detail away. An action can collect fields before it runs. In both cases, the shell owns the interface. Static child pages already give tabs, and the URL holds the current one. An app that needs a control the contract does not have ships an ESM frontend.