Skip to content

artifactr.ui

Forms, dashboards and mini apps in the chat, behind the forms extra: uv add "artifactr-ai[forms]" (ADR-0074, the guide). relayr bridges what people send into reflexr in relayr.ui (relayr's reference).

Forms, dashboards and mini apps in the chat (lattice RFC-0005), behind the forms extra.

An agent asks a form with ask_form, which pauses its run until a person answers, or makes a form artifact that takes submissions without a run waiting; it shows figures as a dashboard. Whatever a person sends is one artifactr command, as that person, checked here before it commits. Install artifactr-ai[forms] and import it: nothing else in artifactr imports it, so an application that shows no forms registers none of its types (ADR-0074). The names most applications need are here; the rest are in the modules:

  • artifactr.ui.schema: the form schema, a JSON Schema subset, its hints and its validator.
  • artifactr.ui.types: the artifact types and ask_form's request.
  • artifactr.ui.ids: a submission's derived id.
  • artifactr.ui.integrations: which integrations are on in a workspace.
  • artifactr.ui.checks: the checks for artifactr's Workspaces(checks=...).
  • artifactr.ui.agent: the agent's toolset.
  • artifactr.ui.wire: the JSON Schema clients generate their types from.

relayr bridges what people send into reflexr as ui:form_submitted, in relayr.ui.

artifactr.ui.schema

The form schema: a JSON Schema 2020-12 subset, its hints and its validator (ADR-0076, ADR-0077).

The form schema: a JSON Schema 2020-12 subset, its UI hints, and its validator.

A form is a FormSchema, which models write as JSON Schema and Pydantic emits, beside UiHints, which say how each property is shown and nothing else. The subset is an allow-list: a keyword it doesn't hold is refused, never ignored, and the refusal names it.

  • The root: type: "object", title, description, properties and required; additionalProperties is false whether or not it is written. A property's name is a letter or _ and up to 63 letters, digits, _ or -, and never __proto__, constructor or prototype.
  • A string: minLength, maxLength (at most MAX_LENGTH), a format of date, date-time, email or uri (http, https or mailto), and a default.
  • Choices: an enum of strings, or a oneOf of {const, title} for labelled choices, and a default.
  • A number or an integer: minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf and a default.
  • A boolean: a default.
  • An array, of choices only: items with an enum or a oneOf, uniqueItems, minItems, maxItems and a default.
  • Every property: title and description.

Bounds that no value meets, such as a minLength over the maxLength, are refused, and every number is within MAX_NUMBER either way.

A schema compiles to a Pydantic model once, kept by its JSON for the 256 schemas used last, and FormSchema.validate_values checks a submission's values with it. No third-party validator is involved: the formats and multipleOf are checked by this module's own small checks, which the TypeScript side repeats (ADR-0077).

MAX_NUMBER module-attribute

MAX_NUMBER: Final = 2 ** 53 - 1

The largest number, either way, a form or a dashboard holds: JavaScript's Number.MAX_SAFE_INTEGER, the largest past which two integers read alike, so portalr reads each as it was written.

MAX_PROPERTIES module-attribute

MAX_PROPERTIES: Final = 50

The most properties a form has.

MAX_CHOICES module-attribute

MAX_CHOICES: Final = 64

The most choices a property offers.

MAX_LENGTH module-attribute

MAX_LENGTH: Final = 10000

The longest string a form takes, in characters, whatever its maxLength says.

MAX_SCHEMA_BYTES module-attribute

MAX_SCHEMA_BYTES: Final = 32 * 1024

The largest form, its title, schema and hints as they are stored, in the UTF-8 bytes of their compact JSON (ADR-0077).

MAX_VALUES_BYTES module-attribute

MAX_VALUES_BYTES: Final = 64 * 1024

The largest submitted values, in the UTF-8 bytes of their compact JSON.

RESERVED_NAMES module-attribute

RESERVED_NAMES: Final = frozenset(
    {"__proto__", "constructor", "prototype"}
)

Property names refused although they fit the pattern: JavaScript objects treat them apart.

REFUSED_KEYWORDS module-attribute

REFUSED_KEYWORDS: Final = frozenset(
    {
        "$ref",
        "$defs",
        "pattern",
        "if",
        "then",
        "else",
        "allOf",
        "anyOf",
        "not",
    }
)

The JSON Schema keywords a model is most likely to write that the subset refuses by name.

Every other keyword outside the subset is refused too, as an extra input at its place; a nested object and format: "password" are refused with reasons of their own.

Format module-attribute

Format = Literal['date', 'date-time', 'email', 'uri']

The string formats the subset checks.

Widget module-attribute

Widget = Literal['textarea', 'select', 'checkbox']

How a property may ask to be shown in place of its default: a multi-line text field for a string, a select for choices that would be segments, and a checkbox for a boolean's switch.

Property module-attribute

Property = Annotated[
    Annotated[StringProperty, Tag("string")]
    | Annotated[ChoiceProperty, Tag("choice")]
    | Annotated[NumberProperty, Tag("number")]
    | Annotated[BooleanProperty, Tag("boolean")]
    | Annotated[ArrayProperty, Tag("array")],
    Discriminator(
        _kind,
        custom_error_type="property_type",
        custom_error_message='Each property is a "string", "number", "integer", "boolean" or "array", or choices with an enum or a oneOf',
    ),
    BeforeValidator(_not_nested),
]

One property of a form, told apart by its type, or as choices by enum or oneOf.

StringProperty pydantic-model

Bases: _Property

A string, on one line unless its hint asks for a text area.

Fields:

Validators:

  • _refuse
  • _default_is_a_value
  • _met

value_type

value_type() -> Any

A string of its lengths, at most MAX_LENGTH, in its format if it has one.

Choice pydantic-model

Bases: _Subset

A labelled choice: the value it submits, and the words shown for it.

Fields:

Validators:

  • _refuse

Choices pydantic-model

Bases: _Property

A choice among strings: an enum, or oneOf labelled choices; type is implied.

Fields:

Validators:

  • _refuse
  • _default_is_a_value
  • _one_way

offered property

offered: list[str]

The values it offers, in order.

value_type

value_type() -> Any

One of the values it offers.

ChoiceProperty pydantic-model

Bases: Choices

A property that takes one of its choices: segments up to five, else a select.

Fields:

Validators:

  • _refuse
  • _default_is_a_value
  • _one_way

NumberProperty pydantic-model

Bases: _Property

A number, or with type: "integer" a whole one.

Fields:

  • title (str | None)
  • description (str | None)
  • type (Literal['number', 'integer'])
  • minimum (Number | None)
  • maximum (Number | None)
  • exclusive_minimum (Number | None)
  • exclusive_maximum (Number | None)
  • multiple_of (Annotated[Number, AfterValidator(_positive)] | None)
  • default (Number | None)

Validators:

  • _refuse
  • _default_is_a_value
  • _met

value_type

value_type() -> Any

A number, or an int, within its bounds and a multiple of its multipleOf.

BooleanProperty pydantic-model

Bases: _Property

A yes or no, as a switch unless its hint asks for a checkbox.

Fields:

  • title (str | None)
  • description (str | None)
  • type (Literal['boolean'])
  • default (bool | None)

Validators:

  • _refuse
  • _default_is_a_value

value_type

value_type() -> Any

A bool.

ArrayProperty pydantic-model

Bases: _Property

Some of its items' choices, as checkboxes.

Fields:

Validators:

  • _refuse
  • _default_is_a_value
  • _met

value_type

value_type() -> Any

A list of its items' choices, as many as it allows, each once if they are unique.

FormSchema pydantic-model

Bases: _Subset

A form: a JSON Schema 2020-12 object in the subset, compiled once to check values.

Validating one refuses, by name, every keyword outside the subset, a property the subset doesn't hold, more than MAX_PROPERTIES properties, a name outside the pattern, a required name that isn't a property, a default its property doesn't take, and bounds no value meets. additionalProperties is false, written or not. A form with no properties is a button. Its size counts towards its form's, MAX_SCHEMA_BYTES.

Fields:

Validators:

  • _refuse
  • _checked

validate_values

validate_values(values: JsonValue) -> dict[str, JsonValue]

Check a submission's values against the form, and return them as they were given.

Raises:

Type Description
ValidationFailed

If they don't fit: each error's loc is the property's name (or empty, for values that aren't an object or are over MAX_VALUES_BYTES), as Pydantic writes it, so each can be shown under its field.

