# DocPin documentation (all pages) > Every DocPin doc in one file. Index: https://docpin.app/llms.txt --- Source: https://docpin.app/docs/index.md --- # DocPin developer docs DocPin publishes HTML pages made by people and AI apps (Claude Artifacts, ChatGPT pages, exported reports, prototypes) to a link that looks like yours: `https://acme.docpin.it/q4-report`, or `https://docs.acme.com/q4-report` on your own domain. Pages can be public, password-protected or limited to certain people. Every update keeps the same link and the earlier versions. ## Ways to publish | From | How | Docs | | --- | --- | --- | | Claude, ChatGPT, any MCP client | Connect DocPin's MCP server, then ask: "publish this to DocPin" | [MCP](/docs/mcp.md) | | Scripts, CI, your own app | `POST https://api.docpin.app/v1/pages` with an API key | [REST API](/docs/api.md) | | A person | Drag a file, folder or `.zip` into the DocPin app | [docpin.app](https://docpin.app) | ## Building interactive and live pages - **[How pages run](/docs/page-runtime.md):** the sandbox, links, URL state (`?query` and `#hash` that sync with the address bar), and `localStorage`. - **[Page data](/docs/page-data.md):** a JSON store per page. The page reads it with `docpin.data()`, and you update it from the API, MCP or the app, without republishing. - **[Recipe: live dashboard](/docs/recipes/live-dashboard.md):** a complete page plus update scripts in curl, Node.js and Python. ## Links and addresses | Address | Example | Notes | | --- | --- | --- | | Workspace link | `https://acme.docpin.it/sales` | `{workspace}.docpin.it/{slug}`, or `/{board}/{slug}` in a board. | | Custom domain | `https://docs.acme.com/sales` | Pro. Replaces the workspace link. | | Short link | `https://docpin.it/Ab3dE7` | Never changes. The only link for unlisted pages. | | API | `https://api.docpin.app` | REST API. | | MCP | `https://mcp.docpin.app/mcp` | Remote MCP server with OAuth. | ## Plans Free includes 3 pages, workspace links, public pages and secret links, and page data. Pro ($8 a month) adds unlimited pages, custom domains, password and people-only pages, and a smaller badge. ## For AI models These docs are written to be read by models as well as people. Every page is plain Markdown at its `.md` address. [`/llms.txt`](/llms.txt) is the index, and [`/llms-full.txt`](/llms-full.txt) has every page in one file. --- Source: https://docpin.app/docs/page-data.md --- # Page data Every DocPin page has a **data store**: one JSON object, up to 1 MB, kept separately from the page's HTML. The page reads it in the browser. You change it from the API, from an AI app over MCP, or in the DocPin web app, and the page shows the new values without being republished. Use it for anything that changes more often than the page itself: dashboards, status boards, price lists, scores, schedules, "last updated" banners. ## How it fits together ``` your script / cron / CI ──PATCH /v1/pages/{id}/data──┐ Claude or ChatGPT ───────set_page_data (MCP)─────────┤ DocPin web app ──────────Data tab────────────────────┤ ▼ page data (JSON, ≤ 1 MB) │ docpin.data() / docpin.onData() ◀┘ (in the published page) ``` - Updating data **doesn't create a version**, and the page's version history isn't touched. - Data belongs to the page, not to a version: publishing a new version, or restoring an old one, keeps the current data. - Deleting the page deletes its data. - Available on Free and Pro. ## Read it in the page DocPin adds a small script to every published page, which provides a global `docpin` object. You don't include anything yourself. ### `docpin.data()` Returns a promise for the page's data object (`{}` if nothing has been set). ```html
…
``` After it resolves, `docpin.updatedAt` holds when the data last changed (ISO 8601), or `null`. ### `docpin.onData(callback, { every })` Reads the data now, then re-checks every `every` seconds (default 30, minimum 10) and calls `callback(data)` whenever it changed. Checks pause while the tab is hidden. It returns a function that stops checking. ```js const stop = docpin.onData((data) => render(data), { every: 60 }); // later, if you need to: stop(); ``` Use `onData` for anything left open on a screen, like a TV dashboard: new data appears without anyone reloading. ### Writing pages that use data - **Render from data, not from hard-coded numbers.** Keep the layout in the HTML and the values in the data store, so updates never need a republish. - **Always handle empty data.** A new page has `{}`. Show a clear empty state ("No data yet") rather than `undefined` or `NaN`. - **Treat every field as optional.** Someone may update one key and not another, so check each value before using it. - **Keep the shape flat and named.** Prefer `{ "revenue": 128400, "regions": [{ "name": "NZ", "value": 40 }] }` over deeply nested or positional data. - **Include your own timestamp if it matters to readers**, e.g. `"asOf": "2026-11-06T09:00:00Z"`. `docpin.updatedAt` is when the data was stored, which may differ from when the numbers were measured. ## Update it from code: the REST API Base URL: `https://api.docpin.app`. Authenticate with a workspace API key: `Authorization: Bearer dp_…`. Workspace owners create keys in the DocPin app under **Connections → API keys**. You need the page's **id**, which `GET /v1/pages` lists and every publish response includes as `doc.id`. | Method | Path | What it does | | --- | --- | --- | | `GET` | `/v1/pages/{id}/data` | Returns `{ "data": {…}, "updatedAt": "…" \| null }` | | `PATCH` | `/v1/pages/{id}/data` | **Merges** the top-level keys you send. A `null` value removes that key. | | `PUT` | `/v1/pages/{id}/data` | **Replaces** all of the page's data with the object you send. | The request body is the JSON object itself, not wrapped. Both writes return the full stored data and its new `updatedAt`. ```bash curl -X PATCH https://api.docpin.app/v1/pages/$PAGE_ID/data \ -H "Authorization: Bearer $DOCPIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"revenue": 128400, "asOf": "2026-11-06T09:00:00Z", "stale": null}' ``` ```json { "data": { "revenue": 128400, "asOf": "2026-11-06T09:00:00Z" }, "updatedAt": "2026-11-06T09:00:02.114Z" } ``` **Prefer `PATCH`.** It only touches the keys you send, so two jobs that each own different keys (say, sales and support numbers) can't overwrite each other. Use `PUT` when one job owns the whole object. ### On a schedule Any scheduler that can make an HTTPS request works: cron, GitHub Actions, a serverless function, Zapier or Make. For example, a GitHub Actions workflow: ```yaml on: schedule: [{ cron: "*/15 * * * *" }] jobs: update: runs-on: ubuntu-latest steps: - run: | REVENUE=$(curl -s https://example.com/internal/revenue) # your source curl -fsS -X PATCH https://api.docpin.app/v1/pages/${{ vars.PAGE_ID }}/data \ -H "Authorization: Bearer ${{ secrets.DOCPIN_API_KEY }}" \ -H "Content-Type: application/json" \ -d "{\"revenue\": $REVENUE, \"asOf\": \"$(date -u +%FT%TZ)\"}" ``` Keep the API key in your scheduler's secrets. It can publish and change every page in the workspace. ## Update it from an AI app: MCP With DocPin connected to Claude or ChatGPT (see [MCP](/docs/mcp.md)), two tools work with page data: | Tool | Arguments | What it does | | --- | --- | --- | | `get_page_data` | `slug` (and `board` if in one), or `id` | Returns the page's current data and when it changed. | | `set_page_data` | the page, `data` (object), optional `replace: true` | Merges `data` in (a `null` removes a key), or replaces everything with `replace: true`. | People can ask in plain language: "update the sales dashboard with these numbers", "clear the old regions from the dashboard data". An AI app should call `get_page_data` before replacing data, so it doesn't drop keys it didn't know about. Scheduled AI tasks (for example a daily Claude task that pulls figures from a connected tool) can call `set_page_data` the same way. ## Edit it by hand In the DocPin web app, open the page and choose the **Data** tab. Edit the JSON and choose **Save data**. Only the keys you changed are sent, so a scheduled update made while you were editing isn't undone. ## Limits | Limit | Value | | --- | --- | | Size | 1 MB per page, measured as JSON text. Larger writes get `413`. | | Shape | The top level must be a JSON object. Values can be any JSON. | | Keys | 1 to 200 characters. | | Writes | About 30 a minute per workspace. Over that you get `429`; wait a minute. | | Checks from pages | `docpin.onData` checks at most every 10 seconds per open page. | ## Who can see it **Page data is exactly as visible as the page.** It's served from the page's own address: - On a public page, anyone with the link can read the data. - On a password page, only people who entered the password can read it. - On a people-only page, only the people allowed in can read it. When you change a page's password or people list, links that people opened before the change stop working for the page and its data until they open the page again. Version previews never show the current data. **Never put secrets in page data**: no API keys, passwords, tokens or personal information you wouldn't put on the page itself. If a page needs data from a private source, have your server or scheduled job fetch it and push only the results. ## Errors Errors come back as `{ "error": "Plain-English message" }` with these statuses: | Status | Meaning | | --- | --- | | `400` | The body isn't a JSON object, or a key is empty or too long. | | `401` | Missing or invalid API key, or the person who created the key left the workspace. | | `403` | DocPin took the page down. | | `404` | No such page in this workspace (check the id). | | `409` | The data changed several times while saving. Retry. | | `413` | Over 1 MB. | | `429` | Too many writes. Wait a minute. | ## Related - [Live dashboard recipe](/docs/recipes/live-dashboard.md): a complete page and update script to copy. - [How pages run](/docs/page-runtime.md): links, URL state and browser storage. - [REST API](/docs/api.md) and [MCP](/docs/mcp.md) references. --- Source: https://docpin.app/docs/page-runtime.md --- # How published pages run A DocPin page is your HTML, served inside a DocPin page at your link (`https://acme.docpin.it/plan`, `https://docs.acme.com/plan`, or a short link like `https://docpin.it/Ab3dE`). Knowing how that works lets you build interactive pages that behave well. ## The short version | You want to… | Do this | | --- | --- | | Keep state shareable (tabs, filters, selected item) | Read `location.search` / `location.hash`, write with `history.replaceState`. DocPin mirrors it into the link. | | Link to a section | `` and an element with `id="pricing"`. | | Remember a visitor's own choices | `localStorage` works, per page, per browser. | | Show numbers you update on a schedule | [Page data](/docs/page-data.md): `docpin.data()` / `docpin.onData()`. | | Link to another site | A normal ``. It opens in a new tab automatically. | | Load a library | From a public CDN with a ` ``` **Tips:** - Use `replaceState` for UI state like tabs and filters, so the back button isn't flooded. Use `pushState` only for things that feel like separate pages. - Keep state small: up to about 4,000 characters of query plus hash. - Use the URL for state people **share**. Use storage for state that's **personal** (a visitor's draft, a dismissed banner). - Don't put anything secret in the URL. It's shown in the address bar and stays in browser history. ## Browser storage `localStorage` and `sessionStorage` work, even though the sandbox normally blocks them: - **`localStorage`** is kept for this page in the visitor's browser, under the page's link (for example `acme.docpin.it`). It survives reloads and new versions of the page. - Each page has its own storage. Pages don't see each other's. - Up to about 1 MB per page. Past that, `setItem` throws `QuotaExceededError`. - It's still the visitor's browser: clearing site data removes it, and it doesn't follow them to another device. - **`sessionStorage`** lasts for the current visit only. The standard API works: `getItem`, `setItem`, `removeItem`, `clear`, `key(i)`, `length`, and `localStorage.name = value`. Values are strings, so use `JSON.stringify` and `JSON.parse` for objects. Storage is per visitor. To show the same data to everyone, use [page data](/docs/page-data.md). ## Link previews and icons When someone shares a page's link in Slack, iMessage, email or social media, the preview comes from DocPin, using what your page says about itself: | Your page's `` | Used for | | --- | --- | | `` | The preview title and the browser tab. | | `<meta name="description">` | The preview's description. | | `<meta property="og:image">` | The preview image. Use a file in your upload (`cover.png`) or an `https://` URL. | | `<link rel="icon">` | The browser tab icon. A file in your upload, an `https://` URL, or a small `data:` image (an emoji SVG works). | | `<meta name="theme-color">` | The browser's UI colour on mobile. | Without an `og:image`, DocPin draws a share card from the page's title and link. Private pages (password or people-only) always show a "Private page" card, with no title, description or image, because a preview shouldn't reveal what's inside. Secret links show the generated card but not your description or image. ```html <title>Q4 proposal for Acme ``` ## The `docpin` object DocPin's script adds a global `docpin` before your own scripts run: | Member | Description | | --- | --- | | `docpin.data()` | Promise of the page's data object. See [page data](/docs/page-data.md). | | `docpin.onData(cb, { every })` | Calls `cb(data)` now and whenever the data changes, checking every `every` seconds (default 30, minimum 10). Returns a stop function. | | `docpin.updatedAt` | When the data last changed (ISO 8601), after a read. | If your page might also be opened outside DocPin (for example, as a local file), check before using it: `if (window.docpin) { … }`. ## Checklist for AI-generated pages When an AI app writes a page for DocPin, it should: 1. Send one complete, self-contained HTML document, or `index.html` plus its files. 2. Keep any state worth sharing in the URL (`replaceState`), and read it on load. 3. Use `localStorage` only for per-visitor state, and wrap it in try/catch. 4. Read changing numbers from `docpin.data()` / `docpin.onData()` and show an empty state when there's no data. 5. Use plain `` links to other sites, never `location.href` to another site. 6. Avoid cookies, IndexedDB, service workers, and anything that needs the camera or location. 7. Keep secrets out of the HTML, the URL and the page data. 8. Give the page a clear `` and `<meta name="description">` for link previews, and optionally a favicon. --- Source: https://docpin.app/docs/recipes/live-dashboard.md --- # Recipe: a live dashboard Publish a dashboard once, then push new numbers to it on a schedule. People who have it open see the numbers change without reloading. You need a DocPin workspace and either an API key (**Connections → API keys**) or DocPin connected to Claude or ChatGPT. ## 1. The page A complete page. It reads everything from [page data](/docs/page-data.md), handles missing values, and re-checks every minute. ```html <!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Sales dashboard

