> ## Documentation Index
> Fetch the complete documentation index at: https://agentheya.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom Dashboard Pages

> Build extra dashboard pages with json-render — and wire them to live data, values your agent pushes, a private per-buyer scratchpad, and settings the agent reads.

A **custom dashboard page** is an extra, owner-authored screen on the Agent Dashboard, built entirely with [json-render](/generative-ui). Beyond drawing static cards and tables, a custom page can move **data** in four directions: pull live values on demand, display values the **agent** pushes, give **each buyer** a private editable scratchpad, and expose **settings** the agent reads at runtime.

This page is the complete reference for those four data mechanisms — what to declare, how the data flows, and a worked read / write / update / create example for each.

<Note>
  This page assumes you already know the json-render **component and binding model** — `card`, `list`, `stat`, `input`, `$state`, `$bindState`, `repeat`, `on.press` actions, the *"every prop key must be present"* rule, etc. All of that lives in **[Generative UI](/generative-ui)** and applies here unchanged. This page only covers what is **unique to custom dashboard pages**: the `data_sources`, `data_files`, and `settings` channels.
</Note>

<Info>
  Custom dashboard pages are a **Pro / Business** feature. On the Solo plan the editor is unavailable.
</Info>

## Where a page lives and who sees it

A page is one entry in your agent's `custom_dashboard_pages` list. Each entry has:

| Field          | Meaning                                                                                                |
| -------------- | ------------------------------------------------------------------------------------------------------ |
| `id`           | Stable slug, e.g. `study-planner`. **Immutable** — changing it orphans every buyer's saved scratchpad. |
| `title`        | Display name. Unique per agent (case-insensitive).                                                     |
| `enabled`      | `false` hides it from the sidebar. New pages start disabled.                                           |
| `visible_to`   | `owner` (only you) or `both` (you **and** buyers).                                                     |
| `spec`         | The json-render spec — `{ root, elements, state }`.                                                    |
| `data_sources` | Live-data + agent-push panels (this page).                                                             |
| `data_files`   | Per-buyer scratchpad slots (this page).                                                                |
| `settings`     | Owner knobs the agent reads (this page).                                                               |

<Steps>
  <Step title="Create & order on /web">
    The **Dashboard pages** card on the [Web](/sidebar-menu/web) page is where you add a page (give it a name), toggle it on/off, and drag to reorder. The row order is the order pages appear under **SETTINGS** in each buyer's sidebar.
  </Step>

  <Step title="Build at /custom-pages/[id]">
    The per-page editor is where you write the json-render `spec` and declare `data_sources`, `data_files`, and `settings`. A live preview renders as you type.
  </Step>

  <Step title="Buyers open it at /agent/[name]/dashboard/custom/[id]">
    A disabled page, an `owner`-only page viewed by a buyer, or a missing id all **redirect to the dashboard's Details page** rather than 404.
  </Step>
</Steps>

## The four data channels at a glance

Everything below writes into — or reads out of — the spec's **state** at a [JSON Pointer](https://datatracker.ietf.org/doc/html/rfc6901) path (`/headlines`, `/settings/paused`). The spec then reads state with `{ "$state": "/path" }`, binds editable inputs with `{ "$bindState": "/path" }`, and persists with a button action — exactly as in [Generative UI](/generative-ui#making-it-dynamic).

| Channel                                                         | Declared in      | Written by                                                                                                | Read by the page                    | Read by the **agent**?              |
| --------------------------------------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------- | ----------------------------------- |
| **Live pull** (`data_sources`, `tool`/`code`/`url`/`file`/`db`) | `data_sources[]` | a curated tool, sandboxed code, an HTTPS API, a per-agent data file, or a read-only DB query — per viewer | `{ "$state": "/target" }`           | ❌ never                             |
| **Agent push** (`data_sources`, `writable`)                     | `data_sources[]` | the **agent**, via `update_widget_data`                                                                   | `{ "$state": "/target" }`           | ❌ (it wrote it)                     |
| **Buyer scratchpad** (`data_files`)                             | `data_files[]`   | each **buyer** (private, copy-on-write)                                                                   | `{ "$bindState": "/target" }`       | ❌ **never**                         |
| **Owner settings** (`settings`)                                 | `settings[]`     | the **owner** (on the page or in the editor)                                                              | `{ "$bindState": "/settings/key" }` | ✅ `get_dashboard_settings` + prompt |