FieldHints pydantic-model

Bases: _Written

How one property is shown: its widget, a placeholder and help, all plain text.

Fields:

  • widget (Widget | None)
  • placeholder (str | None)
  • help (str | None)

Section pydantic-model

Bases: _Written

A titled group of properties, by name.

Fields:

UiHints pydantic-model

Bases: _Written

How a form is shown, and nothing else.

A hint names a component of the kit, or carries plain text: none carries a colour, a class, HTML or a URL. fields hints each property by name, order and sections arrange them, and submit_label names the form's button.

Fields:

check

check(schema: FormSchema) -> None

Check that every name is the schema's and every widget fits its property.

Raises:

Type Description
ValueError

If a hint names a property the schema hasn't, or asks for a widget that doesn't fit its property.

artifactr.ui.types

The form, submission, dashboard and app kinds, and ask_form's request.

The artifact types of forms and dashboards, and ask_form's request.

Register UI_TYPES with the application's own types, in Workspaces(types=...) and in ArtifactWorkspace(types=...):

  • Form, kind form: a FormSchema with its hints, and how many submissions it takes.
  • Submission, kind submission: the values a person sent to a form, or to an app, against the version they saw. Its id is derived (submission_id).
  • Dashboard, kind dashboard: a snapshot of figures in panels, true as_of a moment, which the agent writes.
  • App, kind app: a mini app's HTML and its state, which an agent writes through its own tools, and which runs sandboxed, with no network, where mini apps are on.

