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
| Action | Minimum role |
|---|---|
| Publish a workspace document or dashboard | member |
| Publish a public document or dashboard | admin |
| Publish a form (always public) | admin |
update_page (title / HTML / visibility) | admin |
unpublish_page (revoke the link) | admin |
list_pages, get_page, find_logo | reader |
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.kindis inferred: if you passformit's a form,dashboardit's a dashboard, otherwise it's a document. Settingkindexplicitly is optional but clearer.- To change a document later, use
update_pagewith newhtml(admin).
Branding a report with find_logo
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:
viz | Shows | Needs |
|---|---|---|
metric | a single number | a metric (omit group_by) |
bar | grouped horizontal bars | a metric + group_by |
table | rows of dimension + value | a metric + group_by |
Widget fields:
title— widget heading.viz—metric|bar|table.schema— the product, e.g."crm".table— the base table to aggregate, e.g."deal".metric—{ fn, column? }.fniscount,sum,avg,min, ormax.countneeds no column;sum/avg/min/maxrequire one.group_by— dimension column forbar/table(e.g."stage").filters— optional WHERE filters (see below).order_by—{ column, dir }wheredirisascordesc.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+tableis 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.type—text(default),email,tel,textarea, ornumber.required—true/false.column— target column (defaults toname).
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 win —
fixedcolumns 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 & expiry —
max_submissionscloses the form once hit;expires_in_dayscloses 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
submissionCountyou see inlist_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
| Tool | Purpose | Endpoint | Role |
|---|---|---|---|
publish_page | Create a document, dashboard, or form | /api/mcp | member (workspace) / admin (public + forms) |
find_logo | Resolve a company logo for branding | /api/mcp | reader |
list_pages | List all hosted pages | /api/mcp | reader |
get_page | Inspect one page by id | /api/mcp | reader |
update_page | Edit title / HTML / visibility | /api/mcp | admin |
unpublish_page | Delete a page, revoke its link | /api/mcp | admin |
| URL | What |
|---|---|
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>/submit | Form submit endpoint (the form POSTs here automatically) |
Related
- 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.