Sales dashboard

Loading…

Revenue this month
–
New customers
–
RegionRevenue
No regions yet
``` It uses `textContent`, not `innerHTML`, for data values, so text in the data can't inject markup. ## 2. Publish it **From the API:** ```bash curl https://api.docpin.app/v1/pages \ -H "Authorization: Bearer $DOCPIN_API_KEY" \ -F file=@dashboard.html -F slug=sales ``` The response includes `url` (the link to share) and `doc.id`. Save `doc.id` as `PAGE_ID`. **From Claude or ChatGPT:** "Publish this to DocPin as sales." ## 3. Push numbers **From the API**, from cron, CI or any scheduler: ```bash curl -X PATCH https://api.docpin.app/v1/pages/$PAGE_ID/data \ -H "Authorization: Bearer $DOCPIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "revenue": 128400, "customers": 37, "regions": [{"name": "New Zealand", "revenue": 51200}, {"name": "Australia", "revenue": 77200}], "asOf": "2026-11-06T09:00:00Z" }' ``` **From Node.js:** ```js const res = await fetch(`https://api.docpin.app/v1/pages/${process.env.PAGE_ID}/data`, { method: "PATCH", headers: { Authorization: `Bearer ${process.env.DOCPIN_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ revenue: 128400, customers: 37, asOf: new Date().toISOString() }), }); if (!res.ok) throw new Error((await res.json()).error); ``` **From Python:** ```python import os, datetime, requests r = requests.patch( f"https://api.docpin.app/v1/pages/{os.environ['PAGE_ID']}/data", headers={"Authorization": f"Bearer {os.environ['DOCPIN_API_KEY']}"}, json={"revenue": 128400, "customers": 37, "asOf": datetime.datetime.now(datetime.timezone.utc).isoformat()}, ) r.raise_for_status() ``` **From Claude or ChatGPT:** "Update the data on the sales dashboard: revenue 128,400, 37 customers." The app calls `set_page_data`. ## 4. Check it - Open the page's link: the numbers show, and anyone with it open sees updates within a minute. - In the DocPin app, the page's **Data** tab shows the current JSON. - `GET /v1/pages/$PAGE_ID/data` returns it too. ## Variations - **Private dashboard:** make the page password-protected or people-only (Pro). The data is then just as private. - **Several sources:** have each job `PATCH` only its own keys (`sales`, `support`, `uptime`), so they never overwrite each other. - **History or charts:** keep a short array in the data, e.g. the last 30 daily values, and replace it each run. Store long history elsewhere: the limit is 1 MB per page. --- Source: https://docpin.app/docs/api.md --- # REST API Publish and manage pages from scripts, CI and your own apps. - **Base URL:** `https://api.docpin.app` - **Auth:** `Authorization: Bearer dp_…`. API keys belong to one workspace and act with the rights of the owner who created them; they stop working if that person leaves the workspace. Workspace owners create keys in the DocPin app under **Connections → API keys**. The full key is shown once. - **Format:** JSON in and out, except uploads, which can be `multipart/form-data`. - **Errors:** a non-2xx status with `{ "error": "Plain-English message" }`. - **Rate limits:** about 30 publishes a minute per workspace, and separately about 30 data writes a minute. Over that: `429`. Pages are identified by **id** (a UUID) in paths. `GET /v1/pages` lists them, and publishing returns `doc.id`. ## Publish a page `POST /v1/pages` Upload one HTML file, several files, or a `.zip` (up to 25 MB) as `multipart/form-data`, or send HTML as JSON. | Field | Type | Notes | | --- | --- | --- | | `file` | file(s) | `index.html`, other files, or one `.zip`. Multipart only. | | `html` | string | The whole HTML document. JSON only. | | `title` | string | Defaults to the document's ``. | | `slug` | string | The page's address, e.g. `sales` → `/sales`. **Reusing a slug publishes a new version of that page**: same link, earlier versions kept. | | `board` | string | Board slug to publish into. | | `unlisted` | boolean | Only the short link opens it, and it stays off listings. | | `access` | object | Who can open it: `{"kind":"public"}`, `{"kind":"password","password":"…"}`, or `{"kind":"people","people":["sam@acme.com","@acme.com"]}`. Private kinds need Pro. In multipart, send it as a JSON string. | ```bash curl https://api.docpin.app/v1/pages -H "Authorization: Bearer $DOCPIN_API_KEY" \ -F file=@index.html -F slug=q4-report curl https://api.docpin.app/v1/pages -H "Authorization: Bearer $DOCPIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"html": "<!doctype html><title>Hello