Their data's JSON Schema, with ask_form's request's, is artifactr.ui.wire's.

MAX_SERIES module-attribute

MAX_SERIES: Final = 3

The most series a bar or line chart holds: as many as portalr's charts have inks for.

MAX_TABLE_ROWS module-attribute

MAX_TABLE_ROWS: Final = 500

The most rows a table panel holds.

MAX_DASHBOARD_BYTES module-attribute

MAX_DASHBOARD_BYTES: Final = 256 * 1024

The largest dashboard, in the UTF-8 bytes of its compact JSON: every version is in the log, and relayr bridges each.

MAX_APP_HTML_BYTES module-attribute

MAX_APP_HTML_BYTES: Final = 256 * 1024

The largest app's HTML, in UTF-8 bytes.

MAX_APP_STATE_BYTES module-attribute

MAX_APP_STATE_BYTES: Final = 64 * 1024

The largest app's state, in the UTF-8 bytes of its compact JSON.

MAX_TITLE module-attribute

MAX_TITLE: Final = 200

The longest title of a form, a dashboard or an app, in characters.

MAX_SUMMARY module-attribute

MAX_SUMMARY: Final = 2000

The longest summary of an app, its text in place of what it runs, in characters.

ASK_FORM module-attribute

ASK_FORM: Final = 'ask_form'

The tool name of a paused request that asks a form, which portalr renders as one.

Submissions module-attribute

Submissions = Literal['once', 'per_person', 'many']

How many submissions a form takes: one in all, one a person, or any number.

Panel module-attribute

Panel = Annotated[
    StatPanel
    | ChartPanel
    | DonutPanel
    | TablePanel
    | TextPanel
    | FormPanel,
    Field(discriminator="type"),
]

One panel of a dashboard, told apart by its type.

UI_TYPES module-attribute

UI_TYPES: Final[tuple[type[Artifact], ...]] = (
    Form,
    Submission,
    Dashboard,
    App,
)

The forms layer's artifact types, for Workspaces(types=...).

AGENT_TYPES module-attribute

AGENT_TYPES: Final[tuple[type[Artifact], ...]] = (
    Form,
    Dashboard,
)

