zerolith.ioChecking…

← All articles

Tutorial · Frontend · Billing

Artifact, canvas, frame: hosting the generated page yourself

9 min read

An artifact shows an idea in ten seconds, then stays inside the conversation. A function serves the same HTML page, with a URL of your own, a private SQLite database, and four ways to choose who may open it: API key, presigned URL, public, custom domain.

You have probably done this: describe a chart to an assistant and get back, ten seconds later, a page that works, curves and colours and hover and all. Every vendor has a name for that object. It is an artifact in Claude, a canvas in ChatGPT, in Gemini and in Mistral's Le Chat, Grok Studio at xAI, a Lab in Perplexity, a Frame in Dust. They all do the same thing, and they do it well: show you an idea before you decide whether it deserves a project.

Then comes the next question, and it kills the demo: "can you send me the link?"

Sometimes there is a link. You publish an artifact, you share a Frame through a token-gated link, a Lab's app gets deployed to its own page. But that page lives at the vendor's, inside a sandbox. The numbers it shows are the ones written into it, it has nowhere of its own to write, and "who can open it" is limited to what the product offers, public or restricted to your workspace.

What is missing is not the rendering. It is the hosting. And a function is perfectly capable of returning a page, not only JSON. With, on top, the three things an artifact does not have: a URL that is yours, a database, and a door whose lock you choose.

A function can answer with something other than JSON

The handler contract lets you choose the content type. A dict goes out as application/json, a string as text/plain, but those are defaults, not impositions: a Content-Type in the headers replaces them.

main.py
def handler(request):
    return 200, "<!doctype html><h1>Hello</h1>", {
        "Content-Type": "text/html; charset=utf-8",
    }

That is the whole trick. Deploy, open the URL, and your browser shows a page. No bundler, no npm install, no build step, no static server alongside; one file and one HTTP response are enough.

The same lever serves an SVG (image/svg+xml), an RSS feed (application/rss+xml), an .ics, a CSV the browser offers to save. A function is not condemned to be an API.

The real example: a dashboard with its own data

A static page is no better than a file dropped on a server. It gets interesting when the page fetches its data at the moment you load it.

The example below queries open-meteo (no API key), computes the geometry of two charts, and returns a self-contained document. httpx already ships in the runtime image.

It is running, and you can open it right now: the dashboard, live. Change ?city=, append &format=json for the raw data; the footer tells you which pod answered you and whether it had just cold-started. The full handler also sits in the documentation's example gallery, alongside the other public functions.

main.py : fetching
import httpx

def fetch(city, days):
    with httpx.Client(timeout=8) as client:
        geo = client.get("https://geocoding-api.open-meteo.com/v1/search",
                         params={"name": city, "count": 1, "language": "en"})
        hits = geo.json().get("results") or []
        if not hits:
            return None
        place = hits[0]

        forecast = client.get("https://api.open-meteo.com/v1/forecast", params={
            "latitude": place["latitude"], "longitude": place["longitude"],
            "current": "temperature_2m,apparent_temperature,wind_speed_10m",
            "hourly": "temperature_2m",
            "daily": "temperature_2m_max,temperature_2m_min",
            "forecast_days": days, "timezone": "auto",
        })
        return place, forecast.json()

Then the chart. No library: an SVG line is a list of points turned into a string, and that turning takes fifteen lines of Python.

main.py : the line chart
def line_chart(points, width=760, height=220):
    pad_l, pad_r, pad_t, pad_b = 44, 16, 18, 34
    temps = [p["temp"] for p in points]
    lo, hi, step = nice_scale(min(temps), max(temps))   # an axis on round numbers
    plot_w, plot_h = width - pad_l - pad_r, height - pad_t - pad_b

    def x_of(i):  return pad_l + plot_w * i / max(len(points) - 1, 1)
    def y_of(v):  return pad_t + plot_h - plot_h * (v - lo) / (hi - lo)

    coords = [(x_of(i), y_of(p["temp"])) for i, p in enumerate(points)]
    path = " ".join(f"{'M' if i == 0 else 'L'}{x:.1f},{y:.1f}"
                    for i, (x, y) in enumerate(coords))

    return (f'<svg viewBox="0 0 {width} {height}" role="img" aria-label="Temperature">'
            f'<path d="{path}" fill="none" stroke="#1fa3a7" stroke-width="2" '
            f'stroke-linejoin="round"/></svg>')

The handler assembles the rest: four tiles of current readings, the next twenty-four hours as a line, daily min-to-max range bars, the CSS, and twenty lines of JavaScript for the hover layer.

main.py : assembling
HTML = {"Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store"}

def handler(request):
    city = (request.query.get("city") or "London").strip()[:80]
    data = fetch(city, 7)
    if data is None:
        return 404, not_found_page(city), HTML
    if request.query.get("format") == "json":
        return 200, data                       # the same data, raw
    return 200, page(data), HTML

Two details that matter more than they look.

The charts are already drawn when the page arrives. The SVG is in the HTML, not built by JavaScript on load. So there is no loading state, no layout shift, no blank page if a script fails; JavaScript only adds the hover tooltip. A crawler, a screen reader or a curl all see what you see.

The same function serves the page and the data. ?format=json returns the raw dict. That is not a flourish: it is what keeps the dashboard from being a dead end the day someone wants to plug something else into it.

What it costs

Drawing the charts on the server costs nothing measurable: an SVG is a string, and assembling the 15 KB of the page disappears next to the call to the third-party API. Moving them off the server to be drawn by the browser would therefore gain you nothing, at the cost of a bundle to download.

On the bill, small reserves 0.125 GiB and 0.1 vCPU, at the published rates of €0.000004 per GiB-second and €0.0000125 per vCPU-second:

cost per pod-second
memory      0.125 GiB × 0.000004  =  0.0000005  €/s
cpu           0.1 vCPU × 0.0000125 =  0.00000125 €/s
                                      ────────────────
                                       0.00000175 €/s

And here, as with a cron, the honest arithmetic is not the handler's own duration. A pod does not vanish when the response leaves. The autoscaler watches a stability window (60 s by default) and then allows a grace period, so one isolated visit costs on the order of 90 pod-seconds, about €0.00016.

That is the bad news and the good news at once: a dashboard looked at once a day costs half a cent a month, and a dashboard reloaded twenty times in an afternoon costs almost nothing more, because those twenty visits land in the same pod. The worst case is the lonely visit, not the crowd.

Three traps, paid for by building it

Escape everything that comes from the request. The city name arrives from the client and ends up in the HTML. Without html.escape, ?city=<script>… is a reflected XSS served from your own domain. That is the biggest difference between a page rendered by a server and a page rendered inside a conversation: yours has visitors you did not choose.

python
city = html.escape(f"{place['name']}, {place['country']}")

Geometry that works today breaks on other data. The peak and trough labels sat perfectly on today's weather. On a flat series both landed on the same pixel; in Tromsø the minimum's label slid under the axis tick column. Neither city was in the sample. Re-rendering against extremes (a flat series, negative temperatures, a 60-degree range, two days instead of ten) costs five minutes and finds what a glance cannot.

The Content-Type is half the subject. It is worth checking with curl -I rather than in the browser: a text/plain shows your page as source code, and the only symptom is "it doesn't work". We learned that one from the inside. Until 16 August 2026 the Python runtime silently overwrote the handler's header and the Node runtime emitted two of them. It is fixed, and it is exactly the kind of defect no unit test sees: it lives in the bytes that go out on the wire.

Controlling who can open the page

A shared artifact is a two-position setting: private, or open to whoever holds the link. A function is private by default, since it wants an API key, which a browser does not send when you hand it a plain link. You choose how to open it, and there are four regimes, one per use, set per function.

The API key, in the header. Authorization: Bearer <key>. The default regime, the one for machine-to-machine calls: a script, a backend, an agent. We only ever store the key's hash, and it is shown once.

A presigned URL. The function stays private and you mint a link with a deadline, revocable, valid for that one function. It is the only way to send a page to someone who has nothing to install:

a shareable URL
curl -X POST -H "Authorization: Bearer $JWT" \
     -d '{"name": "client-report", "expires_at": "2026-08-23T09:00:00Z"}' \
     https://zerolith.io/api/functions/<id>/tokens
# -> {"token": "faast_…", "url": "https://…/?faas_token=faast_…"}
#    The secret is shown here and only here. There is no "never" expiry: that is an API key.

This is what you want for a dashboard sent to a client: the link opens in any browser, expires on its own, and can be revoked without touching anything else. A presigned link's rate limit is counted per (account, function), so a link that runs away cannot starve your other functions.

Public. The function answers everyone, unauthenticated, with the same partitioned rate limit. The right choice for a demo you put in a post or a talk.

Your own domain. report.mycompany.com pointed at the function: one TXT record to prove the domain is yours, one CNAME for the traffic, and the certificate is issued and renewed on its own. The page stops looking like a demo hosted somewhere else. It is billed per domain per month; the rate is on the pricing page, the procedure in the documentation.

The locks combine: your own domain and a presigned URL, one public function and a private one in the same account.

Wiring a database to the page

An artifact remembers nothing: reload it and it is back to the state it was written in. A function can have a database of its own: a private SQLite database, in your own space, that sleeps when nobody queries it and wakes on the first request. You attach it with one command, and two environment variables appear in the pod:

main.py : the page keeps its own counter
import os, libsql_client

def handler(request):
    db = libsql_client.create_client_sync(
        url=os.environ["DATABASE_URL"],
        auth_token=os.environ["DATABASE_AUTH_TOKEN"],
    )
    with db:
        db.execute("INSERT INTO visits(seen_at) VALUES (datetime('now'))")
        total = db.execute("SELECT count(*) FROM visits").rows[0][0]
    return 200, page(total), HTML

From there the page no longer only displays, it records: a response form, a team vote, a queue, the history of what last night's cron collected. The dashboard then shows your data rather than a public API's. The full walkthrough is in the previous article, and the private database has its own page.

What this does not replace

The limits are real:

  • There is no CDN. Every load goes through the function. Fine for an internal dashboard or a demo; not for a high-traffic landing page.
  • This is not a framework. No router, no components, no client state. Past a few screens you will want a real frontend, and you will deploy it elsewhere.
  • You have to deploy. An artifact appears in the conversation; here there is a function to create and a domain to point if you want one. That is a few minutes, and it is the price of everything else.

The sweet spot is fairly sharp: one page, fresh data, a link to send. A weekly report, an on-call dashboard, a view over a table, a client demo, the output of a cron made readable.

Where the assistant fits

It does not leave the story; it moves. The MCP server exposes deployment as a tool: an agent writes the handler, deploys it, calls it, reads the logs and fixes it, inside one conversation. The function in this article was deployed exactly that way.

So keep the artifact, the canvas or the Frame for what they are best at: writing the page in ten seconds and looking at it. What changes afterwards is what survives the conversation, a URL that is yours, a lock you choose, a database that remembers, a per-second price, and a page that will show tomorrow's data without being rewritten.

Rates are on the pricing page and the full handler contract is in the documentation.

Try it on your own account

Signing up comes with credit, enough to deploy, schedule and measure everything above, without getting a card out.

Get started »