Skip to main content

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 the window.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. Connect the server first (see MCP Quick Start), then ask your agent to deploy a page — it calls erdo_deploy_page for you.
1

Connect the MCP server

claude mcp add erdo \
  --transport http \
  --url https://api.erdo.ai/mcp \
  --header "Authorization: Bearer YOUR_API_KEY"
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.
ToolWhat it does
erdo_deploy_pageDeploy a new HTML page/app. Returns its ID, editor URL, optional public URL, and structured validation results.
erdo_update_pageUpdate a deployed page. Fields are merged — send only js to fix a script without resending html/css.
erdo_validate_pageDry-run validation (HTML structure, JS/JSX syntax + runtime smoke checks, window.erdo usage, dataset-slug references) without deploying.
erdo_list_artifacts / erdo_get_artifactFind and inspect pages you’ve already deployed.

erdo_deploy_page inputs

FieldTypeNotes
titlestringPage title shown in Erdo.
htmlstringFull document or a fragment. With the default runtime, include a root element (e.g. <div id="root"></div>) and put React code in js.
cssstring?Optional stylesheet.
jsstring?Optional JavaScript/JSX. window.erdo is available; with react-tailwind, React 18 + Tailwind + Erdo UI components are loaded.
runtimestring?react-tailwind (default) or none.
dataset_slugsstring[]?Datasets the page reads via window.erdo.queryDataset. The page is granted read access to each — you must be able to view them. Unknown slugs are a hard error, not a silent drop.
writable_dataset_slugsstring[]?Datasets the page may append to via window.erdo.insertRows. The page is granted write (edit) access to each — you must hold edit yourself. List a dataset in both if the page reads and writes it.
kv_slugsstring[]?Named KV stores (collections) the page reads via erdo.kv.get/list with { kv: "slug" }. Granted read access on each. The page’s own private KV store needs no grant.
writable_kv_slugsstring[]?Named KV stores the page may write via erdo.kv.set/delete. Granted edit access on each — you must hold edit yourself.
publicbool?true makes the page publicly viewable (view-only) at its share URL. Default false: only you and people the page’s thread is shared with can view it.
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 table data — 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.
// Query a dataset you've been granted (returns columns + rows)
const { columns, rows, row_count } = await erdo.queryDataset("acme.sales", {
  query: "SELECT month, revenue FROM data ORDER BY month",
  limit: 1000,
});

// Same query, shaped as objects keyed by column name (usually what you want)
const records = await erdo.queryAsObjects("acme.sales", { limit: 100 });

// File datasets (CSV/Excel) with multiple sheets: pass resource_key to pick one.
// Pass filters to apply the page's filter-bar state server-side.
const sheet = await erdo.queryAsObjects("acme.workbook", {
  resource_key: "Q1",
  filters: erdo.getFilters(),
});

// Discover and inspect datasets
const { datasets } = await erdo.listDatasets({ limit: 50 });
const dataset = await erdo.getDataset("acme.sales");   // schema, columns, resources

// Other resources you can dereference by id
await erdo.getResource("knowledge_object", id);
await erdo.getKnowledgeObject(id);   // { object, links, backlinks, related_objects }
await erdo.getAsk(id);               // a saved shortcut/ask
await erdo.runAsk(id);               // open it in a new thread
const { artifacts } = await erdo.listArtifacts({ type: "html_page", limit: 20 });

Writing data

Two governed write paths — pick by who’s writing:
// 1. insertRows — direct, RBAC-checked append. Needs a SIGNED-IN viewer and a
//    page deployed with this dataset in writable_dataset_slugs.
await erdo.insertRows("acme.leads", [
  { name: "Ada", email: "[email protected]" },
], { key_column: "email" });          // → { rows_affected }

// 2. submitEvent — the pipeline path. Works for ANONYMOUS public-page visitors
//    too, so it's the right choice for public lead forms and actions.
const res = await erdo.submitEvent(pipelineId, { name, email }, { variant: "signup-form" });
// → { ok, status, body }
insertRows requires a signed-in viewer with a write grant (writable_dataset_slugs on deploy). It is not served for anonymous visitors — a public page capturing leads from logged-out users must use submitEvent into a dataset.write pipeline, which carries its own trusted-intermediary identity. Use insertRows for internal/authenticated tools; use submitEvent for public capture.

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.
// Read (direct, viewer-authorized)
const { value, found, updated_at } = await erdo.kv.get("theme", { partition: "viewer" });
const { items } = await erdo.kv.list({ prefix: "todo:", partition: "shared" });

// Write (direct, RBAC-checked)
await erdo.kv.set("theme", { mode: "dark" }, { partition: "viewer" });
await erdo.kv.delete("todo:42", { partition: "shared" });

// Pass { kv: "slug" } to target a named, org-level store
await erdo.kv.set("count", 1, { partition: "shared", collection: "leaderboard" });
Every write needs a signed-in viewer. Both viewer and shared writes require a verified identity — an anonymous visitor to a public page can read shared state but cannot write it (until anonymous identity ships). The page’s own VIEW access is the gate; for named KV stores an explicit EDIT grant is additionally required (see Auth & sharing).

Realtime channels

erdo.channel(topic) is pub/sub between everyone viewing the page — the basis for multiplayer.
const chan = erdo.channel("cursors");

// Subscribe; returns an unsubscribe function
const off = chan.subscribe((msg) => render(msg));

// Publish to other viewers
await chan.publish({ x, y, user: "Ada" });

// later
off();
Shared-KV mutations automatically broadcast on the reserved kv.change topic, so you can make state reactive without wiring your own messages:
erdo.channel("kv.change").subscribe(({ kv, key }) => {
  // re-read the shared value that just changed
  erdo.kv.get(key, { partition: "shared" }).then(applyUpdate);
});

Live datasets, filters, and theme

// Live row/event streams (open a subscription, then listen; clean up on unmount)
await erdo.subscribeLiveDataset(datasetId);
const offLive = erdo.onLiveEvent((evt) => append(evt));   // { datasetId, payload }
// later: offLive(); await erdo.unsubscribeLiveDataset(datasetId);

const filters = erdo.getFilters();                 // current filter-bar state
const offFilter = erdo.onFilterChange((f) => refetch(f));

// Scheduled/agent dataset refreshes — re-query when one lands
erdo.getDatasetUpdateVersion();                    // monotonic version token
const offData = erdo.onDatasetChange((e) => refetch()); // { version, datasetIds, updatedAt }

erdo.getTheme();                                   // 'light' | 'dark'
const offTheme = erdo.onThemeChange((t) => setTheme(t));

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 insertRows or a dataset.write pipeline.
  • 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.
When in doubt, prefer a dataset — KV stores are deliberately small and page-scoped.

A reactive multiplayer example

A shared counter every viewer sees update live, built from two primitives — shared KV + the kv.change channel:
async function render() {
  const { value } = await erdo.kv.get("count", { partition: "shared" });
  document.getElementById("count").textContent = value ?? 0;
}

document.getElementById("inc").onclick = async () => {
  const { value } = await erdo.kv.get("count", { partition: "shared" });
  await erdo.kv.set("count", (value ?? 0) + 1, { partition: "shared" });
};

// Any viewer's write re-renders for everyone
erdo.channel("kv.change").subscribe(({ key }) => {
  if (key === "count") render();
});

render();

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.erdo calls 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

LimitValue
Collection value size64 KB per item
Collection keys1,000 per partition
Event body size1 MB per submission
Viewer write rate (events)300 / minute sustained, burst 60
Page contentHTML + CSS + JS (single page; multi-file bundles are not supported in v1)
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.