Hello

", "slug": "hello"}' ``` Response (`201` for a new page, `200` for a new version): ```json { "created": true, "url": "https://acme.docpin.it/hello", "prettyUrl": "https://acme.docpin.it/hello", "shortUrl": "https://docpin.it/Ab3dE7", "doc": { "id": "61fb0253-…", "title": "Hello", "slug": "hello", "board": null, "unlisted": false, "access": { "kind": "public" } }, "version": { "id": "…", "files": 1, "bytes": 52 } } ``` `url` is the link to share. ## Pages | Method | Path | Body | Description | | --- | --- | --- | --- | | `GET` | `/v1/pages` | | Lists pages, newest first: `id`, `title`, `slug`, `board`, `prettyUrl`, `shortUrl`, `access`, `views`, `updated`, `archived`. | | `PATCH` | `/v1/pages/{id}` | `title`, `slug`, `board`, `unlisted`, `listed`, `archived` | Changes a page. A new slug changes the link; the old link redirects. | | `DELETE` | `/v1/pages/{id}` | | Deletes the page, its files, versions and data. | | `PUT` | `/v1/pages/{id}/access` | an `access` object (above), or `{"kind":"inherit"}` | Changes who can open it, without changing the link. | | `GET` | `/v1/pages/{id}/views` | | View counts by day, and named viewers for people-only pages. | ## Page data A JSON object per page that the published page reads with `docpin.data()`. See [Page data](/docs/page-data.md). | Method | Path | Body | Description | | --- | --- | --- | --- | | `GET` | `/v1/pages/{id}/data` | | `{ "data": {…}, "updatedAt": "…" \| null }` | | `PATCH` | `/v1/pages/{id}/data` | JSON object | Merges top-level keys; `null` removes a key. Returns the full data. | | `PUT` | `/v1/pages/{id}/data` | JSON object | Replaces everything. Returns the full data. | Up to 1 MB per page, measured as JSON text. ## Versions Every publish to the same slug is kept. | Method | Path | Description | | --- | --- | --- | | `GET` | `/v1/pages/{id}/versions` | Newest first, with `number`, `publishedAt`, `via`, `by`, and `current`. | | `POST` | `/v1/pages/{id}/versions/{versionId}/preview` | `{ "url": "…" }`: a private preview link that works for an hour. | | `POST` | `/v1/pages/{id}/versions/{versionId}/restore` | Makes that version live again behind the same links. Newer versions are kept. | ## Boards Boards group pages under one landing page, like `/team-updates`. | Method | Path | Body | Description | | --- | --- | --- | --- | | `GET` | `/v1/boards` | | Lists boards. | | `POST` | `/v1/boards` | `title`, optional `slug`, `description` | Creates a board. | | `PATCH` | `/v1/boards/{slug}` | `title`, `slug`, `description` | Changes a board. | | `PUT` | `/v1/boards/{slug}/access` | an `access` object | Who can open the board and its pages. | | `DELETE` | `/v1/boards/{slug}` | | Deletes the board. Its pages are kept unless you add `?pages=delete`. | ## Status codes | Status | Meaning | | --- | --- | | `200` / `201` | OK / created. | | `400` | Something in the request is wrong; the message says what. | | `401` | Missing or invalid key, or the key's creator left the workspace. | | `403` | Not allowed: a Pro feature on Free, an owner-only action, or a taken-down page. | | `404` | Not found in this workspace. | | `409` | Conflict, e.g. a slug already in use. | | `413` | Too large. | | `415` | Unsupported content type for publishing. | | `429` | Rate limited. Wait a minute. | --- Source: https://docpin.app/docs/mcp.md --- # MCP: DocPin for AI apps DocPin is a remote [Model Context Protocol](https://modelcontextprotocol.io) server. Once it's connected, an AI app like Claude or ChatGPT can publish pages, update them, manage who can open them, and push data to them. - **Server URL:** `https://mcp.docpin.app/mcp` (streamable HTTP) - **Auth:** OAuth. The person signs in to DocPin and picks one workspace; the app then acts in that workspace with their rights. ## Connect **Claude:** Customize → Connectors → Add → Add custom connector. Name it DocPin, paste the server URL, then Connect and sign in. **ChatGPT:** chatgpt.com/plugins → Add → Add custom MCP server. Name it DocPin, keep OAuth, paste the server URL, create it, then Connect DocPin. In a chat, type `@DocPin`. **Other MCP clients:** add a remote server with the URL above. The client discovers OAuth through the server's `/.well-known` documents and registers itself. ## Tools Pages are referred to by `slug` (plus `board` if the page is in one) or by `id` from `list_pages`. | Tool | What it does | | --- | --- | | `publish_page` | Publishes HTML (`html`) or several files (`files`: `[{path, content}]` with an `index.html`) and returns the links. **Reusing a `slug` publishes a new version behind the same link.** Optional `title`, `board`, `unlisted`, `access`. | | `list_pages` | Pages with their links and slugs, newest first. Optional `board`. | | `rename_page` | New title and/or slug. Old links redirect, and the short link never changes. | | `move_page` | Moves a page into or out of a board. | | `archive_page` | Hides a page from the page list without deleting it (`archived: false` brings it back). | | `delete_page` | Deletes a page. Only when the person explicitly asks. | | `set_access` | Who can open a page or board: `public`, `password`, `people` (emails and `@domains`), or `inherit`. Private pages need Pro. | | `list_versions` / `restore_version` | Version history, and putting an earlier version live again. | | `get_page_views` | How often a page was opened, and by whom on people-only pages. | | `get_page_data` | A page's [data](/docs/page-data.md): the JSON object it reads with `docpin.data()`. | | `set_page_data` | Updates a page's data without republishing. Merges by default (`null` removes a key); `replace: true` replaces everything. | | `list_boards` / `create_board` / `update_board` / `delete_board` | Boards: landing pages that group related pages. | The server also returns instructions on connect that summarise how published pages run. They're the same rules as [How pages run](/docs/page-runtime.md). ## Building pages with an AI app For a page that's interactive or updated over time, a model should: 1. **Write the page to read from page data.** Lay out the HTML, then fill values from `docpin.data()`, or `docpin.onData()` for pages left open. Always handle empty data. 2. **Publish it** with `publish_page` and a clear `slug`. 3. **Fill in the data** with `set_page_data`. Use `get_page_data` first when changing an existing page's data, so no keys are lost. 4. **Update later** with `set_page_data` alone. Republish only when the layout changes, with `publish_page` and the same slug. 5. **Keep shareable state in the URL** (`history.replaceState`) and personal state in `localStorage`. **Example conversation:** > **Person:** Make me a dashboard of this week's support tickets and publish it to DocPin. > **Model:** *(writes HTML that renders from `docpin.onData`, calls `publish_page` with slug `support`, then `set_page_data` with `{ "open": 42, "closed": 118, "asOf": "…" }`)* It's live at acme.docpin.it/support. > **Person** *(the next day):* Update it: 37 open, 131 closed. > **Model:** *(calls `set_page_data` with `{ "open": 37, "closed": 131, "asOf": "…" }`)* Updated. Anyone with it open will see the new numbers within a minute. ## Notes for app builders - Tool results include structured content (`structuredContent`) alongside the text, for example links and ids from `publish_page`. - Errors come back as tool results with `isError: true` and a plain-English message to show the person, such as an upgrade prompt for Pro features. - `set_page_data` and publishing are each rate limited to about 30 a minute per workspace.