Build Apps on Erdo
Erdo pages are small apps that run in a managed runtime with first-class, permission-aware access to your data. A page can query your datasets, read and write per-page state, react to other viewers in realtime, and submit events into pipelines — all under your existing Erdo RBAC, with no servers to run. You write plain HTML/CSS/JS (React is available by default). Erdo hosts it, injects thewindow.erdo client, wires data access to your grants, validates the build, and gives you a private editor URL plus an optional public share link.
The model in one line: pages read directly (
queryDataset, kv.get, channel subscribe) and write through governed, RBAC-checked APIs (kv.set, insertRows, submitEvent). Identity is always the signed-in viewer — attribution and authorization come from your token, never from anything the page sends.Quickstart — deploy from Claude Code in under 5 minutes
Pages deploy through the Erdo MCP server, so any MCP client (Claude Code, Claude Desktop, Cursor) can ship one.erdo_list_pages is in the curated core list. erdo_deploy_page, erdo_update_page, and erdo_validate_page are in the pages toolset. While mcp_curated_tools is on, the connection has to request pages; while it is off, tools/list already includes them. Connect the server first (see MCP Quick Start and Toolsets), then ask your agent to deploy a page — it calls erdo_deploy_page for you.
1
Connect the MCP server
X-Erdo-MCP-Toolsets: pages. erdo_list_artifacts and erdo_get_artifact are in the threads toolset if you also want those listed.2
Ask the agent to deploy
“Deploy a page titled Sales Dashboard that charts my acme.sales dataset by month.”
The agent writes the HTML/JS and calls erdo_deploy_page with dataset_slugs: ["acme.sales"]. You get back an editor URL, validation results, and (if you asked for public: true) a share link.3
Iterate until validation is clean
A deploy with validation errors still saves — the agent fixes them with
erdo_update_page (send just js to patch a script) and re-checks until the build is clean.The deploy tools
These are MCP tools (and matching REST endpoints) — the external front door to the same artifact service, validation, and renderer that Erdo’s own agents use.erdo_deploy_page inputs
Write grants are opt-in.
erdo.insertRows and writes to a named KV store only work when the page was deployed with the matching writable_dataset_slugs / writable_kv_slugs; otherwise they return a permission error. Reads (dataset_slugs), the page’s own private KV store, and submitEvent (pipelines) need no write grant. erdo_update_page takes the same fields to add or replace grants later (send [] to clear one).Runtimes.
react-tailwind (default) preloads React 18, Tailwind, and the Erdo UI SDK (DatasetChart, DatasetTable, …) so your js can render components against query results immediately. none ships your HTML/CSS/JS with no runtime scripts injected — use it for fully hand-rolled pages.The window.erdo client
Every page gets window.erdo, a Promise-based client that talks to the Erdo platform over a sandboxed bridge. Everything it does runs as you (or, on a public page, as the anonymous viewer) under your RBAC.
Reading data
Every dataset is queried as the tabledata — the slug in the call selects which dataset, and the SQL addresses its rows as FROM data, regardless of backend. The dialect follows the storage: file datasets (CSV/Excel) use DuckDB SQL, synced integration datasets use ClickHouse SQL. Both support WHERE / GROUP BY / ORDER BY / CAST / aggregations.
getDatasetRefreshStatus(datasetIds) accepts 1–100 dataset UUIDs. The authenticated
host supplies the saved Page identity; the server validates access to every requested
dataset before returning any state. A Page-only shared viewer can read this safe
projection without direct job access. It includes configuration presence, enabled
schedule/timezone, next run, last refresh and current execution status, plus an opaque
job ID for a maintainer link. It excludes recipes, job parameters, credentials and raw
errors. This method is unavailable in public Pages and standalone capture scripts.
Reading it never starts research or consumes provider credits. running means the
execution is open and can include a run waiting for approval; it does not establish
active work. Direct maintainers to the linked automation for current approval details.
Viewing status does not grant approval or manual-run permission.
runAsk accepts optional flat invocation data for a saved Ask. Values may be strings,
finite numbers, booleans, or null; keys must be simple identifiers no longer than 64
characters and the serialized object is limited to 4096 characters. The data is appended
to the saved prompt under invocation-data and is untrusted input: the Ask should require
corroboration and must not treat it as an instruction or use it to override the saved
prompt, configured agent, pinned knowledge, or datasets. Nested objects, arrays,
undefined, non-finite numbers, invalid keys, and oversized payloads are rejected before
the new thread is created. Omitting parameters preserves the normal saved Ask behavior.
By default runAsk navigates to the new thread; pass navigate: false to start the
saved Ask in the background. The background result includes the new thread_id after
the launch is accepted; it does not mean the work has finished. Both modes use the
saved prompt, agent, datasets and pinned knowledge. Keep progress and results on
your page by reading the workflow’s saved data. Every configured dataset must be
available and confirmed on the new thread before the saved Ask runs. A missing
dataset or failed verification rejects the launch.
Required pinned knowledge and agent skills are loaded before generation. If a
required context read fails, the run stops with an error. A background launch
can already have returned its thread_id, so keep checking the saved workflow
status and show its failure as well as its progress and results.
The embedded Ask component accepts the same data through its parameters prop, for
example: <Ask id="your-ask-uuid" parameters={{ request_id: "your-request-uuid" }} />.
For navigate: false, the page and erdo shortcut run use the same saved-selector
launch on POST /v1/ask. The server loads the current saved configuration and
verifies context before acknowledging startup. The returned thread ID identifies
a fresh invocation; it does not mean the workflow completed. Interactive navigation
keeps the chat’s initial-message streaming handoff.
Rendering results — reach for the UI components first
Thereact-tailwind runtime preloads the Erdo UI components as globals, so a table, chart, or KPI should be one of these — not hand-rolled markup. They share the platform’s formatting, theming (light/dark), loading and empty states, so a page built from them looks native and stays consistent as the design system evolves.
The three high-level components fetch their own data — give them the dataset slug and (optionally) a SQL query, always with invocationId="iframe":
erdo.queryAsObjects and pass the array to the lower-level components (BarChart, LineChart, ComboChart, PieChart, ScatterChart, HeatmapChart) — each takes data, a displayConfig ({ seriesKey: { label, color } }), and a dataConfig ({ chartType, xAxis, yAxes, series }), plus stacked for stacked bars.
ComboChart puts a volume measure and a rate on one chart, each with its own axis — bars for counts against the left axis, a smoothed line for a rate against the right:
type (bar, line, or area) and, via axisIndex, which yAxes entry it’s measured against — 0 for the left axis, 1 for the right.
Sorting is expressed in the query, not the table.
DatasetTable renders rows in the order the query returns them and has no clickable-header sorting — to let viewers re-sort, render a small sort control whose state rewrites the sqlQuery’s ORDER BY (keying the component on the sort choice re-fetches). The same applies to filtering: push it into WHERE, or wire the page filter bar through erdo.getFilters().Reading the attention feed
A page can read the attention feed — the digests, choices and escalations Erdo has raised for a human — and filter it to what each item is about. That is what turns a list of your campaigns into a list that says how many actions Erdo is currently proposing on each one.google_ads_campaign, meta_ads_campaign, google_ads_ad_group, google_ads_ad, page, dataset and workstream; the id is the provider’s own id, the page’s artifact UUID, or the dataset/workstream slug. Pass subject_kind alone for every item about any subject of that kind. Omit organization to read the organization the page belongs to.
Each item carries what a reader needs to decide it without leaving the page: body_markdown is the text a human reads, expires_at is the deadline after which the safe default applies, answer holds whatever has already been recorded, and payload is the item’s own kind-specific shape — a choice’s payload.questions[] (each { id, label, type, options[], multi_select }), an escalation’s payload.proposed_answer and payload.safe_default.
Deciding an item from the page
erdo.respondAttentionItem asks the viewer to confirm, then answers, dismisses or acknowledges one item, so a dashboard can go down a list of campaigns and settle each proposal inline. The confirmation belongs to the parent Erdo page, not artifact-authored code; cancelling rejects the promise without sending a mutation.
{ item: { slug, status, answered_at, expires_at } }. Answering a choice whose options map to experiment variants records your pick as a human comparison in the calibration ledger, exactly as answering it inside Erdo does — both go through the same path. To flag one or more of a choice’s variants as broken, pass defects: [{ variant_key, reason }], on its own or alongside an answer.
organization follows the same rule as the read, so a manager can decide a client’s item from their own workspace. An item that is already answered, dismissed or past its expiry rejects rather than being overwritten — the safe default is the answer of record once the deadline passes.
Writing data
Two governed write paths — pick by who’s writing:Per-page state (KV)
erdo.kv (aliased as erdo.collections for already-published pages) is per-page key/value state with two partitions:
shared— one value for the whole page, visible to every viewer. Collaborative state (a counter, a guestbook, a board).viewer— private to the signed-in viewer (their draft, their preferences). Requires a signed-in user.
Realtime channels
erdo.channel(topic) is pub/sub between everyone viewing the page — the basis for multiplayer.
kv.change topic, so you can make state reactive without wiring your own messages:
Live datasets, filters, and theme
onDatasetChange tells an open page in the Erdo app
to re-read its connected data after rows are written or a refresh completes. The callback contains
dataset IDs and an update version, so your page can refetch the data it uses without reloading the iframe.
It does not contain the written rows. Nearby updates may be grouped into one
callback, and a repeated event may notify the page again. Continue to load data
when the page opens; notifications depend on an active connection.
KV vs. datasets — which store?
Both store structured values. The decision rule is about who reads it:The rule
If anything other than this page reads the value, it’s a dataset. If only the page reads it, it’s KV.
- Dataset — leads from a form, events, anything an agent will analyze, anything another page or report consumes, anything that needs schema/queries/joins. Write with
insertRowsor adataset.writepipeline. - KV (collection) — page-private memory: UI state, a draft, a shared scratchpad, a small leaderboard that only this page renders. Capped and key/value only.
A reactive multiplayer example
A shared counter every viewer sees update live, built from two primitives — shared KV + thekv.change channel:
Auth & sharing
- Private by default. A new page is visible only to you and anyone the page’s thread is shared with. Set
public: true(on deploy or update) for a view-only public link at/p/:id. - Identity is the signed-in viewer.
window.erdocalls run as the authenticated user; the page can never impersonate someone — identity comes from your token, server-side. - Reads are viewer-authorized. A page can only read datasets you granted it (
dataset_slugs), and only viewers who can see the page can read its data and shared state. On a public page, anonymous visitors get view/read access to public content and shared state. - Writes are RBAC-checked. Writing the page’s own private KV store requires VIEW on the page plus a signed-in identity. Writing a named (cross-page) KV store or appending to a dataset additionally requires an explicit EDIT grant the page holds — declared at deploy via
writable_kv_slugs/writable_dataset_slugs. Org membership alone is not enough. - One permission model. Pages reuse Erdo’s standard RBAC (
view/edit) — no separate app-level permission system.
Limits
Pages run today’s artifact shape (HTML/CSS/JS) in the Erdo page runtime — they are not arbitrary built bundles (Vite/webpack output). The runtime injects React/Tailwind/the bridge, validates the result, and wires data access. This keeps every page governed by the same validation and RBAC.
Next steps
MCP Server
Connect your MCP client and see the full tool reference, including the page deploy tools.
REST API
Deploy and manage pages over HTTP for CI and scripts.