The types an agent makes and changes with ArtifactWorkspace's generic tools, for its types=...: not a submission, which is a person's, nor an app, which create_app and update_app make and change, while mini apps are on.

FormRequest pydantic-model

Bases: BaseModel

What ask_form asks: the arguments its paused request carries, as portalr reads them.

The request's args hold these as the model called the tool; the tool checked them before the run paused, and the answer is checked against schema.

Config:

  • frozen: True
  • extra: forbid
  • serialize_by_alias: True

Fields:

Validators:

  • _fits

Form pydantic-model

Bases: Artifact

A form that takes submissions without a run waiting.

It is for a poll, a sign-up, or a form that outlives its run. A submission names the version it was made against; an edit of the form makes a new one, so a submission made against an earlier one is refused as changed. Only its author, or whom the application's is_admin allows, changes its schema or closed.

Config:

  • serialize_by_alias: True

Fields:

Validators:

  • _fits

Submission pydantic-model

Bases: Artifact

The values a person sent to a form, or to an app, against the version they saw.

It answers one of the two, form_id or app_id. A many form's and an app's carry the nonce their id is derived from. People never edit one.

Fields:

Validators:

  • _answers_one

Key pydantic-model

Bases: _Panel

A key in a chart's rows or a table's, and the name it is shown by.

Fields:

Part pydantic-model

Bases: Key

One part of a whole, in a donut.

Fields:

  • key (str)
  • name (str)
  • value (Number)

StatPanel pydantic-model

Bases: _Panel

One figure: its label, its value, its unit, and how much it changed.

Fields:

  • type (Literal['stat'])
  • label (str)
  • value (Number | str)
  • unit (str | None)
  • change (Number | None)

ChartPanel pydantic-model

Bases: _Panel

A bar or line chart: rows indexed by index, and up to MAX_SERIES series.

Fields:

DonutPanel pydantic-model

Bases: _Panel

A whole in parts: the largest three in the chart's inks, the rest folded into "Other".

Fields:

TablePanel pydantic-model

Bases: _Panel

A table: its columns, and up to MAX_TABLE_ROWS rows keyed by them.

Fields:

TextPanel pydantic-model

Bases: _Panel

Markdown, which portalr renders without raw HTML.

Fields:

FormPanel pydantic-model

Bases: _Panel

A form artifact, by its id, shown to submit in place: a form with no fields is a button.

Fields:

Dashboard pydantic-model

Bases: Artifact

A snapshot of figures in panels, which the agent writes, true as_of a moment.

Each update is a new version; a dashboard is at most MAX_DASHBOARD_BYTES.

Fields:

  • title (_Title)
  • panels (list[Panel])
  • as_of (AwareDatetime)

Validators:

  • _fits

App pydantic-model

Bases: Artifact

A mini app: one HTML file an agent wrote, its text fallback, and its state.

portalr runs it sandboxed, on an origin of its own, with no network, while mini apps are on in the workspace; its summary is shown beside it, and in its place where it doesn't run. An agent writes it, and its HTML names nothing to load, which the agent's tools and the checks hold; a person changes only its state, through the app.

Fields:

  • title (_Title)
  • summary (str)
  • html (str)
  • state (dict[str, JsonValue])

Validators:

  • _fits

artifactr.ui.ids

A submission's derived id.

A submission's id, derived from what it answers, so a form's policy is artifactr's refusal.

artifactr refuses to create an artifact whose id exists, so a derived id makes a retried submit one artifact, and a form that takes one submission, or one a person, takes no second (ADR-0045's pattern, and relayr ADR-0019's). The check in artifactr.ui.checks recomputes it and refuses a submission whose id isn't its derivation. portalr derives the same ids, and its tests read the vectors in tests/vectors/submission_ids.json.

submission_id

submission_id(
    form_id: str,
    *,
    principal: str | None = None,
    nonce: str | None = None,
) -> str

Derive a submission's id: sub_ and 32 hex digits of a SHA-256 digest.

The digest is of the parts joined by a NUL, which no id holds, since artifactr refuses one: the policy and the form's id, once, for a form that takes one submission; with the person's principal, acting_for(actor).participant, per_person, for one a person; or with the nonce the client makes once per submit and keeps in the submission, many. The policy is a part, so a form whose policy changes takes submissions afresh, and no nonce passes for a principal. A submission to an app is derived as one to a many form, from the app's id.

