Download all docs

Public Pages That Show Live Data

The sanctioned shape for “anonymous visitors open a link and see real data” — and why the obvious-looking shortcuts return 401 or 422.

The visibility model, in one minute

Every element has a visible_to list that governs who can see it. Frontends (spa, ssr, view) and apps can be made public — that is how a live page serves to a logged-out visitor. But actions can never be public: a python or javascript element caps at collaborator/audience visibility, and trying to set visible_to: [public] on one returns 422 VISIBILITY_NOT_ALLOWED.

That cap is deliberate. An action runs arbitrary code in a sandbox on the owning circle’s billing — a directly-public action would be an unauthenticated remote-code-execution-and-spend surface. For the same reason, an anonymous browser calling an action’s ops/invoke directly gets 401 — invoke requires an authenticated circle member.

So a public page can render, but its fetch() to a sibling action bounces. Two locks, both intentional. The platform’s answer is a third element.

The pattern: an io/http gateway is the public seam

Put an io/http element between the public page and the action. Its receive operation is the one sanctioned unauthenticated entry point on the platform (auth: none — designed for webhooks and public endpoints), and it dispatches to the flow you wire behind it:

anon browser
    │  POST /api/{circle}/{gateway}/ops/receive  {"n": 7}  (no auth header)
    ▼
io/http "gateway"        spec.auth.type: none | bearer | api_key
    │  dispatches spec.target_ref
    ▼
actions/python "calc"    visible_to stays collaborator — never public
    ▼
result flows back to the browser

Build steps:

  1. Create the action as usual; leave its visibility alone.
  2. Create the gateway: element_type: http with spec: { auth: { type: "none" }, target_ref: "<action-slug>" }, then enable it.
  3. The public page fetches POST /api/{circle}/{gateway}/ops/receive with the payload. Every element operation lives at exactly this shape — /api/{circle}/{element}/ops/{operation}. There is no un-prefixed form and no form without the /ops/ segment. An earlier revision of this guide omitted both, publishing a path that is not a route at all — it falls through to the frontend-serve router and answers 405.
  4. Make the frontend/app public (visible_to), promote demo → live.

Why this is secure rather than a loophole: the action’s own surface stays 401 to the world — reach exists only through the seam you explicitly authored, and you choose its posture. auth.type: none is fully open (rate-limited and billed to the circle); for anything beyond a demo, use bearer/api_key (a shared secret) or enable HMAC body verification so the gateway rejects unsigned requests before the action ever runs.

Verify like an attacker would: an anonymous POST to the gateway returns the real result; an anonymous POST to the action’s ops/invoke still returns 401. Both together prove the page works and the action is not exposed.

Reads too, not just writes

The same seam pattern serves “the page lists live records”: point the gateway (or an automation step behind it) at the read path — for tabular data that is the SQL element’s query op — instead of having the page call a data element directly with no auth. See Reading and writing data from action code for why the flow, not sandboxed action code, should own database access.

What “done” looks like

A public data page is finished when a logged-out browser (fresh profile, no cookies) can load the live URL, submit or fetch through the gateway, and see real data come back — and the action still rejects direct anonymous invokes. If any of those checks was skipped, the page may still be bouncing strangers to login while looking perfectly healthy to its logged-in author.