A host for anfra Data Apps that you run yourself, in place of the appserve pages anfra serve has built in. It is a reference for putting Data Apps in a page of your own: everything it needs from anfra is the published @holistics/anfra-sdk and the core HTTP API.
- A sidebar lists the repo's Data Apps; each opens at
/apps/<path>. - The stage runs the open Data App in a sandboxed frame. Its queries come back to the page over the SDK's bridge and are answered by anfra through this host's server. So are its exports: a Data App's
query.export()downloads a query's whole result as a CSV. - Pictures: the toolbar downloads a PNG or PDF of the whole app, a node's row in the Structure tab one of that container or block, and a Data App can save its own with
Anfra.render(). The SDK draws them in the frame; this host turns that on (render: trueinsrc/data-app.ts), since it has the page save a file the app's code made. Turn it on only for apps you trust with that. - A toolbar reloads the app, and opens the inspect panel:
- Structure: the app's parts as its markup declares them, as a tree. Hover a node to highlight it on the page, click to scroll to it, copy its handle (
[Revenue](data-anfra-block="revenue")) to point an agent at it, or download a picture of it. Pick mode goes from the page to the tree. - Data: every app's queries (state, rows, executed AQL and SQL), controls and selection.
- Structure: the app's parts as its markup declares them, as a tree. Hover a node to highlight it on the page, click to scroll to it, copy its handle (
- Light or dark follows the system until you pick one with the toggle next to the title.
- In development, the open app reloads when its file or the AML changes.
Tested with anfra 0.8.0 and @holistics/anfra-sdk 0.8.0. Bump them together: the SDK is released with anfra.
browser this host (Node, Hono) anfra serve --no-apps
┌──────────────────────┐ ┌────────────────────────────┐ ┌────────────────────┐
│ page (React) │ /api │ allowlist, rate limit, │ /api │ core API │
│ └ frame: Data App ──┼─bridge─┤ "your auth goes here" ────┼────────▶│ (no auth: private) │
│ │ /host │ apps/ read from ANFRA_REPO │ └────────────────────┘
└──────────────────────┘ └────────────────────────────┘
- anfra answers the core API only. Run with
--no-apps, it serves no pages, and it must listen where only this server can reach it:anfra servehas no authentication. - The server (
server/) is the one origin the browser talks to:/api/core.show,/api/core.queryand/api/core.query.export, passed to anfra, and nothing else of the core API. Queries and exports are rate-limited per client, each with its own limit. The spot to check your own session is marked inserver/anfra.ts./exports/download/<token>/<name>: an export's file, streamed from anfra. anfra makes its export links at this server's origin (ANFRA_SITE_URL, below), so the browser downloads through here, never from anfra directly. A link works for whoever has it until it expires; a host whose readers sign in checks the session here too (marked inserver/anfra.ts)./host/apps(the Data App tree, labelled by each file's<title>),/host/files/<path>(a definition or a file it refers to, sent sandboxed) and, in development,/host/events(live reload). The core API has nothing to list or read app files, so the server reads them from the repo'sapps/folder itself./health: this host's version and anfra's, or 503 while anfra does not answer.- With
MCP=1,/mcp: anfra's MCP endpoint, passed through. See MCP. - In production, the built page, with each Data App's link preview.
- The page (
src/): every call into the SDK is insrc/data-app.ts. That file is the part to copy into a host of your own; the rest is UI.
You need Node 22 or newer, pnpm, and anfra.
-
Start anfra on the Data Folder, without its own pages:
cd path/to/your/data-folder && anfra serve --no-apps
Set
ANFRA_SITE_URLto where the browser reaches this host, so the export links anfra makes point here:http://localhost:5173in development. Without it, a link points at anfra's own address, which the browser can't reach.Or
pnpm dev:anfra, which does the same inANFRA_REPO, withANFRA_SITE_URLathttp://localhost:5173unless you set it. With Docker:docker run --rm -p 127.0.0.1:7878:7878 -e ANFRA_SITE_URL=http://localhost:5173 -v "$PWD:/repo" ghcr.io/holistics/anfra:0.8.0 serve --no-apps --addr 0.0.0.0:7878 -
Start the host:
pnpm install ANFRA_REPO=path/to/your/data-folder pnpm dev
Open http://localhost:5173. Vite serves the page with hot reload and passes
/api,/host,/exportsand/healthto the server on port 8787.
.npmrc points the @holistics scope at npmjs, in case your own npm config sends it elsewhere.
pnpm build
NODE_ENV=production ANFRA_REPO=… ANFRA_URL=… PORT=5173 HOSTNAME=0.0.0.0 pnpm startRun anfra with ANFRA_SITE_URL at this host's public origin (https://data.example.com). One process serves the page, the API and the Data Apps. It reads the app tree once at startup and does not watch the repo. Built assets are cached for a year (their names are hashed); definitions for a minute, with an ETag.
| Variable | Default | |
|---|---|---|
ANFRA_REPO |
../demo-data |
The Data Folder anfra serves. Data Apps are its apps/**/*.html. |
ANFRA_URL |
http://127.0.0.1:7878 |
Where anfra answers. Keep it off the public network. |
PORT, HOSTNAME |
8787, 127.0.0.1 |
Where this server listens. |
NODE_ENV |
production serves the built page, without live reload. |
|
QUERY_RATE_LIMIT |
120 |
core.query requests a minute per client. |
EXPORT_RATE_LIMIT |
10 |
core.query.export requests a minute per client. Each runs its query to the last row. |
MCP |
1 passes anfra's MCP endpoint through at /mcp. See MCP. |
|
TRUST_PROXY |
1 takes the client's address from X-Forwarded-For: the last entry, which the one proxy in front of this server appends. |
|
ANFRA_SITE_URL |
This host's public origin, for the absolute links in link previews. Without it, each request's origin (with X-Forwarded-Proto under TRUST_PROXY=1). |
A link to a Data App shared in Slack, a chat app or a social site shows the app's title, a description and an image. Their crawlers run no script, so in production the server writes the tags (og:*, twitter:card) into the page it sends for /apps/<path>, from the definition's own head:
- the title:
og:title, else<title>, else the file name; - the description:
og:description, else<meta name="description">, else a line about Data Apps; - the image:
og:image, a URL or a path relative to the definition, elsepublic/og-image.png, the anfra card. A file underapps/that isn't there shows the default (and is warned of at startup).
An image under apps/, and the default, gets ?v= and a short hash of its bytes, read at startup, so a crawler that caches images by URL (Facebook, LinkedIn) fetches a changed one. They are said to be 1200x630 (og:image:width, og:image:height), as no image is read to learn its size: pre-render them at that size in the Data Folder, as anfra-demo-data's scripts/og_snapshots.py does. Every image gets og:image:type from its extension (png, jpg, webp) and og:image:alt, the title.
<title>Funnel Analysis</title>
<meta name="description" content="A customer funnel over ecommerce orders, with breakdowns and drill-downs.">
<meta property="og:image" content="funnel-analysis.png">Set ANFRA_SITE_URL to this host's public origin, as for anfra, so the links in the tags point there.
A repo can say how its folders and Data Apps are shown in an optional apps/_meta.yaml (or _meta.yml). Keys are paths relative to apps/ (a Data App's with its .html), so renaming something here never changes a URL. Every field is optional; without the file, folders are named by their directory, apps by their <title>, and everything is open and alphabetical.
folders:
sales/:
name: Sales & Revenue # shown in place of the directory name
icon: wallet
tags: [finance, weekly]
collapsed: true # starts closed (open anyway if it holds the open app)
order: 1 # before the unordered, lowest first
apps:
sales/overview.html:
name: Revenue overview # shown in place of the <title>
icon: chart-line
tags: [kpi]
endorsed: true # a green check on its icon
order: 1iconis one of the names inserver/entry.ts(lucide, kebab-case); an unknown one is logged and falls back to the default.endorsed: true(Data Apps) puts a small green check on the icon's bottom right.- The sidebar's filter matches names and tags.
orderputs entries first, lowest first; the rest follow by file name.- A Data App's link preview takes its own
og:titlefirst, thenname, then its<title>. - The server logs each problem once while it lasts: unknown fields, a field on the wrong kind (
endorsedon a folder), a key written twice (salesandsales/), a key under the wrong section, and keys that match nothing.
With MCP=1, the server passes anfra's MCP endpoint (streamable HTTP) through at /mcp, so an agent can connect to https://<this host>/mcp. It is a plain passthrough, for demos: unlike /api, it has no allowlist, no rate limit and no auth spot. Every op anfra serves as a tool is reachable, except core.ingest, which rebuilds the search catalog and is refused here.
- Run anfra without
--no-mcp:anfra serve --no-appsserves/mcp. core.query.exportanswers with a link to the file, downloaded through this server as a Data App's export is (/exports/download/*, served with or withoutMCP=1), so start anfra withANFRA_SITE_URLset to this host's public URL, such ashttps://demo.anfra.ai.
pnpm typecheck
pnpm lint # Biome; pnpm format to fix
pnpm buildserver/
main.ts start: config, listen
app.ts the routes
anfra.ts /api: the allowlist, rate limits, auth spot; export downloads; /health
mcp.ts /mcp, passed to anfra (MCP=1)
catalog.ts the Data App tree
files.ts a file under apps/, confined and sandboxed
events.ts live reload: the repo watched, changes debounced
src/
data-app.ts every SDK call: datasets, the reader, mounting a Data App
App.tsx layout, routing, live reload
components/ sidebar, home, stage; ui/ is shadcn/ui
inspector/ the inspect panel, self-contained to carry to another host; SPEC.md says how
lib/host.ts what the page asks the server, apart from the core API