Parameters:

Name Type Description Default
form_id str

The form's id, or the app's.

required
principal str | None

The submitter's principal, for a per_person form.

None
nonce str | None

The submit's nonce, for a many form or an app.

None

Raises:

Type Description
ValueError

If both a principal and a nonce are given.

artifactr.ui.integrations

Which integrations are on in a workspace (ADR-0080).

Which of the forms' integrations are on in a workspace, on lattice's shared port (ADR-0080).

Forms, dashboards and mini apps are lattice's integrations ui:forms, ui:dashboards and ui:mini-apps, declared in lattice-core's KNOWN, the last off by default. An application asks lattice-core's Integrations port about them, as it asks about every integration (lattice ADR-0027): grantr's store, or Defaults without one.

  • The artifacts are gated by artifactr's gate (ADR-0081): give Workspaces(gate=integration_gate(integrations, ui_key_of)), and making or changing a form, a submission, a dashboard or an app while its integration is off is refused as not_enabled.
  • The agent's tools are offered only while their integration is on: ui_tools's integrations.
  • An answer to ask_form, which changes no artifact the gate could see, is refused by ui_checks's integrations while forms are off, as not_enabled too.

FORMS module-attribute

FORMS: Final = 'ui:forms'

Forms: ask_form, create_form, and the submissions and answers people send.

DASHBOARDS module-attribute

DASHBOARDS: Final = 'ui:dashboards'

Dashboards: show_dashboard and update_dashboard, and the dashboards they write.

MINI_APPS module-attribute

MINI_APPS: Final = 'ui:mini-apps'

Mini apps, RFC-0005's second phase, off by default: apps, and the submissions sent to them.

IntegrationKey module-attribute

IntegrationKey = Literal[
    "ui:forms", "ui:dashboards", "ui:mini-apps"
]

The keys of forms, dashboards and mini apps.

ui_key_of

ui_key_of(
    kind: str, data: Mapping[str, JsonValue]
) -> str | None

The integration that governs an artifact, for artifactr's integration_gate.

A form and a submission to one are forms', a dashboard is dashboards', and an app and a submission to one are mini apps'. Any other kind is none of these.

artifactr.ui.checks

The checks for Workspaces(checks=...) (ADR-0075).

The checks that refuse what doesn't fit a form, before it commits (ADR-0075).

Give ui_checks to artifactr's Workspaces(checks=...). artifactr records an answer or a new artifact as given, and may not import a schema validator; its check hook runs these before each command of their type commits, with the committing handle, so each reads the workspace as the person who sent the command:

  • answer_deferred to an ask_form request: the answer is the form's values, valid against the schema in the request's args.
  • create_artifact of a submission: its form exists and the submitter may read it, it isn't closed or archived (FormClosed), the version it was made against is the form's head (FormChanged), its values are valid, and its id is its derivation. A submission to an app is checked the same way, but an app has no schema to check values against. An app is made by an agent alone, and its HTML loads nothing.
  • edit_artifact: a submission is never edited; a form's schema, submissions and closed are changed only by its author or whom is_admin allows; and a person changes only an app's state, so the HTML that runs is always an agent's, and loads nothing.
  • archive_artifact: a form, or a submission, is archived only by its author or whom is_admin allows.
  • propose_change: the change it proposes is checked as the change itself, and a submission is never proposed: a person sends it.
  • respond_to_proposal: an accepted change is checked again, as its proposer, against what is current, since artifactr applies it to the head; a reviewer's own changes aren't taken on a form, an app or a submission.

While forms are off in the workspace, an answer to ask_form is refused as not_enabled. Making or changing the forms' artifacts while their integration is off is artifactr's gate's to refuse (artifactr.ui.integrations).

A check reads before the command commits and holds nothing: a form closed in between takes one late submission. A check leaves to artifactr what artifactr refuses itself, such as a second answer, a stale edit or an id in use.

