Hosted pages: reports, live dashboards & intake forms

At a glance: Ask your AI to publish_page and hdls hosts a real page at a stable URL — no front-end to build, no server to run. Three kinds:

  • Document — a generated report, plan, or proposal, served as sandboxed HTML.
  • Dashboard — live widgets that re-run as read-only aggregates on every view, so the numbers are always current.
  • Form — a public intake form that writes submissions straight back into a product (e.g. a new CRM lead), with the right columns locked server-side.

💬 Just ask

  • "Make me a public intake form for leads."
  • "Build a live dashboard of open tickets and give me a link to share."
  • "Publish my Q2 pipeline review as a report I can send to the team."

You don't call these tools yourself — just tell your assistant; the technical reference below is for when you want the details.

Every page gets a link you can share. Workspace pages are members-only; public pages and forms are reachable by anyone with the token link. publish_page, find_logo, list_pages, get_page live on the concierge at https://hdls.ai/api/mcp; update_page and unpublish_page appear there too, but only for admins.

New here? Get connected first, then come back — you'll be publishing pages by asking your assistant within a couple of minutes.


The mental model

A page is a hosted artifact pinned to your workspace (one tenant). hdls renders it for you and serves it at a stable link — you never deploy anything.

  • Sandboxed rendering. Documents and dashboards render in an isolated iframe with no access to the viewer's hdls session, cookies, or workspace. A shared report can't touch the reader's data.
  • Tenant isolation is automatic. Dashboards aggregate, and forms write, only within your workspace (enforced by Postgres row-level security — see Security & trust). You never pass a tenant_id — it's stamped server-side from your credential.
  • Two visibilities:
    • workspace — only signed-in members of your workspace can open it (via /p/<id>).
    • public — anyone with the link can open it (via /v/<token>).
  • Forms are always public by nature — the token link is the submit handle.

Who can publish what

ActionMinimum role
Publish a workspace document or dashboardmember
Publish a public document or dashboardadmin
Publish a form (always public)admin
update_page (title / HTML / visibility)admin
unpublish_page (revoke the link)admin
list_pages, get_page, find_logoreader

Roles run reader < member < admin < owner (see Teams). The update_page and unpublish_page tools don't even appear on your connection unless you're an admin.


Publishing a document (report / plan / proposal)

Generate the HTML with your AI, then host it. The result is sandboxed — safe to share.

Minimal workspace report (members only):

// tool: publish_page  (on https://hdls.ai/api/mcp)
{
  "title": "Q2 Pipeline Review",
  "kind": "document",
  "html": "<h1>Q2 Pipeline Review</h1><p>Closed-won up 18% QoQ…</p>"
}

Returns:

{
  "id": "9f3c…",
  "title": "Q2 Pipeline Review",
  "kind": "document",
  "visibility": "workspace",
  "url": "https://hdls.ai/p/9f3c…"
}

Public report with a share link and an expiry (admin):

{
  "title": "Acme Proposal",
  "kind": "document",
  "html": "<h1>Proposal for Acme</h1>…",
  "visibility": "public",
  "expires_in_days": 14
}

A public page also returns a publicUrl (the /v/<token> link). After expires_in_days, the link shows a "Link expired" notice.

Gotchas

  • A document needs non-empty html, or publishing fails.
  • kind is inferred: if you pass form it's a form, dashboard it's a dashboard, otherwise it's a document. Setting kind explicitly is optional but clearer.
  • To change a document later, use update_page with new html (admin).

Embed a company's real logo in a report by resolving it from a domain, URL, or email. find_logo constructs the image URLs — it doesn't fetch anything — so it's instant and safe.

// tool: find_logo
{ "company": "acme.com" }
{
  "domain": "acme.com",
  "logo": "https://logo.clearbit.com/acme.com",
  "favicon": "https://www.google.com/s2/favicons?domain=acme.com&sz=128",
  "candidates": ["https://logo.clearbit.com/acme.com", "https://acme.com/apple-touch-icon.png", "https://www.google.com/s2/favicons?domain=acme.com&sz=128", "https://acme.com/favicon.ico"]
}