<Warning>
  **The agent only ever sees `settings`.** Live-pull results, agent-pushed values, and buyer scratchpads are **display / UI state** — they are not injected into the agent's prompt and there is no tool to read them. If the agent needs a value, it must be a **setting**, or you must collect it through a conversation ([`collect_form`](/builtin-tools/collect_form) / [`render_ui`](/builtin-tools/render_ui)).
</Warning>

<Note>
  **Free-text prose inputs are disabled on dashboards.** A dashboard is not a scratchpad.

  **Not allowed** (rendered **disabled**, with a `disabled` tag) on a custom dashboard page — `input` of:

  * `kind: "text"`
  * `kind: "textarea"`

  **Allowed** (these hold a constrained, sanitised value) — `input` of `kind: "email"`, `"tel"`, `"url"`, `"password"`, `"number"`, `"integer"`, `"select"`, `"checkbox"`. Their values are cleaned on save (control chars stripped, length-capped).

  To collect free-form prose from a user, use a [chat form](/builtin-tools/collect_form) — never a dashboard input. See [Per-buyer scratchpad](#3-per-buyer-scratchpad-data_files).
</Note>

How data lands in state, for every channel:

* **`result_target`** — a JSON Pointer (`/headlines`) saying *where in state* the value is written. Required; must start with `/`.
* **`result_path`** *(pull only)* — a **dotted** path into the tool/code result to extract *before* writing (e.g. `rows`, or `data.items`). Note: **dotted**, not a JSON Pointer.
* Writes are **destructive** — the value at `result_target` is replaced, not merged.

***

## 1. Live data (pull)

A pull `data_source` fetches data **when a viewer opens the page** (and on an optional refresh interval). Each viewer's browser sends only an opaque `action_id` to the server; the tool name, params, and code **never reach the browser**. The result is written to state at `result_target`, where your spec reads it.

There are five kinds — set exactly **one** per source: a **tool** call, a **code** snippet, an HTTPS **url**, a per-agent data **file**, or a read-only **db** query. The first two are shown below; `url`, `file` and `db` work identically here and are documented with simple + full examples on **[Agent Dashboard — Live Data](/agent-dashboard)**.

### 1a. Tool-based source

```json theme={null}
{
  "tool": "read_rss_feed",
  "params": { "feed_url": "https://hnrss.org/frontpage", "limit": 5 },
  "result_target": "/headlines",
  "result_path": "rows",
  "refresh_interval_sec": 900
}
```

**Allowed tools** (read-only): `web_fetch`, `web_search`, `read_rss_feed`, `read_recent_tweets`, `x_read_mentions`, `x_search_tweets`, `calculate`, **or any of your own [code tools](/sidebar-menu/tools)**. `params` are **owner-fixed** — the browser can't change them.

Reading it in the spec — because `table.rows` and `key_value.items` are literal-only, render a dynamic array with `repeat` + `$item` (see [Repeating over a list](/generative-ui#repeating-over-a-list)):

```json theme={null}
{
  "root": "card",
  "elements": {
    "card":  { "type": "card", "props": { "title": "Latest headlines", "subtitle": null }, "children": ["feed"] },
    "feed":  { "type": "list", "props": { "direction": "vertical", "gap": "sm" }, "repeat": { "statePath": "/headlines", "key": "id" }, "children": ["row"] },
    "row":   { "type": "alert", "props": { "title": null, "message": { "$item": "title" }, "variant": "default" }, "children": [] }
  },
  "state": { "headlines": [] }
}
```

<Note>
  Seed the `result_target` path in `state` (here `"headlines": []`) so the panel renders cleanly **before** the first fetch returns.
</Note>

### 1b. Code-based source

Use `code` instead of `tool` to compute the value in a sandbox (`language` is `"javascript"` or `"python"`). The function returns a value; `result_path` extracts from it; `result_target` places it in state.

```json theme={null}
{
  "code": "function handle(params, ctx) { return { total: 3, items: [{ id: 'a', label: 'Open', value: 2 }, { id: 'b', label: 'Closed', value: 1 }] }; }",
  "language": "javascript",
  "result_target": "/tickets",
  "result_path": "items",
  "refresh_interval_sec": 300
}
```

Then a single scalar reads cleanly with `$state`:

```json theme={null}
{
  "root": "card",
  "elements": {
    "card": { "type": "card", "props": { "title": "Tickets", "subtitle": null }, "children": ["open"] },
    "open": { "type": "stat", "props": { "label": "Open tickets", "value": { "$state": "/tickets/0/value" }, "change": null, "changeType": null, "prefix": null, "suffix": null }, "children": [] }
  },
  "state": { "tickets": [] }
}
```

### 1c. HTTPS endpoint, data file, and database sources

Three more pull kinds cover the common "just show me my data" cases without a tool or sandbox:

```json theme={null}
[
  { "url": "https://api.example.com/stats.json", "headers": { "Authorization": "Bearer …" }, "result_target": "/stats", "refresh_interval_sec": 300 },
  { "file": "metrics.json", "result_target": "/metrics" },
  { "db": { "engine": "postgres", "url": "postgresql://…", "query": "SELECT day AS label, total AS value FROM daily" }, "result_target": "/chart_data" }
]
```

* **`url`** — the server fetches a JSON/XML/text endpoint directly (SSRF-guarded; `headers` may hold an API key and stay server-side; `format`: auto | json | xml | text).
* **`file`** — a `.json`/`.xml` file in this agent's `dashboard_data/` folder, which the **agent keeps current** via the `write_dashboard_file` tool (whole document or one JSON-Pointer field at a time). Free.
* **`db`** — a read-only Postgres query returning rows; runs in a `READ ONLY` transaction with row/time caps. Free.

Full walk-throughs — including XML parsing, agent write patterns, and multi-query Postgres dashboards — are on **[Agent Dashboard — Live Data](/agent-dashboard)**.

<Note>
  **Cost & caching.** Pull sources run **per viewer** and may cost credits (a tool call, a sandbox run, or a flat fee per `url` fetch — `file`/`db` are free), so a server-side cache collapses all viewers + refreshes within `refresh_interval_sec` into one upstream call. Pick the **longest** interval you can tolerate. `refresh_interval_sec` is clamped to **60–86400 s** (default **120 s**). Non-owner viewers are blocked when the owner's credit balance is at the floor.
</Note>

***

## 2. Agent push (writable panels)

A **writable** `data_source` is a panel whose value the **agent** sets — perfect for a KPI, a status counter, or a chart the agent refreshes from a scheduled run or its own work. No tool runs when a viewer loads it; the browser just reads the last value the agent stored.

**Declare** it with `writable: true` and a stable `key` — and **no** `tool`/`code`/`params`:

```json theme={null}
{
  "writable": true,
  "key": "sales_by_day",
  "result_target": "/sales",
  "refresh_interval_sec": 120
}
```

**The agent writes** it with the built-in `update_widget_data` tool (auto-registered for you whenever the agent has writable panels), addressing the panel by its `key`:

```json theme={null}
{
  "key": "sales_by_day",
  "value": [
    { "id": "mon", "label": "Mon", "value": 12 },
    { "id": "tue", "label": "Tue", "value": 19 },
    { "id": "wed", "label": "Wed", "value": 15 }
  ]
}
```

**The page reads** it from `result_target` like any other state — e.g. a repeating bar of stats:

```json theme={null}
{
  "root": "card",
  "elements": {
    "card": { "type": "card", "props": { "title": "Sales this week", "subtitle": null }, "children": ["bars"] },
    "bars": { "type": "list", "props": { "direction": "horizontal", "gap": "md" }, "repeat": { "statePath": "/sales", "key": "id" }, "children": ["bar"] },
    "bar":  { "type": "stat", "props": { "label": { "$item": "label" }, "value": { "$item": "value" }, "change": null, "changeType": null, "prefix": null, "suffix": null }, "children": [] }
  },
  "state": { "sales": [] }
}
```

<Warning>
  The pushed value is **served to every viewer**, so push only display data — never secrets or raw buyer text. Values are **sanitized to inert JSON** (functions/symbols become `null`) and capped at **256 KB** total. Each push **overwrites** the previous value (no history). Steer the agent from your **Purpose** or **Rules**, e.g. *"After the nightly sync, call `update_widget_data` with key `sales_by_day`."*
</Warning>

***

## 3. Per-buyer scratchpad (`data_files`)

A `data_files` slot gives **each buyer their own private, persistent, editable** value on the page — a checklist, a saved choice, a toggle. It is **copy-on-write**: your `seed` is the default until the buyer saves once, after which their copy is theirs forever (changing the seed later never touches an existing buyer's data). **The agent never reads these.**

<Warning>
  **Free-text prose inputs are disabled on a dashboard.** A dashboard is not a scratchpad, so **`input` of `kind: "text"` and `kind: "textarea"`** render **disabled** on the live page and in the editor preview (with a `disabled` tag). Every other input kind is allowed and works normally in a scratchpad slot — `email`, `tel`, `url`, `password`, `number`/`integer`, `select`, `checkbox` — and their values are sanitised on save. If you need to capture free-form prose from a user, collect it in a [chat form](/builtin-tools/collect_form) (where the value is actually delivered to the agent), not on a dashboard page.
</Warning>

**Declare** a slot:

```json theme={null}
{
  "key": "planner",
  "result_target": "/planner",
  "label": "Study planner",
  "seed": { "goal": "", "notes": "", "done": false }
}
```

| Field           | Notes                                                           |
| --------------- | --------------------------------------------------------------- |
| `key`           | `[a-z0-9_]`, 1–40 chars, unique on the page. The save handle.   |
| `result_target` | JSON Pointer where the value loads into state.                  |
| `seed`          | Optional default, copied to the buyer on first save (≤ 256 KB). |
| `label`         | Optional UI hint.                                               |

**Read** (create): on load the slot resolves to the buyer's saved value, or the `seed` if they've never saved. The spec binds editable inputs with `$bindState` and saves with a button whose action is `submit` + `params.savePageData: true` (this is **repeatable** — not a chat submit; the form stays open):

```json theme={null}
{
  "root": "card",
  "elements": {
    "card":  { "type": "card", "props": { "title": "Study planner", "subtitle": "Private to you — only you can see and edit this." }, "children": ["goal", "notes", "done", "save"] },
    "goal":  { "type": "input", "props": { "kind": "text", "name": "goal", "label": "This week's goal", "placeholder": null, "required": false, "value": { "$bindState": "/planner/goal" }, "options": null, "min": null, "max": null }, "children": [] },
    "notes": { "type": "input", "props": { "kind": "textarea", "name": "notes", "label": "Notes", "placeholder": null, "required": false, "value": { "$bindState": "/planner/notes" }, "options": null, "min": null, "max": null }, "children": [] },
    "done":  { "type": "input", "props": { "kind": "checkbox", "name": "done", "label": "Done", "placeholder": null, "required": false, "value": { "$bindState": "/planner/done" }, "options": null, "min": null, "max": null }, "children": [] },
    "save":  { "type": "button", "props": { "label": "Save", "variant": "primary", "disabled": null }, "on": { "press": { "action": "submit", "params": { "savePageData": true } } }, "children": [] }
  },
  "state": { "planner": { "goal": "", "notes": "", "done": false } }
}
```

<Steps>
  <Step title="Create">Declare the slot with a `seed`. First load shows the seed.</Step>
  <Step title="Read">On every load the slot resolves to the buyer's own saved value (or the seed).</Step>
  <Step title="Update & save">The buyer edits `$bindState` inputs; clicking the `savePageData` button writes **their** copy. Clicking again overwrites it.</Step>
</Steps>

<Note>
  Saving collects the **current state of every declared `data_files` slot** on the page and writes each as the buyer's private copy. Without a save click, edits are lost on reload. Up to **20 slots** per page; each value ≤ 256 KB. When you (the owner) view the page, you are your own buyer — your saves go to your own copy.
</Note>

***

## 4. Owner settings (the agent reads these)

A `settings` entry is an **owner knob** — a toggle, number, text field, or select — that does two things: it can be edited on the page (by the **owner only**), and the **agent reads it** at runtime to change its behaviour. This is the one channel that crosses from the page into the agent.

**Declare** settings:

```json theme={null}
[
  { "key": "booking_paused", "kind": "toggle", "label": "Pause bookings", "value": false, "help": "When on, the agent stops taking new bookings." },
  { "key": "max_per_day",    "kind": "number", "label": "Max bookings / day", "value": 10 },
  { "key": "tone",           "kind": "select", "label": "Reply tone", "value": "friendly", "options": ["friendly", "formal", "brief"] }
]
```

| Field            | Notes                                                                                |
| ---------------- | ------------------------------------------------------------------------------------ |
| `key`            | `[a-z0-9_]`, 1–40 chars, unique on the page.                                         |
| `kind`           | `toggle` → boolean, `number` → number, `text` → string, `select` → one of `options`. |
| `value`          | Current value, coerced to `kind`.                                                    |
| `label` / `help` | Optional UI text.                                                                    |
| `options`        | Required for `select` (≤ 50).                                                        |

**Each setting seeds into state at `/settings/{key}`.** The agent reads them two ways:

1. **In its prompt** — enabled pages' settings are injected on every turn as a `<dashboard_settings>` block: `- booking_paused (Pause bookings): off`.
2. **On demand** — the `get_dashboard_settings` tool returns the live values:

```json theme={null}
// agent calls get_dashboard_settings  ->
{ "ok": true, "settings": { "booking_paused": false, "max_per_day": 10, "tone": "friendly" } }
// or for one key: get_dashboard_settings({ "key": "booking_paused" })  ->
{ "ok": true, "key": "booking_paused", "value": false }
```

**The owner edits them live** on the page: bind a control to `{ "$bindState": "/settings/KEY" }` and add a button with `params.saveSettings: true`. Saving is **owner-only** (a buyer gets a `403`); values are coerced to their `kind` and persisted to the agent config:

```json theme={null}
{
  "root": "card",
  "elements": {
    "card":   { "type": "card", "props": { "title": "Booking controls", "subtitle": "Owner only" }, "children": ["paused", "cap", "save"] },
    "paused": { "type": "input", "props": { "kind": "checkbox", "name": "booking_paused", "label": "Pause bookings", "placeholder": null, "required": false, "value": { "$bindState": "/settings/booking_paused" }, "options": null, "min": null, "max": null }, "children": [] },
    "cap":    { "type": "input", "props": { "kind": "integer", "name": "max_per_day", "label": "Max bookings / day", "placeholder": null, "required": false, "value": { "$bindState": "/settings/max_per_day" }, "options": null, "min": 0, "max": 100 }, "children": [] },
    "save":   { "type": "button", "props": { "label": "Save settings", "variant": "primary", "disabled": null }, "on": { "press": { "action": "submit", "params": { "saveSettings": true } } }, "children": [] }
  },
  "state": { "settings": { "booking_paused": false, "max_per_day": 10 } }
}
```

<Note>
  The agent reads the **new** value on its **next** turn (the prompt block and `get_dashboard_settings` both read live). Settings are per-page; up to **20** per page. Use `settings` (not a scratchpad) whenever the agent needs to act on the value.
</Note>

***

## Interactive actions (click → run)

Buttons on a custom page can also run work **on click** — the same `runCode` and `callTool` [server actions](/generative-ui#server-actions) documented for Generative UI. They write their result into state at a `target` pointer (or show a modal/toast), so you can build "Refresh", "Search", or "Recalculate" buttons that don't depend on a page reload. See [Generative UI → Server actions](/generative-ui#server-actions) for the full contract.

## A complete page (all four channels)

```json theme={null}
{
  "id": "ops-console",
  "title": "Ops console",
  "enabled": true,
  "visible_to": "both",
  "data_sources": [
    { "tool": "read_rss_feed", "params": { "feed_url": "https://status.example.com/feed", "limit": 3 }, "result_target": "/incidents", "result_path": "rows", "refresh_interval_sec": 600 },
    { "writable": true, "key": "queue_depth", "result_target": "/queue", "refresh_interval_sec": 60 }
  ],
  "data_files": [
    { "key": "notes", "result_target": "/notes", "label": "Shift notes", "seed": { "text": "" } }
  ],
  "settings": [
    { "key": "alerting", "kind": "toggle", "label": "Paging enabled", "value": true }
  ],
  "spec": {
    "root": "root",
    "elements": {
      "root":   { "type": "card", "props": { "title": "Ops console", "subtitle": null }, "children": ["queue", "incidents", "notes", "save", "ownerCard"] },
      "queue":  { "type": "stat", "props": { "label": "Queue depth (agent-pushed)", "value": { "$state": "/queue" }, "change": null, "changeType": null, "prefix": null, "suffix": null }, "children": [] },
      "incidents": { "type": "list", "props": { "direction": "vertical", "gap": "sm" }, "repeat": { "statePath": "/incidents", "key": "id" }, "children": ["incident"] },
      "incident": { "type": "alert", "props": { "title": null, "message": { "$item": "title" }, "variant": "warning" }, "children": [] },
      "notes":  { "type": "input", "props": { "kind": "textarea", "name": "notes", "label": "Shift notes (private to you)", "placeholder": null, "required": false, "value": { "$bindState": "/notes/text" }, "options": null, "min": null, "max": null }, "children": [] },
      "save":   { "type": "button", "props": { "label": "Save notes", "variant": "primary", "disabled": null }, "on": { "press": { "action": "submit", "params": { "savePageData": true } } }, "children": [] },
      "ownerCard": { "type": "card", "props": { "title": "Owner settings", "subtitle": null }, "visible": { "$state": "/settings/alerting", "neq": "__never__" }, "children": ["alerting", "saveSettings"] },
      "alerting": { "type": "input", "props": { "kind": "checkbox", "name": "alerting", "label": "Paging enabled", "placeholder": null, "required": false, "value": { "$bindState": "/settings/alerting" }, "options": null, "min": null, "max": null }, "children": [] },
      "saveSettings": { "type": "button", "props": { "label": "Save settings", "variant": "secondary", "disabled": null }, "on": { "press": { "action": "submit", "params": { "saveSettings": true } } }, "children": [] }
    },
    "state": { "incidents": [], "queue": 0, "notes": { "text": "" }, "settings": { "alerting": true } }
  }
}
```

## Limits & validation

| Channel            | Per-page cap | Key rule                                 | Other                                                                                           |
| ------------------ | ------------ | ---------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `data_sources`     | 12           | writable `key`: `[a-z0-9_]` 1–40, unique | `result_target` JSON Pointer; `refresh_interval_sec` 60–86400 (default 120); allowed tools only |
| `data_files`       | 20           | `key`: `[a-z0-9_]` 1–40, unique          | `seed` ≤ 256 KB; copy-on-write                                                                  |
| `settings`         | 20           | `key`: `[a-z0-9_]` 1–40, unique          | `kind` enum; `select` needs `options` (≤ 50)                                                    |
| agent-pushed value | —            | —                                        | sanitized inert JSON, ≤ 256 KB, overwrites                                                      |

The `spec` is validated against the json-render catalog on save — an invalid layout is rejected with a precise error. The tool/code/params of a `data_source` are minted into an opaque `action_id` and **stripped before anything reaches a browser**.

## Building a page with the AI helper

Instead of hand-writing the JSON, ask the [AI Helper](/ai-helper): the agent has a `build_custom_dashboard_page` tool that creates (or updates, by reusing the same `page_id`) a page with its `spec`, `data_sources`, `data_files`, and `settings` in one shot, runs the same validation, and enables it immediately. You can then fine-tune it in the editor.

## See also

* [Generative UI](/generative-ui) — the json-render component, binding, expression, `repeat`, and server-action reference (applies here unchanged).
* [Web](/sidebar-menu/web) — where pages are created, ordered, and toggled.
* [Custom tools](/sidebar-menu/tools) — your own code tools, usable as a `data_source`.
* [`render_ui`](/builtin-tools/render_ui) / [`collect_form`](/builtin-tools/collect_form) — collect input *in a conversation* (the agent **does** read these), as opposed to a page scratchpad (it doesn't).