RefusalReason module-attribute

RefusalReason = Literal['changed', 'closed']

The reason in the payload of artifactr.ui's invalid_state refusals: FormChanged and FormClosed. An invalid_state with none, to an answer, is artifactr's: the request is answered already.

IsAdmin module-attribute

Whether the workspace's actor may govern any form, and archive any submission, as their authors may. The application answers it, from grantr's roles or its own: artifactr can't import them (ADR-0075).

FormChanged

FormChanged(target: Versioned[Any], version: int)

Bases: InvalidState

A submission was made against a version of its form, or its app, that is not its head.

Its payload's reason is "changed", with the target's artifact_id, the version the submission named and the head, so a client shows "This form changed" without reading the message.

details

details() -> dict[str, JsonValue]

Return the reason, the target and both versions.

FormClosed

FormClosed(target: Versioned[Any])

Bases: InvalidState

A submission was sent to a form that is closed, or to a form or an app that is archived.

Its payload's reason is "closed", with the target's artifact_id.

details

details() -> dict[str, JsonValue]

Return the reason and the target.

ui_checks

ui_checks(
    *,
    is_admin: IsAdmin | None = None,
    integrations: Integrations | None = None,
) -> Mapping[str, Check]

Return artifactr.ui's checks, by command type, for Workspaces(checks=...).

Merge them with the application's own, if it has any for the same command types, by calling both in one check of its own.

Parameters:

Name Type Description Default
is_admin IsAdmin | None

Whether an actor may govern any form and archive any submission; only their authors may if omitted.

None
integrations Integrations | None

Whether forms are on in a workspace, asked before an answer to ask_form; each integration's default, lattice-core's Defaults, if omitted.

None

artifactr.ui.agent

The agent's toolset (ADR-0078, ADR-0082).

The agent's toolset: asking forms, making them, showing dashboards, and making mini apps.

Register ui_tools on the agent as an application toolset (ADR-0017), beside the ArtifactWorkspace capability with AGENT_TYPES among its types, the kinds an agent makes and changes itself::

agent = Agent(
    model,
    deps_type=Session[None],
    output_type=[str, DeferredToolRequests],
    toolsets=[ui_tools()],
    capabilities=[ArtifactWorkspace(types=[*app_types, *AGENT_TYPES])],
)

Each tool checks what the model wrote before anything is recorded, and gives a refusal back to the model as a retry that names every field and keyword it refuses. Each is offered only while its integration is on in the run's workspace: mini apps', off by default, among them (ADR-0082).

ui_tools

ui_tools(
    *,
    forms: bool = True,
    dashboards: bool = True,
    apps: bool = True,
    integrations: Integrations | None = None,
) -> FunctionToolset[Session[Any]]

Build the toolset: forms' tools, dashboards' tools, mini apps' tools, or any of them.

Parameters:

Name Type Description Default
forms bool

Include ask_form, which pauses the run until a person answers a form, and create_form, which makes a form that takes submissions without a run waiting. The agent's output_type must then include DeferredToolRequests.

True
dashboards bool

Include show_dashboard and update_dashboard.

True
apps bool

Include create_app and update_app, offered only while ui:mini-apps, off by default, is on in the run's workspace.

True
integrations Integrations | None

Whether an integration is on in the run's workspace, asked before each model request: one that is off has its tools left out of the request, descriptions and all. Each integration's default, lattice-core's Defaults, if omitted.

None

artifactr.ui.wire

The wire schema, committed as schemas/artifactr_ui.v1.json.

The wire schema: the JSON Schema of artifactr.ui's kinds, ask_form's request and refusals.

The schema is checked in at schemas/artifactr_ui.v1.json; a test fails if it drifts from the models. portalr generates its TypeScript types from it. Regenerate it with moon run artifactr:schema, or::

python -m artifactr.ui.wire > schemas/artifactr_ui.v1.json

No other module imports this one, so running it registers the artifact types once.

wire_schema

wire_schema() -> dict[str, Any]

Return the JSON Schema of each kind's data, and of ask_form's request's args.

main

main() -> None

Write the wire schema to standard output.