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>, like transcripts. A subject type named pages is a boot error. An app router whose prefix is /pages 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:
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 the submitted field values. 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. Card.actions and EmptyState.actions hold either one.

How to read the model listings

Each listing below gives the exact Python fields and the exact JSON. Three rules hold for every one of them. 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 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, and Chart. So does 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.

Section

Card

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.

Form

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.

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.

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.

NumberValue

StatusValue

The app writes the word. The tone selects the presentation. The contract has no type named Status.

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. Every field has name, label, help_text, is_required, and value. name is the key the shell sends.

TextField

TextAreaField

NumberField

SelectField

MultiSelectField

RadioField

CheckboxField

Page and Follows

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 expandable table row, no modal, no inline reveal form, and no general client-state API. 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.