logo is the brand mark; favicon always resolves as a fallback. Accepts acme.com, https://acme.com, or jo@acme.com. Drop the logo URL into your report's <img> tag.


Publishing a live dashboard

A dashboard is a set of widgets, each one a read-only aggregate over a product table. hdls re-runs every widget on each view, so the page is always current — it's never a snapshot. The whole thing renders server-side, sandboxed.

Each widget aggregates one table in one product schema. Three visualizations:

vizShowsNeeds
metrica single numbera metric (omit group_by)
bargrouped horizontal barsa metric + group_by
tablerows of dimension + valuea metric + group_by

Widget fields:

  • title — widget heading.
  • vizmetric | bar | table.
  • schema — the product, e.g. "crm".
  • table — the base table to aggregate, e.g. "deal".
  • metric{ fn, column? }. fn is count, sum, avg, min, or max. count needs no column; sum/avg/min/max require one.
  • group_by — dimension column for bar/table (e.g. "stage").
  • filters — optional WHERE filters (see below).
  • order_by{ column, dir } where dir is asc or desc.
  • limit — row cap (defaults to 50 per widget).

Example — a CRM dashboard (workspace-visible):

// tool: publish_page
{
  "title": "Sales Dashboard",
  "kind": "dashboard",
  "dashboard": {
    "widgets": [
      {
        "title": "Open Deals",
        "viz": "metric",
        "schema": "crm",
        "table": "deal",
        "metric": { "fn": "count" }
      },
      {
        "title": "Pipeline Value by Stage",
        "viz": "bar",
        "schema": "crm",
        "table": "deal",
        "metric": { "fn": "sum", "column": "value" },
        "group_by": "stage",
        "order_by": { "column": "value", "dir": "desc" }
      },
      {
        "title": "Deals Created This Quarter",
        "viz": "metric",
        "schema": "crm",
        "table": "deal",
        "metric": { "fn": "count" },
        "filters": [
          { "column": "created_at", "op": "gte", "value": "2026-04-01" }
        ]
      }
    ]
  }
}

Make it public (admin only) by adding "visibility": "public".

Filters

filters accepts either form:

  • Equality map{ "stage": "won" } (each key AND-ed).
  • Condition array[{ "column": "amount", "op": "gte", "value": 1000 }].

Available op values: eq, neq, gt, gte, lt, lte, like, ilike, in, is_null, is_not_null, between. Use gte/lte/between for date ranges.

Gotchas

  • A dashboard needs at least one widget.
  • Each widget's schema + table is validated when you publish — a typo'd or non-existent table fails fast.
  • If a widget can't load at view time, that one widget shows an inline "Couldn't load" message; the rest of the dashboard still renders.
  • Dashboards are read-only aggregates — they can't expose individual rows, only counts/sums/etc.

Publishing an intake form

A form hosts a branded, public page that writes each submission back into a product as a real row — a CRM lead, a contact, a support ticket. You declare which fields are visible and which columns are locked server-side, and that's all a submitter can ever set.

Forms are always public and always require admin to publish. The submitter never touches your workspace beyond the columns you declared, and the row always lands in your tenant.

Form spec fields:

  • target_schema — the product to write into, e.g. "crm".
  • target_table — the table to insert into, e.g. "lead".
  • fields[] — the visible inputs:
    • name — form field name.
    • label — what the submitter sees.
    • typetext (default), email, tel, textarea, or number.
    • requiredtrue/false.
    • column — target column (defaults to name).
  • fixed — server-applied column values, e.g. { "package": "pro" }. These override any submitted value — a submitter can't change them.
  • submit_label — button text (defaults to "Submit").
  • success_message — shown after a successful submit.

Example — a CRM lead-capture form:

