# Apps { #artifacts } An **app** is a page an agent published: a report, a small dashboard, a one-page summary somebody opens in a browser. It has a link that stays the same when the agent publishes it again, so "every Monday, publish the week's numbers to this page" is one link people bookmark rather than a new one each week. An app is not a file. A chart, a generated PDF and a workspace file already have a home in the chat and in the [workspace](sandbox.md). An app is the thing that is *served*: it has an owner, a visibility and grants like an agent or a skill, and it can be given a public link for somebody with no account. ## Shared-page attribution { #shared-page-attribution } Public app viewers show a floating badge with the AgenticOS logo and a GitHub link by default. Shared conversation pages show the same badge below the conversation. An Owner or Admin can hide the footer for the whole organization in **Organization settings → Governance → AgenticOS on shared pages**. The next page load uses the current setting. Password-protected apps show the footer only after unlocking. The footer belongs to the viewer; downloaded files and embedded app content are not modified. ## App appearance { #app-appearance } The signed-in app viewer and its public link have independent **light** and **dark** controls. Apps open with a light preference, regardless of the console or operating-system theme. Switching changes only the embedded page's `prefers-color-scheme` preference: it neither reloads the app nor rewrites its HTML, CSS or stored versions. An app with fixed colors keeps those colors; apps that support both modes can respond to the selected preference. The page still owns contrast. In the bundled page styles, `--ao-muted` is a background color; secondary text uses `--ao-muted-fg` or the `.ao-muted` class. Using the background token as text can make it unreadable in either mode. ## Publishing one Turn on the **Apps** capability for the agent. It adds five tools: `publish_artifact`, which the model calls when the result is something a person should open rather than read once in the chat; `read_artifact`, which reads a published page back; `list_artifacts`, which names the pages a run may open; and `preview_artifact`, which draws a page and says what went wrong in it. The page comes from one of three places: - **A file in the agent's workspace**, ending in `.html` or `.md`. This is the usual case for an agent with the [Sandbox](reference/capabilities.md#files-shell) capability: it writes `report.html`, runs whatever builds it, then publishes the file. The bytes are read through the run's own workspace backend, so it works on every sandbox backend. - **The page passed inline**, for an agent without a workspace or a short page. - **Edits to the page already published** under that name - see below. The chat shows a card for the published page. The card links to the version *this* run published, so reading the conversation later still shows what was published in it, not what the page shows today. ### Changing part of a page Changing a word should not cost the whole page again. The agent calls `read_artifact` with the page's name, which returns the current version as it was written, then `publish_artifact` with the same name and `edits`: exact replacements, applied in order. Each one must match exactly one place in the page; a miss or an ambiguous match is sent back to the model to fix. The result is a new version like any other. The edits carry the version they were made against. If another run published in between, nothing is written and the model is told to read the page again, so an edited copy of an older page never replaces a newer one. Reading follows the same rule as opening the page in the console: the person the run acts for must be allowed to open it. A page over 100,000 characters is returned cut, and says so; an edit can still name text past the cut. ### Finding a page again Each message is a new run, and a run sees the earlier turns as text only, so the model does not keep the names it published under. `list_artifacts` lists the agent's pages in the run's environment, newest first and at most 50: each with its name, title, current version, format, live bindings, visibility, whether a public link is on, when it was published, its console link, and `may_edit` - whether publishing under that name from this run updates the page rather than being refused. The model checks it before choosing a new name. The list is read as the person the run is for, by the console's rule: their own pages, the organization's and those shared with them, or every page for a role that sees every page. In a group chat the channel sees only organization-wide pages: never the speaker's private or team pages, nor a page shared with the speaker alone. A surface with nobody signed in, such as a public widget, lists nothing. ## Live data A page can show records from [Virtual Tables](virtual-tables.md) that stay current, instead of the copy it was published with. The agent passes `bindings` to `publish_artifact`: each binding is a name and a **page query** - a table, the fields the page reads from it, and optionally filters, a sort and a limit. The page calls `agenticos.onData(render)`, and `render(data)` runs with the first data and again on every refresh. `agenticos.data` is a Promise of the first data. ```js agenticos.onData((data) => { // data.tickets = { status, records, fields, count, truncated, limit } }); ``` Each binding arrives as `status` (`ok` or `unavailable`), `records` (`{id, : value}`), `fields` (label, type, options and state for each), `count`, `truncated` and `limit`. A page that gets no reply within 10 seconds sees every binding `unavailable`; a listener added after that asks once more. **Everyone who opens a live page reads the same records**, whatever their own access to the tables. The query is read with the access of the page's [owner](#who-owns-a-page), and only once the owner has agreed to share it ([consent](#consent-the-owner-decides-who-reads-the-data)). So a page is for data everyone who can open it may see. The records are read when somebody views the page, and are never stored in it. ### What a binding freezes A page query is not a link to a saved view. The agent names a `table`, or a [saved view](virtual-tables.md#saved-views) by id or exact name, and the platform **copies** the view's filters and sort into the page when it publishes. Editing or deleting the view later changes nothing on the page. | In the binding | Meaning | |---|---| | `table` or `view` | The table to read, or a view whose filters and sort are copied. Not both | | `fields` | Each name the page reads, mapped to a column by id or label | | `filters` | `{column, op, value}`, all of which must hold; added to the view's filters | | `sort` | `{by, direction}`; replaces the view's sort | | `limit` | At most 1,000, which is also the default; replaces the view's | A view's `visible_columns` and `group_by` are ignored: the binding's `fields` decide the columns. A filter may name a column the page does not read, and the owner is told which. There is no free-text search on a page query, so a view that has a search **cannot be bound**; the publish is refused and names it. A view whose filter names a column that is gone is refused the same way. Formulas, lookups, rollups and link labels are allowed. They read more than the column the page names, so the query records everything it reaches: a formula's input columns, a lookup's or rollup's link and target, each linked table, and the column each linked table labels its records by. The agent's spec must grant read on the table, and the person behind the run must read every table the query reaches, because nobody can bind what they cannot read. The publish result describes each query in words - its table, filters, order, limit, and each field with what it reads (`Status (single_select)`, `Pay band: formula over Salary, Level`). It is the text the owner agrees to, so the agent checks it is what it meant. A binding carried forward unchanged says `kept`. A binding name is lower-case letters, digits and `_`. A field name may be written as its column is labelled: words of letters in any case, digits and `_`, with single spaces between them, such as `Status` or `Created At`. Both are at most 64 characters, and `__proto__`, `constructor` and `prototype` are reserved for both (`id` too, for a field). A page has at most 5 bindings, and a binding at most 50 fields. Version 3 of the [page kit](#the-library-set) draws a binding for the agent: `AO.records("#tickets", data.tickets)` inside `agenticos.onData` renders the records as a table with a head from the fields' labels, numbers and dates right-aligned, choices as badges and every value written as text. Before the data it shows a loading line, and an `unavailable` or empty binding and a cut list each get their own message. `AO.onData` in version 3 is the same call as `agenticos.onData`. A bound page whose script never asks for its data still publishes, with a [warning](#the-page-contract-and-the-publish-warnings). ### A workflow's log as live data { #a-workflows-log } A binding can also read the log of a [workflow](workflows.md) - the lines its steps' Quick Logs wrote - to build a dashboard on a page that stays current. Give the binding `"kind": "workflow_log"` (a binding with no `kind` reads a table, as every binding published before this did). It is a second kind of page query and follows the same rules: it is resolved when the page is published, frozen, read with the access of the page's [owner](#who-owns-a-page) and only after [consent](#consent-the-owner-decides-who-reads-the-data). | In the binding | Meaning | |---|---| | `workflow` | The workflow's id or exact name. It must be one the agent's Workflows capability binds, as for an [action](#actions), and the person behind the run must be able to view it - and so must the page's owner when the publish changes the binding | | `step` | Optional: only this step's lines, by the step's id, its label, or - for a step with no label - its kind's name, such as `Send email`, when that is unambiguous. It must be in the live version or the draft. A step with no Quick Log is allowed, and the publish result notes that it will read no lines | | `outcomes` | Optional: only lines that left by one of these - a port, or `error`. Any of them matches, and a line that left by several ports, stored as `a, b`, counts as each | | `run` | Optional: a run id, or the 4 or more characters it starts with | | `include_test` | Lines written by test runs as well. Off by default | | `lines` | Serve the newest N lines, newest first. Up to 500 in one binding; the log's own route pages 100 at a time | | `per_day` with `days` and `tz` | Serve the count of lines per day and outcome instead: `days` is 7, 30 or 90, today included, and `tz` an IANA name (UTC by default) whose days are counted | Give `lines` or `per_day`, not both. These are the filters and limits of `GET /workflows/{id}/logs` and `GET /workflows/{id}/logs/summary`, served by the same code. The workflow and the step are resolved to ids when the page is published and stored with the filters: renaming the workflow or the step later changes nothing on the page. An unknown workflow, one the agent does not bind or the person cannot view (all said the same way, so a refusal never confirms it exists), an unknown step, a filter that does not hold or a limit over 500 refuses the publish, and the message says which. The page receives it through the same `agenticos.onData` call. With `lines`, a binding arrives as `status`, `records` (each `{id, created_at, step, outcome, status, message, run_id}`), `count`, `truncated` and `limit`, cut by the page's record and byte limits like a table's. With `per_day` it arrives as `status`, `days` (every day of the window, oldest first, each `{day, counts: {outcome: n}}`), `total`, `window_days` and `tz`, with no record limit. ```js agenticos.onData((data) => { const week = data.errors_per_day; if (week.status !== "ok") return; AO.chart("#errors", { type: "bar", data: { labels: week.days.map((d) => d.day), datasets: [{ label: "Errors", data: week.days.map((d) => d.counts.error ?? 0) }], }, }); }); ``` published with `bindings: {"errors_per_day": {"kind": "workflow_log", "workflow": "Order intake", "outcomes": ["error"], "per_day": true, "days": 30, "tz": "Europe/Warsaw"}}`. The publish result and the owner's consent say it in words: which workflow, which filters, and whether it serves the newest N lines or counts per day over D days. Any change to a log binding - the workflow, the step, the outcomes, the run, test lines, lines or per-day, the limit, the days or the time zone - changes the page's queries, so it needs the owner's consent again, and a [data change review](#consent-the-owner-decides-who-reads-the-data) marks it as showing more. A log has no columns, so it cannot drift; instead the binding is `unavailable` when the owner can no longer view the workflow (`creator_lost_access`) or the step is no longer in it (`source_changed`, naming `step`). Two limits are different from a table's: a page that reads a log **cannot have a public link**: publishing a log binding is refused while the link is on, the link cannot be turned on or pinned to a version that reads a log, and such a version cannot be restored while the link is on. A log binding is never served on a public page. The roles that read every table also read a page's log without consent only when they view every workflow, which every built-in role that reads every table does; one that does not waits for the owner's consent like an editor. ### Who sees what Which version a viewer is served, and whether its data is read for them, depends on who they are. | Viewer | Served | Its data | |---|---|---| | The page's owner | The latest version | Always | | Owner, Admin, Builder and Operator, even view-only | The latest version | Always, for the roles that read every table (`tables:view` at ALL) | | Anyone else who can edit the page | The latest version, so an editor's agent keeps editing what is current | While the owner's consent to that version holds | | Everyone else who can open it (the **audience**) | The newest version that was ever consented to, or that reads no data | While the owner's consent to it holds | The audience is never served a version nobody agreed to share. When the data's consent no longer holds - an editor withdrew it, or the owner changed - the audience still gets that **whole page**, and every binding arrives `unavailable` with the reason `awaiting_consent`; the page says "The data appears once the page's owner confirms it." When no version at all is consented or free of data, the audience sees "This page isn't available yet" and nothing else. A binding is also `unavailable` when what it reads changed since the page was published. The reason is told only to someone who can edit the page; a reader sees how many sources are unavailable. | Reason | What happened | |---|---| | `creator_gone` | The page's owner is no longer an active member | | `creator_lost_access` | The owner can no longer read a table the query reaches, or view the workflow whose log it reads | | `source_changed` | A workflow-log binding's step is no longer in the workflow (`step` is named), or a column's meaning changed (its type, a formula, a link or lookup target, an aggregate, an archived or restored option), or a column or table was archived, or a linked table now labels its records by another column. The column's current label is named | | `awaiting_consent` | The owner has not confirmed sharing this version's data, or it was withdrawn | | `public_link` | A workflow-log binding was asked for on a public page, which never serves one | One binding going `unavailable` leaves the others working. The page is never blocked from a table change: nothing stops a column from being changed, and the binding stops serving until the agent publishes the page again, which resolves the query against the table as it is now. Renaming a column does not change a binding; the page keeps reading it by the name the agent gave. Adding an option to a select, or relabelling one, does not either. ### Consent: the owner decides who reads the data A page's data is read with its owner's access, so the owner decides who else reads it, per version. That decision is **consent**: the owner reviews the page queries in words - each query's table, filters, order, limit and fields, every linked table it reaches, and who will read it - and confirms. The review names who will see the page: the roles that see every page (Owner, Admin, Builder and Operator), the page's visibility, the people it is shared with and the groups, including future members. The owner can only confirm what they read themselves. A version's data is in one of these states: | State | Meaning | |---|---| | `not_needed` | The version has no bindings | | `pending` | Nobody has confirmed it. The audience keeps the version it had | | `consented` | The owner confirmed it, or it was consented to automatically | | `declined` | A request to confirm it was declined | | `withdrawn` | A consent that no longer holds: an editor took it back, or its owner is no longer the page's owner | A new version is consented **automatically** when its page queries - and everything they reach - are the ones the owner already consented to. Otherwise it is `pending`: that includes the first bound version, unless the owner has [auto-consent](#auto-consent) on. The owner, and the owner alone as themselves, confirms in the viewer. While the latest version waits, the strip under the page's title says so, with **Confirm data**: it opens what the version shares and with whom, and the confirmation carries a fingerprint of exactly what was shown. If the page was republished in between, or its queries moved, the confirmation is refused and the owner looks again. Confirming also checks that the owner can still read every table the query reaches. An Owner or Admin may confirm for the owner. It is audited as consented by them for the owner, and the owner's access still backs the data. Anyone with an `edit` grant cannot: a grant to edit the page is no authority over whose data it reads. **Withdrawing** takes the consent back. Anyone who can edit the page, as themselves, uses **Stop sharing data** in the viewer's menu. Every consent given so far is void, not only the latest, and auto-consent turns off. Every `edit_data` grant on the page drops to `edit` as well, so a data editor's next republish waits for the owner instead of undoing the withdrawal. The audience keeps the page and loses its data until the owner confirms again. Withdrawing only narrows who reads, which is why every editor may do it. When the owner leaves the organization, their consents lapse with their membership. An owner who is removed and invited again starts fresh. An owner who is deactivated and reactivated revives their consents: reactivating is an account-wide act, in every organization the account belongs to. #### Republishing When an editor other than the owner publishes a version that reads different data, the version is `pending` and the platform files a **version request** for the owner - see [Share requests](#share-requests). Until the owner decides, the audience still sees the earlier version. The publish result says "The audience still sees v2 until Alex confirms this version's data in the console." (with the owner's name, or "the page's owner"). A run with no person behind it can republish a bound page only unchanged - the same bytes and the same queries, which needs no person and no consent. It cannot swap the code that receives the owner's records. A publish by an editor is refused when the page's owner cannot read a table the query reaches. #### Auto-consent The owner can switch on **Share new versions automatically** for a page. It is off by default and only the owner, as themselves, turns it on, never an agent. It asks first: "Any data change your runs make to this page, including new tables and columns, reaches everyone who can open it without asking you. This includes API runs." The audit entry records the confirmed text. It covers only the owner's own republishes. An editor's republish still waits. Auto-consents are recorded as automatic, and when one reaches more than the version served before it, the owner's strip lists it: "v4 was shared automatically and shows more data: +Deals, +Salary". The owner or an Owner or Admin turns it off, and withdrawing or handing the page to someone else turns it off too. It never covers a run that started while an administrator impersonated the owner, however late that run publishes: a background run, a workflow step or a run resumed after an approval carries it with it, and the same holds for an editor's data-editor grant (below). Republishing queries the owner already confirmed is still consented. #### Data editors The sharing dialog has a grant level above `edit`: **Can edit, including data** (`edit_data`), for pages only. It is a standing consent to that person's republishes, a change of query included. Only the owner, or an Owner or Admin, as themselves, grants it, after a popup: "Alex can change the data everyone sees on this page, including new tables and columns, without asking you. This also covers their API runs." For a group the popup says "Anyone in Sales, now or later, can change ...". The grant is audited with the confirmed text. A republish by someone with `edit_data` is consented automatically, recorded as made through their grant. They still have to read every table the query reaches, and the owner still has to. The role scope of Owner or Admin never implies it. Anyone who can edit the page lowers or removes it, and an ownership switch drops every `edit_data` grant to `edit`. ### Who owns a page A page's owner is the person the first publishing run acted for. It never changes by itself and it is the page's single data owner: the person whose access reads the data and whose consent shares it. If the owner is deleted, the page has none. Nobody can claim a page. The owner hands it to another active member of the organization with **Change owner** in the viewer's menu, and an Owner or Admin may reassign any page, one whose owner is gone included. Each does it as themselves, not by impersonating someone. A grant to edit the page does not allow it. There is no agent tool for it. The switch is immediate, with no acceptance step. The page's data stops reaching the audience until the new owner confirms it, every consent so far is void for good (so handing the page back never revives an old one), auto-consent turns off, any pending request is voided, every `edit_data` grant drops to `edit`, and the old and the new owner are notified, except whoever made the switch. It is audited as `artifact.owner_changed`. Giving an owner-less page visibility `private` counts as taking it over, and is open only to an Owner or Admin. It goes through the same rules. ### Share requests Widening who can open a page that reads data - a wider visibility, or a new grant or a higher level - is the owner's decision, or an Owner's or Admin's for the owner. Narrowing is open to every editor. A page that reads no data keeps the ordinary sharing rules. Anyone else who can edit the page and tries to widen it does not get a refusal. The platform files a request. There are three kinds: | Kind | Made by | Asks | |---|---|---| | `audience` | An editor, in the Share dialog or through their agent's `share_artifact` | To widen who opens the page | | `access` | A member of the page's organization who opens its link without access | To open the page, with an optional note | | `version` | The publish of another editor whose version reads different data | To confirm that version | A member can have one pending `audience` and one pending `access` request per page, and a page has one pending `version` request; a newer one replaces the older. Requests are limited to ten an hour per person per organization by default ([`RATE_LIMIT_ARTIFACT_SHARE_REQUESTS_PER_HOUR`](configuration.md)). A `version` request counts against the publisher's allowance too: past it, a republish still replaces the pending request, but the owner is not notified again. The page's owner decides. When it has no owner left, the Owners and Admins can decline; to approve, they first hand the page to a member (**Change owner**), who confirms the data. A request goes void without a decision when the page's queries change, the owner changes or the page is archived. The owner is notified in the console and by email, as set in **Settings → Notifications** ("Page requests"). The notification names the page and the requester, never its data. They are answered in a **Requests** section of the Share dialog, and a **Requests** button in the viewer's strip shows how many wait. Approving an `audience` or `access` request applies the visibility and grants it asked for, each only where it still widens. **It never consents to data**: when the version the audience is served has no consent that holds, approving is refused with "Confirm the page's data before sharing it", and the owner confirms the data first, then approves. Declining shares nothing. An `audience` or `access` request made under a membership that has since ended - the member was removed and invited again - cannot be approved; decline it. The list of requests (`GET /api/v1/artifacts/{id}/share-requests`) always holds every pending request. With `include_decided=true` it adds the decided and void ones, which are kept, newest first and a page at a time with `skip` and `limit` (50 by default, at most 100); `total` counts all of them, and the Share dialog says when it shows only the newest decided ones. The requester is not told when it is decided: the page opening, or not, is the answer. A page that reads no data takes a plain `access` request, which any editor of it may approve. #### Requesting access A member of the organization who opens the link of a page they cannot open sees "You don't have access to this page", a note field and **Request access**. Nothing else is revealed: no title, owner or agent. A signed-out visitor or somebody from another organization gets the ordinary not-found. #### Reviewing a changed version A `version` request opens the page for the owner on the pending version, with its real data. The strip says who changed the data the version shows, with **Review data change**. The panel compares the version's page queries with the one the audience sees now, binding by binding and in words: fields, filters, order, limit and linked tables added, removed or changed, each marked **Shows more**, **Shows less** or no change, new bindings with their full disclosure, and unchanged ones collapsed. A toggle, **What others see**, shows the page as the audience has it. The panel names who published the version and when, and links to the conversation when the owner may open it. **Approve** confirms the version. **Decline** keeps the audience on the earlier version and marks the version `declined` in its history; a later republish asks again. In the Requests list a version request offers **Review data change**, not Approve: it is confirmed from the review. ### Sharing a page from an agent `share_artifact(name, visibility?, people?)` lets an agent ask for a page to be shared: `visibility` is `team` or `org`, `people` are members by full name or email and groups by name, each asked at view level. It **never shares anything**. It files an `audience` request for the person the run is for - the owner's own run included - and answers "Waiting for Alex to approve in the page's Share dialog". The approval is a person in the console, signed in as themselves, never a tool call. A name that matches nobody, or several people, is reported back, never guessed. It asks for nothing public: the public link is only set up in the console. A run with no person behind it, such as a widget visitor, cannot ask. ### What the viewer sees A page with bindings shows a **Live** badge on its card, in the dashboard's artifact widget and in the viewer. In the viewer its dot pulses while the data arrives and turns amber when some sources are unavailable to this viewer. A viewer who can read none of them sees no badge, and neither does an older version. The viewer has **Refresh**, which reads the data again and updates a page that uses `agenticos.onData` in place, with when it was last read in the button's tooltip. Beside it is a menu to refresh automatically: Off, every minute, every 5 or 15 minutes, or every hour. Each viewer picks their own, and the choice is remembered per page in that browser. Auto-refresh does nothing while the tab is hidden, and reads once when the tab comes back if a tick was missed. It stops when the data is refused (the page is gone for this viewer) and when no source on the page can be read, until the viewer clicks Refresh and a read answers. A failure that says nothing about the page, such as a server error, keeps it polling. A busy answer (429) is tried again twice, a second apart, before the viewer is told "Busy, try again in a moment." A line under the strip appears only when something needs attention: a version waiting for confirmation, a request to review, or sources that cannot be read. A refused page says "This page's live data is not available to you." A reader whose sources cannot be read sees how many ("2 of 3 sources unavailable to you"). An editor also sees why for each source, with an **Open the table** link to the bound table. When an agent publishes a new version while somebody has the current one open, the next read (a refresh, manual or automatic) moves the viewer to the version they are now served: the page is loaded again, with its own data. A viewer who picked a version from the version menu stays on it. On an older version, live data is not read. The viewer says "Live data only on the current version." with **Open current**, and, for an editor, **Restore** for that version. A restore resolves the version's page queries against the tables as they are now, so it is a new query when a table moved on, and it waits for consent like any other republish. ### How the data reaches the page When the console opens a page it asks for a signed address that carries who the viewer is. The content route reads each binding **once, on load**, and writes the result into the page's platform script, so the first `onData` call already has its data and no round trip follows. A [public link or an embed](#live-data-on-a-public-link) is written in the same way, under stricter gates. Nothing is injected for a thumbnail, a card or a version from the history. Refresh and auto-refresh keep using the data route and a `postMessage` from the console, which now answers the same page-query rows for everyone served that version. The route is never cached, the address is short-lived, and a record's content is kept out of logs. A binding that cannot be read in time or fails in the database is injected `unavailable` and the page still renders. ### Limits Every answer is bounded, so one page cannot take the deployment down: | Limit | Value | |---|---| | Bindings per page | 5 | | Records per binding | 1,000 | | Shaped data per page | 4 MiB, split fairly between the bindings | | One query | at most 2 s | | One page | 5 s deadline | | Data loads | 30 a minute per viewer, one in flight per viewer, and a cap per process | | Reasons-only reads (`rows=false`, an editor's strip) | The same limits, counted separately from data loads | | Share requests | 10 an hour per person per organization (audience, access and version) | A cut binding says `truncated: true`, with `limit` set to `records` or `bytes` and `count` as the number of records served. `bytes` also covers a read whose measured cost ran out before the shaped data filled the page's share. The deadline is checked before each query, without cancelling any: each query runs at most the smaller of 2 s and the time left of the 5 s, and no query starts once the 5 s are spent, so a page answers in about 5 s and the wall clock can reach about 7 s. Injected loads count against the 30 a minute, like refreshes. Past a rate limit the data route answers 429, the console retries a busy answer twice, and a page served with its data gets `busy` bindings. When Redis is down, the rate limit's floor and the in-flight limit count per API worker, and the per-process cap still bounds each worker. ### Who may publish a bound page A page binds to a table only if the publishing agent's spec grants read on it, and the person behind the run reads every table the query reaches, checked when the page is published or a version restored. Revoking either later does not stop pages already published; the owner's access is checked again on every load. A bound page can be **changed** only by a run acting for a member (an unchanged republish, with the same bytes and queries, needs no person): the signed-in member (web chat, HTTP API), a linked channel sender or a schedule's creator. A widget, embed or hosted-page visitor, an unlinked channel sender and an agent event trigger's delivery (GitHub, email, webhook relay) do not count, and neither does a workflow run admitted by a public door or anywhere in such a run's chain. ## Actions { #actions } A page can start a [workflow](workflows.md), but only when a person agrees. The page does not act: it asks, the console shows the person what would happen, and the run belongs to them. This is how a page becomes a small app - a form that files a request, a button that approves an invoice - without the page holding any power of its own. ### Declaring actions The agent declares the workflows a page may start when it publishes, with the `actions` argument of `publish_artifact`: up to five entries, each a workflow (its id or exact name) and a **label**, the word the page uses for it. ```json {"actions": [{"workflow": "Approve invoice", "label": "Approve"}]} ``` A workflow can be an action only when all of these hold: - the publishing agent has it in the [Workflows capability](reference/capabilities.md#workflows), so what a page may start is part of what the agent's author reviewed; - it is live (published, not archived) and starts from a **Manual** or **API** trigger; - the person behind the run may view and run it. One they may not is answered as if it were not bound, so a refusal never names a workflow the person cannot see. The label is plain text of at most 60 characters with control and invisible characters removed; empty, it becomes the workflow's name. Leaving `actions` out keeps the current version's, and `[]` removes them. The publish result lists each declared action in words, or says `kept`. A Markdown page cannot have actions. A run with nobody behind it cannot add or change them, and can only republish unchanged a page that already has some. Restoring an older version checks its actions again against the agent as it is now, and is refused if one no longer stands. Actions are stored with the version, so a new version replaces the whole list and an older one keeps its own. Unbinding a workflow from the agent later does not edit a published version; the workflow's own state and each viewer's access are checked every time the page is opened and again when a run starts. ### Who sees which actions When the console opens a page, it lists the actions the **served version** declares that this viewer may run now: each with the workflow's real name, the page's label and the fields its trigger asks for. One the viewer may not run is left out. The viewer's strip shows **This page can run: ...**, naming the workflows, each a link to that workflow's runs. Without any, nothing is shown. A page gets no actions, and its `run` call is rejected at once, when it is shown: - on a public link, in an embed, in a preview or in a card's thumbnail; - as an older version from the history, or one that is not the version this viewer is served (an audience still waits on consent for the newer one); - to a member who cannot run any of its workflows, or to a session that is impersonated. The start route refuses all of the same, so a page that skipped the console would gain nothing. ### Running one from the page In the page, `agenticos.run(action, input)` - `AO.run` in the [page kit](#the-library-set) - asks the console to start an action. `action` is a workflow id, the page's label or the workflow's name. The console matches the id exactly first, then the label, then the name, ignoring case. A tier that fits two actions matches nothing and does not fall through to the next. `input` is an object holding exactly the values the workflow's trigger declares. The call returns a Promise of the finished run, and `onRun` (`AO.onRun`) hears each change on the way: ```js AO.run("Approve", { invoice: "INV-204" }).then( (run) => show(run.status, run.output_text), (error) => show("not run", error.message), ); ``` The console starts nothing until the person has agreed. Every run, however many came before, opens a confirmation dialog: - its title is the **workflow's own name**, and the page's label appears only as a quote; - it shows every value the page sends, in full and as text, with characters that draw nothing or reorder the text written out (`\u{202E}`); - **Run** is disabled for half a second and never takes focus, so a click already on its way does not agree; Cancel has the focus; - one question is open at a time, across run and link dialogs, and a request that arrives meanwhile is refused as `busy`; - after **three declines** for one opening of the page (reloading the frame keeps the count) the console stops asking and refuses further requests until the page is opened again. A request is refused without a dialog when it is too early (the frame has not loaded), names an action the page does not declare, does not fit the workflow's fields, or comes from anything but the page's own frame under the nonce the console minted for that load. A message from another frame, or a stale page, is dropped without a reply. The promise rejects with an `Error` whose `message` is a short code, so the page words its own copy: | Code | When | |---|---| | `cancelled` | The person declined | | `busy` | Another question was open | | `too_many_cancels` | Three declines; the page is not asked again until reopened | | `not_ready` | The page asked before its frame had loaded | | `unknown_action` | No declared action the viewer may run fits | | `invalid_input` | A value is missing, unknown or the wrong type; `error.problems` lists `{field, kind}` only when the console found the problem, and is absent when the server answered 422 | | `rate_limited` | Too many runs from this person, or the caller's or the organization's admission quota | | `not_found`, `unavailable` | The action is gone, no longer startable by hand or archived, or the page cannot run actions here at all (a public link, an embed, a preview, history, a viewer with no runnable action) | | `failed` | The run could not be started or followed | | `page_changed` | The page was replaced while the question was open | ### What the run is The run is the **viewer's**: it starts with their permissions and counts against their budget, as if they had run the workflow from the console. Its trigger is **App** (`app`), and it records the page and the version the person was looking at. The page confers nothing; the run is refused when the person could not have started it themselves. A run that is waiting for an approval or for someone to look at it stays in the workflow's [run history](workflows.md#running-a-workflow), and the viewer shows a line with **Open run**. The page is never handed the whole run. It reads a projection: the status, the answer text, the structured answer with file references removed, the first line of an error (at most 200 characters), and, while the run waits on someone, the path to it in the console. Only the person who started the run can read it, and only through the page that started it. The console reads the run with a growing delay and posts each change to the page. A run that waits on someone is read for ten minutes of the tab being visible. After that the page's promise settles with the last state, `paused: true` and no `finished`, and the page hears nothing more; the line in the viewer is the way to the run. A page that is reloaded or closed is told nothing. ### Limits and audit | Limit | Value | |---|---| | Actions per page | 5 | | Label | 60 characters | | Runs started from one page | 10 a minute per person and page (`RATE_LIMIT_ARTIFACT_ACTIONS_PER_MINUTE`) | | Declines before the console stops asking | 3 per opening of the page (reloading the frame keeps the count) | | Outstanding work | the workflow runs' own admission quota, which also answers `rate_limited` | Past a limit the person sees a message and the page gets `rate_limited`. Every start is audited as `artifact.action_started` with the page, the version, the workflow and the run - never the input, which is the person's and is on the run itself. See [governance](governance.md#audit) and [the security page](security.md#a-page-that-starts-workflows). ## One name, one link The identity of an app is its **name within the agent** - `weekly-report`, `churn-dashboard`. Publishing under the same name updates the same app, from any surface: the chat, the API, a [trigger](triggers.md) or a workflow. A new name makes a new page. The name is lower-case letters, digits and hyphens, up to 64 characters. The name is also **within the environment** the run answered from. A run in a named [environment](environments.md) - `staging`, `dev` - publishes a page of its own beside the default environment's, so trying a new version of an agent cannot republish the page production readers have bookmarked. The environment is read from the run itself, never from the model, and the list and the page name it. The name is shared by everybody who runs the agent, but the page is not. A run republishes an existing app only when the person it acts for owns it or holds `artifacts:edit` on it - from the role or from an `edit` grant, the same rule as managing it in the console. Anybody else's run is told the name is taken and publishes under another one, so a colleague asking the same shared agent for a `weekly-report` cannot replace the page behind your link. Every publication is a new **version**. Nothing is overwritten, so the version list on the app's page is the page's history. Two things keep that history bounded: - Publishing exactly the bytes the current version holds adds no version. The result says `unchanged`, and a schedule that found nothing new leaves the history alone. It still counts as a publication, so retention's clock restarts. - Only the newest versions are kept, `ARTIFACT_MAX_VERSIONS` of them (20 by default). An older version is removed when a new one lands - except one the public link is pinned to. A conversation that links to a removed version says the version is no longer kept and offers the latest one. **Restore this version** puts a kept version back. A member who may edit the page opens the version and restores it; that adds a new version with the old one's bytes, so the history keeps what happened and the restore can be undone the same way. Nothing new is stored. It is recorded in the audit trail. The new version says where it came from: the version list shows it as `v4 · restored from v2`, and the API gives its `restored_from` number, which a publish leaves empty. ## Supported formats | Format | What is stored | What is served | |---|---|---| | HTML (`.html`, `.htm`, or `format: html`) | The document as the agent wrote it | The same document, behind a short platform script (see [How the page is isolated](#how-the-page-is-isolated)) | | Markdown (`.md`, `.markdown`, or `format: markdown`) | The Markdown source | The source rendered into a plain page, tables included. Raw HTML in it is escaped | One version is one self-contained document of at most `ARTIFACT_MAX_BYTES` (5 MiB by default). There are no multi-file bundles: inline your own script, styles and images (as `data:` URIs) into the one file. !!! warning "The page has no network" Scripts run, so a chart drawn by a library works. But the page cannot load anything from anywhere and cannot send anything anywhere - a CDN script, a web font from a URL and an API call all fail. The exceptions are the library set below, which the deployment serves itself, and the [live data](#live-data) the console hands a page that declared bindings. The tool tells the model this. It is deliberate, and [How the page is isolated](#how-the-page-is-isolated) says why. ### The library set The deployment serves a few files beside every page, so the model stops pasting a whole chart library into each one. A page loads them by a relative address, and keeps working if the deployment later moves its content to another origin: | Address in the page | What it is | |---|---| | `lib/chart-4.5.1.umd.min.js` | Chart.js 4.5.1, as `window.Chart` | | `lib/d3-7.9.0.min.js` | d3 7.9.0, as `window.d3` | | `lib/lucide-1.46.0.min.js` | Lucide 1.46.0 icons, as `window.lucide` - the console's own icon set | | `lib/agenticos-3.css` | The page kit: the console's look for pages, light and dark, and the `ao-` components a page is built from | | `lib/agenticos-3.js` | `window.AO`: Chart.js in the console's style, number formatting in the page's language, icons, tabs, `AO.onData`, `AO.records`, and `AO.run` and `AO.onRun` for [actions](#actions) | | `lib/agenticos-2.css`, `lib/agenticos-2.js` | The previous version of the kit, kept for the pages published against it | | `lib/agenticos-1.css` | The first version of the look, kept for the pages published against it | | `lib/three-0.186.0/three.module.js` | three.js 0.186.0 as an ES module, with `addons/controls/OrbitControls.js` beside it - map `three` and `three/addons/` to them with an import map | | `lib/mermaid-12.1.0.min.js` | Mermaid 12.1.0, as `window.mermaid` | | `lib/leaflet-1.9.4/leaflet.js`, `leaflet.css` | Leaflet 1.9.4, as `window.L` - draws on the deployment's [map tiles](#visuals), when it has any | | `lib/agenticos-map-2.js` | `window.AOMap.itinerary`: numbered pins shown in order and dropped one by one on request, a map 16:9 of the page's width, a line joining them, and cards for the stops - on map tiles, or as an outline of the stops without them. `lib/agenticos-map-1.js`, which drops the pins on load at one height, stays served for the pages that name it | | `lib/agenticos-map-1.js` | `window.AOMap.itinerary`: numbered pins dropped in order, a line joining them, pause and replay, and cards for the stops - on map tiles, or as an outline of the stops without them | | `lib/topojson-client-3.1.0.min.js`, `lib/world-atlas-2.0.2-110m.js`, `lib/agenticos-worldmap-1.js` | `window.AOWorldMap.draw`: a world map in SVG - countries shaded by a value, pins and great-circle routes - with no tile server; load them after d3, in this order | Every name carries its version and is cached for a year. The files answer `Access-Control-Allow-Origin: *`, because a module script is fetched in CORS mode and a page in an opaque origin sends `Origin: null` - without it three.js would not import. An upgrade adds a new file beside the old one, so a page published against a version keeps getting it. Nothing is fetched from outside the deployment, so an air-gapped one works the same way. The files are listed in `backend/app/core/catalog/artifact_lib/`. Version 3 keeps every class and call of version 2. It also forgives markup a model writes loosely: a title, subtitle and meta line placed straight in the header stack rather than spreading across it, a table built without `` still gets its header row, and a message written into a table's wrap is padded. It adds loading and note lines, a caption line, a live dot, lists, a toolbar with inputs, and the header's icon and actions. Icons a script adds after the page loaded, as a live page's records do, are drawn too. The bundled **`artifact-pages`** [skill](skills.md) teaches an agent to use them: the mandatory skeleton, every class of the kit, three templates (a dashboard, a report and a live table drawn with `AO.records`), the house style, icons instead of emoji, and how to change a page with `read_artifact`. A new organization gets it with the other bundled skills, an existing one through `seed-skills`, and the Apps capability's **Page style** tab in the Builder offers it. Edit the skill to describe your own brand, and the agent follows it. ### The page contract and the publish warnings The agent cannot see the page it publishes, so the platform tells it what a page must look like and what looks wrong in the one it sent. The Apps capability gives every agent that has it a short **page contract** in its instructions, whether or not the skill is bound: the skeleton (doctype, viewport, `lang`, the kit's stylesheet and script in order, `main.ao-page` and the header), a closed list of the kit's most-used classes, Lucide icons rather than emoji, and `AO.records` for bound data. When the agent's run offers the `artifact-pages` skill and has not loaded it, its first whole HTML page is sent back once with a request to load the skill and rebuild. That happens once per run; each new message is a new run, so it can happen again on a later turn. An edit is never sent back. A published HTML page is then checked, and the result carries a `warning` with one line for each problem found. The page is published either way. The checks: - no `` as the first line, or no viewport meta; - an `ao-` class, in the markup or named in a script, that the kit version the page loads does not define and its own `