// tool: publish_page
{
  "title": "Talk to Sales",
  "kind": "form",
  "form": {
    "target_schema": "crm",
    "target_table": "lead",
    "fields": [
      { "name": "name",    "label": "Your name",    "required": true },
      { "name": "email",   "label": "Work email",   "type": "email", "required": true },
      { "name": "company", "label": "Company" },
      { "name": "message", "label": "What do you need?", "type": "textarea" }
    ],
    "fixed": { "source": "website", "package": "pro" },
    "submit_label": "Request a demo",
    "success_message": "Thanks — we'll be in touch within one business day."
  },
  "max_submissions": 500,
  "expires_in_days": 30
}

Returns a url and publicUrl of the form (https://hdls.ai/v/<token>). Share that link, embed it, or drop it in an email. Each submission inserts a row into crm.lead with source and package forced server-side.

How submissions are protected

The public submit endpoint is https://hdls.ai/api/pages/<token>/submit (the rendered form POSTs there for you — you don't call it manually). hdls applies several guards automatically:

  • Spec validation — only the columns you declared can be written; everything else is ignored.
  • Fixed values winfixed columns always override submitted input.
  • Honeypot — a hidden field traps bots; a filled honeypot looks successful but inserts nothing.
  • Per-IP rate limit — at most 20 accepted submissions per IP per hour, per form. Over that, the submitter sees "Too many submissions — try again later." (IPs are hashed, never stored raw.)
  • Required-field check — missing required fields are rejected with a clear message.
  • Caps & expirymax_submissions closes the form once hit; expires_in_days closes it after the deadline.

Gotchas

  • A form needs target_schema, target_table, and at least one field — and the target table must exist, or publishing fails.
  • Forms ignore visibility — they're always public.
  • The submissionCount you see in list_pages / the console reflects only accepted, non-honeypot inserts.

Managing pages

List everything

// tool: list_pages   (no arguments)
{}
{
  "pages": [
    {
      "id": "9f3c…",
      "title": "Talk to Sales",
      "kind": "form",
      "visibility": "public",
      "url": "https://hdls.ai/v/abc123…",
      "createdAt": "2026-06-01T09:12:00Z",
      "submissionCount": 47
    }
  ]
}

Inspect one page

// tool: get_page
{ "id": "9f3c…" }

Returns { found: true, id, title, kind, visibility, url, createdAt, submissionCount }, or { found: false } if the id belongs to another workspace. You can't read another tenant's page — isolation holds here too.

Update a page (admin)

Change a document's title, HTML, or visibility. Pass only what you want to change.

// tool: update_page
{
  "id": "9f3c…",
  "title": "Q2 Pipeline Review (Final)",
  "visibility": "public"
}

html only applies to documents. (Dashboards and forms are edited by republishing.)

Revoke a page (admin)

// tool: unpublish_page
{ "id": "9f3c…" }

This deletes the page and revokes its link immediately — the URL stops working for everyone.


The console Pages view

Open Console → Pages to see every hosted page in a table: title, kind (Report / Dashboard / Form), visibility, a link to open it, and — for forms — the submission count. Admins also get a Revoke button per row (the same effect as unpublish_page).

Note that the console is read-and-manage only — you publish from your AI with publish_page. The console is where you review what's live, grab links, and revoke.


Quick reference

ToolPurposeEndpointRole
publish_pageCreate a document, dashboard, or form/api/mcpmember (workspace) / admin (public + forms)
find_logoResolve a company logo for branding/api/mcpreader
list_pagesList all hosted pages/api/mcpreader
get_pageInspect one page by id/api/mcpreader
update_pageEdit title / HTML / visibility/api/mcpadmin
unpublish_pageDelete a page, revoke its link/api/mcpadmin
URLWhat
https://hdls.ai/p/<id>Workspace-private viewer (members only)
https://hdls.ai/v/<token>Public viewer (anyone with the link)
https://hdls.ai/api/pages/<token>/submitForm submit endpoint (the form POSTs here automatically)

  • Getting connected — set up your workspace and connect your assistant before you publish.
  • Products — the schemas your dashboards aggregate and your forms write into.
  • Teams — roles (reader < member < admin < owner) that gate who can publish public pages and forms.
  • Security & trust — sandboxed rendering, row-level tenant isolation, and how form submissions are guarded.
  • Customer portals — when you need a richer, signed-in surface for customers beyond a single hosted page.