Docs
Everything you need to deploy and call a function: the handler contract, HTTP invocation, environment variables, quotas, metrics and agent driving. Every example matches how the platform actually behaves.
Quickstart
- Create an account and verify your email address.
- Deploy a function from the dashboard. A working sample is prefilled; pick Python or Node.js.
- Try it from its page with the "Test the function" button.
- Create an API key to call it from outside (public functions need no key).
The free signup credit easily covers these first steps: an idle function costs nothing.
The handler contract
A function is a single file exposing a handler. It receives a request object and returns the response: a string, a JSON object, or a (status, body, headers) tuple.
# main.py — handler: main.handler
def handler(request):
# request.method -> "GET", "POST", ...
# request.path -> str
# request.headers -> dict[str, str]
# request.query -> dict[str, str]
# request.body -> bytes
# request.json() -> parsed JSON body
name = request.query.get("name", "world")
return {"message": f"hello, {name}"}
# other return shapes:
# "text" -> 200 text/plain
# {"a": 1} | [1, 2] -> 200 application/json
# (404, "not found") -> (status, body)
# (201, body, {"X-My": "hdr"}) -> (status, body, headers)// main.js — handler: main.handler
exports.handler = (request) => {
// request.method -> "GET", "POST", ...
// request.path -> string
// request.headers -> object (lower-cased names)
// request.query -> object (first value per key)
// request.body -> Buffer
// request.json() -> parsed JSON body
const name = request.query.name || 'world';
return { message: `hello, ${name}` };
// other return shapes:
// 'text' -> 200 text/plain
// { a: 1 } -> 200 application/json
// [404, 'not found'] -> [status, body]
// [201, body, { 'X-My': 'hdr' }] -> [status, body, headers]
};The form's "Handler" field is module.function (default main.handler). Runtimes already bundle the common libraries: requests, httpx, pydantic, PyYAML on Python; axios, lodash, zod, dayjs on Node.
Invoking a function
A private function is called with an API key (created in the app, shown once) passed in the Authorization header:
curl -H "Authorization: Bearer $ZEROLITH_KEY" \
"https://<function-url>?name=neo"A public function is called without authentication, which is handy for webhooks:
# public function — no key needed curl "https://<function-url>?name=neo"
A presigned URL calls one private function with no key on the caller's side, until the date you set. Create one from the function page; it is shown once, and you can revoke it before its expiry:
# presigned URL — one function, until its expiry date curl "https://<function-url>?faas_token=faast_..."
The token authorizes that function and nothing else, not your account and not your other functions. Calls made through it are billed to you like any other invocation, and the owner gates still apply (a suspended function or an empty balance refuses it).
Authentication is checked at the edge, before your function even wakes, so an unauthorized call costs you nothing. All HTTP methods (GET, POST, PUT, PATCH, DELETE) and paths are forwarded to the handler.
Live examples
Four functions really deployed on the platform, public and scale-to-zero. The code shown is the actual handler, and the "Call it live" button really hits the public URL: what you see is the function's own answer, nothing is faked.
A weather dashboard (Python): the function fetches its data server-side (open-meteo, no key), computes the geometry of its two charts in Python and returns a complete HTML page. The SVG is already drawn when the document arrives — no bundler, no build step, no loading screen. The same URL with ?format=json returns the same data raw. This is the function the "Artifact, canvas, frame" article is built on.
main.py
"""zerolith · exemple « artefact hébergé ».
Une seule fonction Python qui rend une PAGE, pas un JSON : elle va chercher ses données
côté serveur (open-meteo, sans clé), calcule la géométrie de deux graphiques, et renvoie
un document HTML autonome — SVG en ligne, CSS en ligne, aucune dépendance externe, aucune
étape de build, aucun bundler.
C'est le même geste qu'un artefact de chatbot, sauf que le résultat a une URL stable, que
les données sont fraîches à chaque chargement, et qu'aucun pod ne tourne entre deux
visites.
GET /?city=Paris
GET /?city=Tokyo&days=7
GET /?city=Paris&format=json -> les mêmes données, brutes
"""
import html
import math
import os
import time
from datetime import datetime
from urllib.parse import quote
import httpx
TIMEOUT = float(os.environ.get("DEMO_TIMEOUT_SECONDS", "8"))
UA = {"User-Agent": "zerolith-demo/1.0 (+https://zerolith.io)"}
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
# Palette de marque, mais ramenée dans la bande de luminosité qui reste lisible sur un
# fond quasi noir : des néons purs à 2 px de trait « bavent » et deux séries voisines
# cessent d'être distinguables en vision daltonienne.
CYAN = "#1fa3a7"
AMBER = "#d26a10"
INK = "#c9d6e2"
INK_DIM = "#8497a6"
INK_FAINT = "#5b6c7b"
GRID = "#16212e"
SURFACE = "#070b11"
PANEL = "#0c121b"
_BOOT = time.monotonic()
_POD = os.environ.get("HOSTNAME", "inconnu")
_SERVED = 0
JOURS = ["lun", "mar", "mer", "jeu", "ven", "sam", "dim"]
# ─────────────────────────────────────────────────────────────────────────────
# Données
# ─────────────────────────────────────────────────────────────────────────────
def fetch(city, days):
with httpx.Client(headers=UA, timeout=TIMEOUT) as client:
geo = client.get(
GEOCODE_URL, params={"name": city, "count": 1, "language": "fr"}
)
geo.raise_for_status()
hits = geo.json().get("results") or []
if not hits:
return None
place = hits[0]
forecast = client.get(
FORECAST_URL,
params={
"latitude": place["latitude"],
"longitude": place["longitude"],
"current": "temperature_2m,apparent_temperature,wind_speed_10m,relative_humidity_2m",
"hourly": "temperature_2m",
"daily": "temperature_2m_max,temperature_2m_min",
"forecast_days": days,
"timezone": "auto",
},
)
forecast.raise_for_status()
data = forecast.json()
hourly = data["hourly"]
daily = data["daily"]
# Une fenêtre de 24 h CENTRÉE SUR MAINTENANT (6 h de passé, 18 h à venir) : open-meteo
# renvoie les heures à partir de minuit, et à 23 h un graphe « aujourd'hui » n'aurait
# plus rien à montrer.
now = data["current"]["time"][:13]
cursor = next((i for i, t in enumerate(hourly["time"]) if t[:13] >= now), 0)
start = max(0, cursor - 6)
return {
"city": place["name"],
"country": place.get("country"),
"timezone": data.get("timezone"),
"current": data["current"],
"hourly": [
{"t": t, "temp": v}
for t, v in zip(
hourly["time"][start : start + 24],
hourly["temperature_2m"][start : start + 24],
)
if v is not None
],
"daily": [
{"date": d, "min": lo, "max": hi}
for d, lo, hi in zip(
daily["time"], daily["temperature_2m_min"], daily["temperature_2m_max"]
)
],
}
# ─────────────────────────────────────────────────────────────────────────────
# Géométrie : tout le calcul des graphiques se fait ici, en Python.
# ─────────────────────────────────────────────────────────────────────────────
def nice_scale(lo, hi, ticks=4):
"""Une échelle qui tombe sur des valeurs rondes, avec un peu d'air en haut/bas."""
span = max(hi - lo, 1.0)
raw = span / ticks
magnitude = 10 ** (len(str(int(raw))) - 1) if raw >= 1 else 0.1
step = next(m * magnitude for m in (1, 2, 2.5, 5, 10) if m * magnitude >= raw)
bottom = step * math.floor(lo / step)
top = bottom + step * ticks
while top < hi:
top += step
return bottom, top, step
def line_chart(points, width=760, height=220):
"""Courbe horaire : une seule série, donc pas de légende — le titre la nomme."""
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))
plot_w = width - pad_l - pad_r
plot_h = 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)]
line = " ".join(f"{'M' if i == 0 else 'L'}{x:.1f},{y:.1f}" for i, (x, y) in enumerate(coords))
area = (
f"{line} L{coords[-1][0]:.1f},{pad_t + plot_h:.1f} "
f"L{coords[0][0]:.1f},{pad_t + plot_h:.1f} Z"
)
out = []
# Grille horizontale seulement : les repères verticaux n'aident pas une courbe.
v = lo
while v <= hi + 1e-9:
y = y_of(v)
out.append(
f'<line x1="{pad_l}" y1="{y:.1f}" x2="{width - pad_r}" y2="{y:.1f}" '
f'stroke="{GRID}" stroke-width="1"/>'
)
out.append(
f'<text x="{pad_l - 8}" y="{y + 4:.1f}" text-anchor="end" class="tick">{v:g}°</text>'
)
v += step
out.append(f'<path d="{area}" fill="url(#fade)"/>')
out.append(
f'<path d="{line}" fill="none" stroke="{CYAN}" stroke-width="2" '
'stroke-linejoin="round" stroke-linecap="round"/>'
)
# Étiquettes directes sur les seuls points qui méritent un chiffre : le pic et le creux.
# Une seule si la série est plate — sinon les deux se superposent au même endroit.
marked = [("max", temps.index(max(temps)))]
if max(temps) != min(temps):
marked.append(("min", temps.index(min(temps))))
for label, idx in marked:
x, y = coords[idx]
# Le creux s'étiquette EN DESSOUS, sauf s'il touche le bas du cadre : là, l'étiquette
# tomberait dans la rangée des heures. Une échelle arrondie rend le cas rare, pas
# impossible — et « rare » est exactement ce qui n'est jamais testé.
dy = -12 if label == "max" or y > pad_t + plot_h - 22 else 20
# Près d'un bord, une étiquette centrée déborde sur la colonne des graduations.
if x < pad_l + 24:
anchor, dx = "start", 8
elif x > width - pad_r - 24:
anchor, dx = "end", -8
else:
anchor, dx = "middle", 0
out.append(
f'<circle cx="{x:.1f}" cy="{y:.1f}" r="4" fill="{CYAN}" '
f'stroke="{SURFACE}" stroke-width="2"/>'
)
out.append(
f'<text x="{x + dx:.1f}" y="{y + dy:.1f}" text-anchor="{anchor}" class="peak">'
f"{temps[idx]:g}°</text>"
)
# Heures, une graduation sur trois.
for i, p in enumerate(points):
if i % 3:
continue
hour = p["t"][11:16]
out.append(
f'<text x="{x_of(i):.1f}" y="{height - 8}" text-anchor="middle" class="tick">{hour}</text>'
)
# Couche de survol : une bande par point, plus large que la marque elle-même.
band = plot_w / max(len(points) - 1, 1)
for i, (x, y) in enumerate(coords):
out.append(
f'<rect class="hit" x="{x - band / 2:.1f}" y="{pad_t}" width="{band:.1f}" '
f'height="{plot_h}" fill="transparent" data-x="{x:.1f}" data-y="{y:.1f}" '
f'data-label="{points[i]["t"][11:16]}" data-value="{points[i]["temp"]:g} °C"/>'
)
return (
f'<svg viewBox="0 0 {width} {height}" class="chart" role="img" '
f'aria-label="Température heure par heure">'
f'<defs><linearGradient id="fade" x1="0" y1="0" x2="0" y2="1">'
f'<stop offset="0%" stop-color="{CYAN}" stop-opacity="0.22"/>'
f'<stop offset="100%" stop-color="{CYAN}" stop-opacity="0"/>'
f"</linearGradient></defs>"
+ "".join(out)
# Le viseur est émis EN DERNIER : en SVG il n'y a pas de z-index, seul l'ordre
# du document décide de ce qui passe au-dessus.
+ f'<g class="crosshair" style="display:none" pointer-events="none">'
f'<line y1="{pad_t}" y2="{pad_t + plot_h}" stroke="{INK_FAINT}" stroke-width="1"/>'
f'<circle r="5" fill="{CYAN}" stroke="{SURFACE}" stroke-width="2"/></g>'
+ "</svg>"
)
def range_chart(days, width=760, height=200):
"""Barres d'amplitude : une barre = l'intervalle min→max d'un jour, pas une magnitude.
C'est pourquoi elle ne part pas de zéro — elle encode un segment, pas une quantité."""
# pad_b tient DEUX rangées de texte sous le cadre : le minimum de chaque barre, puis le
# nom du jour.
pad_l, pad_r, pad_t, pad_b = 44, 16, 26, 44
lo, hi, step = nice_scale(
min(d["min"] for d in days), max(d["max"] for d in days)
)
plot_w = width - pad_l - pad_r
plot_h = height - pad_t - pad_b
slot = plot_w / len(days)
bar_w = min(26, slot - 14) # l'espace entre deux barres reste ≥ 14 px
def y_of(v):
return pad_t + plot_h - plot_h * (v - lo) / (hi - lo)
out = []
v = lo
while v <= hi + 1e-9:
y = y_of(v)
out.append(
f'<line x1="{pad_l}" y1="{y:.1f}" x2="{width - pad_r}" y2="{y:.1f}" '
f'stroke="{GRID}" stroke-width="1"/>'
)
out.append(
f'<text x="{pad_l - 8}" y="{y + 4:.1f}" text-anchor="end" class="tick">{v:g}°</text>'
)
v += step
for i, d in enumerate(days):
cx = pad_l + slot * (i + 0.5)
y_hi, y_lo = y_of(d["max"]), y_of(d["min"])
date = datetime.fromisoformat(d["date"])
out.append(
f'<rect class="hit bar" x="{cx - bar_w / 2:.1f}" y="{y_hi:.1f}" width="{bar_w:.1f}" '
f'height="{max(y_lo - y_hi, 4):.1f}" rx="4" fill="{AMBER}" '
f'data-label="{JOURS[date.weekday()]} {date.day}" '
f'data-value="{d["min"]:g} → {d["max"]:g} °C"/>'
)
out.append(
f'<text x="{cx:.1f}" y="{y_hi - 8:.1f}" text-anchor="middle" class="peak">{d["max"]:g}°</text>'
)
out.append(
f'<text x="{cx:.1f}" y="{y_lo + 15:.1f}" text-anchor="middle" class="tick">{d["min"]:g}°</text>'
)
out.append(
f'<text x="{cx:.1f}" y="{height - 8}" text-anchor="middle" class="tick">'
f"{JOURS[date.weekday()]}</text>"
)
return (
f'<svg viewBox="0 0 {width} {height}" class="chart" role="img" '
f'aria-label="Amplitude quotidienne, minimum au maximum">' + "".join(out) + "</svg>"
)
# ─────────────────────────────────────────────────────────────────────────────
# Page
# ─────────────────────────────────────────────────────────────────────────────
CSS = f"""
*{{box-sizing:border-box}}
body{{margin:0;background:{SURFACE};color:{INK};
font:14px/1.6 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}}
.wrap{{max-width:860px;margin:0 auto;padding:32px 20px 64px}}
h1{{font-size:26px;letter-spacing:.04em;margin:0 0 4px;font-weight:700}}
h2{{font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:{INK_DIM};
margin:36px 0 10px;font-weight:700}}
.sub{{color:{INK_DIM};margin:0 0 28px;font-size:13px}}
.tiles{{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}}
.tile{{background:{PANEL};border:1px solid {GRID};border-left:2px solid {CYAN};
border-radius:2px;padding:14px 16px}}
.tile .v{{font-size:28px;font-weight:700;color:{INK};line-height:1.2}}
.tile .k{{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:{INK_DIM}}}
.panel{{background:{PANEL};border:1px solid {GRID};border-radius:2px;padding:8px 4px;
position:relative}}
.chart{{width:100%;height:auto;display:block;overflow:visible}}
.tick{{fill:{INK_FAINT};font-size:11px}}
.peak{{fill:{INK};font-size:12px;font-weight:700}}
.hit:hover{{cursor:crosshair}}
.bar{{transition:opacity .12s}}
.panel:hover .bar{{opacity:.55}}
.bar:hover{{opacity:1}}
.tip{{position:absolute;pointer-events:none;opacity:0;transition:opacity .1s;
background:{SURFACE};border:1px solid {CYAN};border-radius:2px;padding:6px 9px;
font-size:12px;white-space:nowrap;transform:translate(-50%,-140%);z-index:5}}
.tip b{{color:{CYAN};font-weight:700}}
details{{margin-top:12px}}
summary{{color:{INK_DIM};font-size:12px;cursor:pointer}}
table{{border-collapse:collapse;margin-top:10px;font-size:12px;width:100%}}
th,td{{text-align:right;padding:4px 10px;border-bottom:1px solid {GRID};
font-variant-numeric:tabular-nums}}
th:first-child,td:first-child{{text-align:left}}
th{{color:{INK_DIM};font-weight:400;font-size:10px;letter-spacing:.14em;text-transform:uppercase}}
footer{{margin-top:44px;padding-top:18px;border-top:1px solid {GRID};
color:{INK_FAINT};font-size:12px}}
a{{color:{CYAN};text-decoration:none}} a:hover{{color:{AMBER}}}
@media (prefers-reduced-motion:reduce){{*{{transition:none!important}}}}
"""
JS = """
document.querySelectorAll('.panel').forEach(function (panel) {
var tip = document.createElement('div');
tip.className = 'tip';
panel.appendChild(tip);
var cross = panel.querySelector('.crosshair');
var svg = panel.querySelector('svg');
panel.querySelectorAll('.hit').forEach(function (hit) {
hit.addEventListener('pointerenter', function () {
var box = hit.getBoundingClientRect(), p = panel.getBoundingClientRect();
tip.innerHTML = hit.dataset.label + ' <b>' + hit.dataset.value + '</b>';
tip.style.left = (box.left + box.width / 2 - p.left) + 'px';
tip.style.top = (hit.dataset.y
? svg.getBoundingClientRect().top - p.top
+ (+hit.dataset.y) * svg.getBoundingClientRect().height / svg.viewBox.baseVal.height
: box.top - p.top) + 'px';
tip.style.opacity = 1;
if (cross && hit.dataset.x) {
cross.style.display = '';
cross.querySelector('line').setAttribute('x1', hit.dataset.x);
cross.querySelector('line').setAttribute('x2', hit.dataset.x);
cross.querySelector('circle').setAttribute('cx', hit.dataset.x);
cross.querySelector('circle').setAttribute('cy', hit.dataset.y);
}
});
});
panel.addEventListener('pointerleave', function () {
tip.style.opacity = 0;
if (cross) cross.style.display = 'none';
});
});
"""
def page(d, meta):
"""Assemble le document. `html.escape` sur TOUT ce qui vient de la requête : le nom de
ville arrive du client, et il finit dans le HTML."""
cur = d["current"]
city = html.escape(f"{d['city']}, {d['country']}")
tiles = [
("Température", f"{cur['temperature_2m']:g}°"),
("Ressenti", f"{cur['apparent_temperature']:g}°"),
("Vent", f"{cur['wind_speed_10m']:g} km/h"),
("Humidité", f"{cur['relative_humidity_2m']:g} %"),
]
rows = "".join(
f"<tr><td>{html.escape(x['date'])}</td><td>{x['min']:g} °C</td>"
f"<td>{x['max']:g} °C</td></tr>"
for x in d["daily"]
)
return f"""<!doctype html>
<html lang="fr"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<title>{city} — tableau de bord</title>
<style>{CSS}</style></head>
<body><div class="wrap">
<h1>{city}</h1>
<p class="sub">Fuseau {html.escape(d["timezone"] or "?")} · relevé
{html.escape(cur["time"])} · page assemblée en {meta["render_ms"]} ms</p>
<div class="tiles">
{"".join(f'<div class="tile"><div class="v">{v}</div><div class="k">{k}</div></div>'
for k, v in tiles)}
</div>
<h2>Température, heure par heure</h2>
<div class="panel">{line_chart(d["hourly"][:24])}</div>
<h2>Amplitude quotidienne (min → max)</h2>
<div class="panel">{range_chart(d["daily"])}
<details><summary>Voir les données</summary>
<table><thead><tr><th>Jour</th><th>Min</th><th>Max</th></tr></thead>
<tbody>{rows}</tbody></table></details></div>
<footer>
Rendu par une fonction zerolith · pod <code>{html.escape(meta["pod"])}</code> ·
{"démarrage à froid" if meta["cold_start"] else f'pod en vie depuis {meta["pod_age_s"]} s'} ·
requête n° {meta["served"]} de ce pod<br>
Données <a href="https://open-meteo.com">open-meteo</a>, récupérées côté serveur en
{meta["fetch_ms"]} ms · <a href="?city={quote(d["city"])}&format=json">les mêmes
données en JSON</a>
</footer>
</div><script>{JS}</script></body></html>"""
HTML_HEADERS = {"Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store"}
def handler(request):
global _SERVED
started = time.perf_counter()
cold = _SERVED == 0
_SERVED += 1
city = (request.query.get("city") or "Paris").strip()[:80]
try:
days = min(max(int(request.query.get("days", 7)), 2), 10)
except ValueError:
days = 7
fetch_started = time.perf_counter()
data = fetch(city, days)
fetch_ms = round((time.perf_counter() - fetch_started) * 1000)
if data is None:
body = (
f"<!doctype html><meta charset=utf-8><style>{CSS}</style>"
f'<div class=wrap><h1>Ville introuvable</h1><p class=sub>Aucun résultat pour '
f"« {html.escape(city)} ». Essayez <a href=?city=Paris>Paris</a>.</p></div>"
)
return 404, body, HTML_HEADERS
meta = {
"render_ms": 0,
"fetch_ms": fetch_ms,
"pod": _POD,
"cold_start": cold,
"pod_age_s": round(time.monotonic() - _BOOT, 1),
"served": _SERVED,
}
if request.query.get("format") == "json":
meta["render_ms"] = round((time.perf_counter() - started) * 1000)
return 200, {**data, "meta": meta}
meta["render_ms"] = round((time.perf_counter() - started) * 1000)
return 200, page(data, meta), HTML_HEADERSA countdown-timer web page (Node.js): enter a duration and it counts down to 00:00. The handler returns the full HTML page; all the interactivity runs in the browser.
main.js
'use strict';
const PAGE = `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Timer</title>
<style>
:root { --bg:#0b0d12; --fg:#f4f6fb; --accent:#ffb020; --muted:#8a93a6; --panel:#151925; }
* { box-sizing:border-box; }
body {
margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
background:radial-gradient(1200px 800px at 50% -10%, #1b2130, var(--bg));
color:var(--fg); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
}
.card {
background:var(--panel); border:1px solid #222839; border-radius:20px;
padding:40px 44px; width:min(92vw,440px); text-align:center;
box-shadow:0 20px 60px rgba(0,0,0,.45);
}
h1 { margin:0 0 6px; font-size:20px; font-weight:600; letter-spacing:.3px; }
.sub { color:var(--muted); font-size:13px; margin-bottom:28px; }
.display {
font-size:72px; font-weight:700; font-variant-numeric:tabular-nums;
letter-spacing:2px; margin:18px 0 26px; line-height:1;
transition:color .2s;
}
.display.done { color:var(--accent); animation:pulse 1s infinite; }
@keyframes pulse { 50% { opacity:.4; } }
.inputs { display:flex; gap:8px; justify-content:center; margin-bottom:22px; }
.field { display:flex; flex-direction:column; align-items:center; gap:6px; }
.field label { font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:1px; }
input {
width:80px; padding:12px; font-size:22px; text-align:center; border-radius:12px;
border:1px solid #2a3143; background:#0e121b; color:var(--fg); font-variant-numeric:tabular-nums;
}
input:focus { outline:none; border-color:var(--accent); }
.btns { display:flex; gap:10px; }
button {
flex:1; padding:14px; font-size:15px; font-weight:600; border-radius:12px; cursor:pointer;
border:none; transition:transform .05s, background .2s;
}
button:active { transform:translateY(1px); }
.start { background:var(--accent); color:#1a1200; }
.reset { background:#222839; color:var(--fg); }
.presets { display:flex; gap:8px; justify-content:center; margin-bottom:20px; flex-wrap:wrap; }
.presets button { flex:none; padding:8px 14px; font-size:13px; background:#1b2130; color:var(--muted); font-weight:500; }
.presets button:hover { color:var(--fg); }
</style>
</head>
<body>
<div class="card">
<h1>⏱️ Timer</h1>
<div class="sub">Entrez une durée, comptez jusqu'à zéro</div>
<div id="display" class="display">00:00</div>
<div class="presets">
<button data-s="60">1 min</button>
<button data-s="300">5 min</button>
<button data-s="600">10 min</button>
<button data-s="1500">25 min</button>
</div>
<div class="inputs">
<div class="field"><label>Min</label><input id="min" type="number" min="0" max="999" value="0"></div>
<div class="field"><label>Sec</label><input id="sec" type="number" min="0" max="59" value="30"></div>
</div>
<div class="btns">
<button id="start" class="start">Démarrer</button>
<button id="reset" class="reset">Réinitialiser</button>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const display = $('display'), minI = $('min'), secI = $('sec');
let remaining = 0, tick = null, running = false;
function fmt(t) {
const m = Math.floor(t / 60), s = t % 60;
return String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0');
}
function render() {
display.textContent = fmt(remaining);
display.classList.toggle('done', remaining === 0 && !running);
}
function readInput() {
const m = Math.max(0, parseInt(minI.value || '0', 10));
const s = Math.max(0, Math.min(59, parseInt(secI.value || '0', 10)));
return m * 60 + s;
}
function stop() { clearInterval(tick); tick = null; running = false; }
function start() {
if (running) { stop(); $('start').textContent = 'Démarrer'; render(); return; }
if (remaining <= 0) remaining = readInput();
if (remaining <= 0) return;
running = true; $('start').textContent = 'Pause';
display.classList.remove('done');
tick = setInterval(() => {
remaining--;
render();
if (remaining <= 0) {
stop(); $('start').textContent = 'Démarrer';
display.classList.add('done');
try { new AudioContext(); } catch (e) {}
beep();
}
}, 1000);
}
function reset() { stop(); $('start').textContent = 'Démarrer'; remaining = readInput(); render(); }
function beep() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const o = ctx.createOscillator(), g = ctx.createGain();
o.connect(g); g.connect(ctx.destination);
o.frequency.value = 880; o.type = 'sine';
g.gain.setValueAtTime(0.001, ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.3, ctx.currentTime + 0.02);
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.6);
o.start(); o.stop(ctx.currentTime + 0.6);
} catch (e) {}
}
$('start').onclick = start;
$('reset').onclick = reset;
document.querySelectorAll('.presets button').forEach((b) => {
b.onclick = () => { const t = +b.dataset.s; minI.value = Math.floor(t/60); secI.value = t%60; reset(); };
});
[minI, secI].forEach((el) => el.oninput = () => { if (!running) reset(); });
reset();
</script>
</body>
</html>`;
exports.handler = (request) => {
return [200, PAGE, { 'content-type': 'text/html; charset=utf-8' }];
};An API aggregator (Python): a geocode then three concurrent calls (weather, air quality, Wikipedia) merged into a single JSON response. Total latency is the slowest API, not their sum.
main.py
"""zerolith · exemple « agrégateur d'API ».
Un cas d'usage courant : une requête cliente déclenche un géocodage, puis un appel
simultané à trois APIs publiques (météo, qualité de l'air, Wikipédia), le tout fusionné
en une seule réponse JSON. La latence rendue est celle de l'API la plus lente, pas la
somme des trois.
Aucune clé d'API, aucun état conservé : la fonction est purement calculatoire. C'est le
profil idéal pour le scale-to-zero — elle ne coûte rien tant que personne ne l'appelle,
et la réponse expose son propre temps de démarrage pour que vous puissiez le constater.
GET /?city=Paris
GET /?city=Tokyo&lang=en
"""
import asyncio
import os
import time
from urllib.parse import quote
import httpx
TIMEOUT = float(os.environ.get("DEMO_TIMEOUT_SECONDS", "6"))
UA = {"User-Agent": "zerolith-demo/1.0 (+https://zerolith.io)"}
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
AIR_URL = "https://air-quality-api.open-meteo.com/v1/air-quality"
# Échelle European AQI (bornes hautes -> libellé).
AQI_BANDS = [
(20, "bon"),
(40, "correct"),
(60, "moyen"),
(80, "mauvais"),
(100, "très mauvais"),
(float("inf"), "extrêmement mauvais"),
]
# Cette fonction est appelée depuis un navigateur : sans cet en-tête, le navigateur
# refuse de livrer la réponse à la page. « * » est ici sans risque — la fonction est
# publique et ne lit aucun cookie, elle n'expose donc rien de plus qu'un appel anonyme.
CORS = {
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-store",
}
# Renseigné à l'import du module, donc au démarrage du pod : les compteurs ci-dessous
# mesurent la vraie durée de vie de l'instance, pas celle de la requête.
_BOOT = time.monotonic()
_POD = os.environ.get("HOSTNAME", "inconnu")
_SERVED = 0
def _aqi_label(value):
if value is None:
return None
return next(label for ceiling, label in AQI_BANDS if value < ceiling)
async def _fetch(client, name, url, params):
"""Retourne (nom, résultat) sans jamais lever : une source indisponible dégrade la
réponse au lieu de la casser.
Les erreurs de connexion transitoires sont réessayées quelques fois, très brièvement.
Le géocodage est une dépendance dure de la réponse : sans ces essais, un aléa réseau
d'une fraction de seconde transformerait une requête parfaitement valide en 404.
"""
started = time.perf_counter()
last = None
for attempt in range(3):
try:
response = await client.get(url, params=params, headers=UA, timeout=TIMEOUT)
response.raise_for_status()
return name, {
"ok": True,
"ms": round((time.perf_counter() - started) * 1000),
"data": response.json(),
}
except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
last = exc
await asyncio.sleep(0.25 * (attempt + 1))
except Exception as exc:
last = exc
break
return name, {
"ok": False,
"ms": round((time.perf_counter() - started) * 1000),
"error": f"{type(last).__name__}: {last}",
}
async def _geocode(client, city):
name, result = await _fetch(
client, "geocoding", GEOCODE_URL, {"name": city, "count": 1, "language": "fr"}
)
if not result["ok"]:
return None, result
hits = result["data"].get("results") or []
if not hits:
return None, {**result, "ok": False, "error": f"ville introuvable : {city!r}"}
return hits[0], result
async def _aggregate(city, lang):
timings = {}
async with httpx.AsyncClient() as client:
place, timings["geocoding"] = await _geocode(client, city)
if place is None:
return None, timings
coords = {"latitude": place["latitude"], "longitude": place["longitude"]}
fanout_started = time.perf_counter()
# Le cœur de l'exemple : trois APIs indépendantes interrogées en parallèle, donc
# la latence totale est celle de la plus lente et non la somme des trois.
results = await asyncio.gather(
_fetch(
client,
"weather",
FORECAST_URL,
{**coords, "current": "temperature_2m,wind_speed_10m,weather_code"},
),
_fetch(client, "air_quality", AIR_URL, {**coords, "current": "european_aqi"}),
_fetch(
client,
"wikipedia",
f"https://{lang}.wikipedia.org/api/rest_v1/page/summary/{quote(place['name'])}",
None,
),
)
fanout_ms = round((time.perf_counter() - fanout_started) * 1000)
sources = dict(results)
timings.update(sources)
weather = sources["weather"].get("data", {}).get("current", {})
air = sources["air_quality"].get("data", {}).get("current", {})
aqi = air.get("european_aqi")
body = {
"city": place["name"],
"country": place.get("country"),
"coordinates": {"lat": place["latitude"], "lon": place["longitude"]},
"timezone": place.get("timezone"),
"weather": {
"temperature_c": weather.get("temperature_2m"),
"wind_kmh": weather.get("wind_speed_10m"),
"observed_at": weather.get("time"),
},
"air_quality": {"european_aqi": aqi, "label": _aqi_label(aqi)},
"about": sources["wikipedia"].get("data", {}).get("extract"),
"fanout_ms": fanout_ms,
}
return body, timings
def handler(request):
global _SERVED
started = time.perf_counter()
cold_start = _SERVED == 0
_SERVED += 1
city = (request.query.get("city") or "Paris").strip()
lang = (request.query.get("lang") or "fr").strip().lower()
if lang not in ("fr", "en", "de", "es", "it"):
lang = "fr"
body, timings = asyncio.run(_aggregate(city, lang))
if body is None:
error = timings["geocoding"].get("error", "géocodage impossible")
return 404, {"error": error, "city": city}, CORS
body["meta"] = {
"total_ms": round((time.perf_counter() - started) * 1000),
"sources": {name: {"ok": r["ok"], "ms": r["ms"]} for name, r in timings.items()},
# Le nom du pod change à chaque remontée depuis zéro : de quoi vérifier vous-même
# le scale-to-zero en rappelant la fonction après quelques minutes d'inactivité.
"pod": _POD,
"cold_start": cold_start,
"pod_age_seconds": round(time.monotonic() - _BOOT, 1),
"requests_served_by_this_pod": _SERVED,
}
return 200, body, CORSAn inventory API (Python) backed by a private database: catalogue, item sheet with its history, stock movements in and out. The data survives sleep, since the database sleeps alongside the function and wakes on the first request. This public instance is read-only.
main.py
"""zerolith · exemple « API de stock », branchée sur une base de données privée.
Un inventaire de magasin servi par une fonction : consultation du catalogue, fiche
article avec son historique, entrées et sorties, et quelques agrégats. Les données
vivent dans une base privée attachée à la fonction — SQLite en réseau, répliquée en
continu vers un stockage objet.
Vous n'avez rien à configurer : quand une base est attachée, la plateforme injecte
DATABASE_URL et DATABASE_AUTH_TOKEN dans le pod. Le jeton n'est jamais affiché nulle
part, ni dans l'interface ni dans l'API — il est monté depuis un secret en écriture
seule, et les identifiants du stockage objet ne quittent jamais le serveur de base.
La base se met en veille comme la fonction : au repos, ni l'une ni l'autre ne coûte
de temps de calcul, seul le palier de stockage réservé reste facturé. Le premier appel
après une période creuse réveille les deux et paie un démarrage à froid.
Le stock n'est PAS une colonne que l'on incrémente, mais la somme d'un registre de
mouvements. Un registre donne l'historique gratuitement et évite que deux écritures
simultanées s'écrasent — la contrepartie étant une agrégation à la lecture, négligeable
à cette échelle et indexée.
Le schéma est posé par une fonction compagnon désignée comme migrateur de la base
(voir la section « Bases de données » de la documentation) :
products(id, sku, name, category, price_cents, reorder_level, created_at)
movements(id, product_id, delta, reason, created_at)
Routes
GET / ce message et les agrégats
GET /products catalogue + stock courant (?category= ?low_stock=1)
GET /products/<sku> fiche article + 20 derniers mouvements
POST /products {sku, name, category, price_cents, reorder_level, quantity}
POST /movements {sku, delta, reason} delta négatif = sortie
GET /stats agrégats et valeur du stock
"""
import os
import libsql_client
# Cette instance est une vitrine publique : les écritures y sont désactivées pour que
# les données restent lisibles par tous. Retirez cette variable d'environnement (ou
# passez-la à "0") pour obtenir l'API complète sur votre propre déploiement.
READ_ONLY = os.environ.get("STOCK_READ_ONLY", "1").lower() in ("1", "true", "yes")
# La fonction est appelée depuis un navigateur : sans cet en-tête, le navigateur refuse
# de livrer la réponse à la page. Sans risque ici — la fonction est publique et ne lit
# aucun cookie, elle n'expose donc rien de plus qu'un appel anonyme.
CORS = {
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-store",
}
# Le stock courant se calcule à la lecture : une jointure sur le registre de mouvements.
STOCK_SELECT = """
SELECT p.id, p.sku, p.name, p.category, p.price_cents, p.reorder_level,
CAST(COALESCE(SUM(m.delta), 0) AS INTEGER) AS qty
FROM products p
LEFT JOIN movements m ON m.product_id = p.id
"""
def _client():
"""Ouvre une connexion à la base privée attachée à cette fonction.
Le client libSQL parle HTTP : c'est ce qui permet à la base de dormir et de se
réveiller à la demande, exactement comme la fonction.
"""
return libsql_client.create_client_sync(
url=os.environ["DATABASE_URL"],
auth_token=os.environ["DATABASE_AUTH_TOKEN"],
)
def _product_row(row):
qty, reorder = int(row[6]), int(row[5])
return {
"sku": row[1],
"name": row[2],
"category": row[3],
"price_eur": round(int(row[4]) / 100, 2),
"quantity": qty,
"reorder_level": reorder,
"low_stock": qty <= reorder,
}
def _list_products(db, query):
where, params = [], []
if query.get("category"):
where.append("p.category = ?")
params.append(query["category"])
sql = STOCK_SELECT
if where:
sql += " WHERE " + " AND ".join(where)
sql += " GROUP BY p.id ORDER BY p.category, p.sku"
items = [_product_row(r) for r in db.execute(sql, params).rows]
if query.get("low_stock") in ("1", "true", "yes"):
items = [i for i in items if i["low_stock"]]
return {"count": len(items), "products": items}
def _get_product(db, sku):
rows = db.execute(STOCK_SELECT + " WHERE p.sku = ? GROUP BY p.id", [sku]).rows
if not rows:
return 404, {"error": "sku inconnu", "sku": sku}
product = _product_row(rows[0])
movements = db.execute(
"SELECT delta, reason, created_at FROM movements WHERE product_id = ? "
"ORDER BY id DESC LIMIT 20",
[rows[0][0]],
).rows
product["movements"] = [
{"delta": int(m[0]), "reason": m[1], "at": m[2]} for m in movements
]
return 200, product
def _create_product(db, body):
sku = (body.get("sku") or "").strip()
name = (body.get("name") or "").strip()
if not sku or not name:
return 400, {"error": "sku et name sont obligatoires"}
if db.execute("SELECT COUNT(*) FROM products WHERE sku = ?", [sku]).rows[0][0]:
return 409, {"error": "sku deja utilise", "sku": sku}
db.execute(
"INSERT INTO products (sku, name, category, price_cents, reorder_level) "
"VALUES (?, ?, ?, ?, ?)",
[
sku,
name,
body.get("category") or "divers",
int(body.get("price_cents") or 0),
int(body.get("reorder_level") or 0),
],
)
pid = db.execute("SELECT id FROM products WHERE sku = ?", [sku]).rows[0][0]
qty = int(body.get("quantity") or 0)
if qty:
db.execute(
"INSERT INTO movements (product_id, delta, reason) VALUES (?, ?, 'reception')",
[pid, qty],
)
return 201, {"created": sku, "quantity": qty}
def _move(db, body):
"""Enregistre une entrée (delta positif) ou une sortie (delta négatif)."""
sku = (body.get("sku") or "").strip()
try:
delta = int(body.get("delta"))
except (TypeError, ValueError):
return 400, {"error": "delta doit etre un entier non nul"}
if not sku or delta == 0:
return 400, {"error": "sku et delta (non nul) sont obligatoires"}
rows = db.execute("SELECT id FROM products WHERE sku = ?", [sku]).rows
if not rows:
return 404, {"error": "sku inconnu", "sku": sku}
pid = rows[0][0]
def stock():
return int(
db.execute(
"SELECT COALESCE(SUM(delta), 0) FROM movements WHERE product_id = ?", [pid]
).rows[0][0]
)
before = stock()
if before + delta < 0:
return 409, {
"error": "stock insuffisant",
"sku": sku,
"quantity": before,
"requested": delta,
}
# Le contrôle est rejoué dans le SQL lui-même : entre la lecture ci-dessus et cette
# écriture, une autre requête a pu vider le stock. La condition WHERE rend l'insertion
# conditionnelle côté base, ce qui règle la concurrence sans verrou applicatif.
db.execute(
"INSERT INTO movements (product_id, delta, reason) "
"SELECT ?, ?, ? WHERE (SELECT COALESCE(SUM(delta), 0) FROM movements "
"WHERE product_id = ?) + ? >= 0",
[pid, delta, (body.get("reason") or "manual")[:40], pid, delta],
)
after = stock()
if after == before:
return 409, {"error": "stock insuffisant (concurrence)", "sku": sku, "quantity": after}
return 200, {"sku": sku, "delta": delta, "quantity": after}
def _stats(db):
row = db.execute(
"SELECT COUNT(*), COALESCE(SUM(qty), 0), COALESCE(SUM(qty * price_cents), 0), "
"COALESCE(SUM(CASE WHEN qty <= reorder_level THEN 1 ELSE 0 END), 0) "
"FROM (" + STOCK_SELECT + " GROUP BY p.id)"
).rows[0]
by_cat = db.execute(
"SELECT category, COUNT(*), COALESCE(SUM(qty), 0) FROM ("
+ STOCK_SELECT
+ " GROUP BY p.id) GROUP BY category ORDER BY category"
).rows
movements = db.execute("SELECT COUNT(*) FROM movements").rows[0][0]
return {
"references": int(row[0]),
"articles_en_stock": int(row[1]),
"valeur_stock_eur": round(int(row[2]) / 100, 2),
"references_a_reapprovisionner": int(row[3]),
"mouvements_enregistres": int(movements),
"par_categorie": [
{"category": c[0], "references": int(c[1]), "quantity": int(c[2])} for c in by_cat
],
}
def handler(request):
parts = [p for p in request.path.split("/") if p]
method = request.method.upper()
if READ_ONLY and method not in ("GET", "HEAD"):
return 403, {"error": "instance de démonstration en lecture seule"}, CORS
db = _client()
try:
if not parts:
return 200, {
"service": "stock-api",
"database": "base privée attachée (libSQL, répliquée)",
"read_only": READ_ONLY,
"routes": [
"GET /products?category=&low_stock=1",
"GET /products/<sku>",
"POST /products {sku,name,category,price_cents,reorder_level,quantity}",
"POST /movements {sku,delta,reason}",
"GET /stats",
],
"stats": _stats(db),
}, CORS
if parts[0] == "products":
if len(parts) == 1:
if method == "GET":
return 200, _list_products(db, request.query), CORS
if method == "POST":
status, body = _create_product(db, request.json() or {})
return status, body, CORS
return 405, {"error": "methode non autorisee"}, CORS
if method == "GET":
status, body = _get_product(db, parts[1])
return status, body, CORS
return 405, {"error": "methode non autorisee"}, CORS
if parts[0] == "movements" and len(parts) == 1:
if method == "POST":
status, body = _move(db, request.json() or {})
return status, body, CORS
return 405, {"error": "methode non autorisee"}, CORS
if parts[0] == "stats" and method == "GET":
return 200, _stats(db), CORS
return 404, {"error": "route inconnue", "path": request.path}, CORS
finally:
db.close()Nothing is invoked (or billed) until you click: that is also what lets these functions honestly sit at zero instances. The first call triggers a brief cold start; the next ones are served warm.
The examples repository collects these handlers and more, ready to deploy, with the commands that ship them: github.com/zerolith-faas/zerolith-examples
Environment variables
Define reusable variables (config or secret) at the account level, then attach them per function. They are injected as plain environment variables:
# Python # Node.js import os process.env.MY_VAR os.environ["MY_VAR"]
Everything is also driven through the API: create, list, update and delete your variables, then attach them to a function through its env_var_ids field:
# create a secret — its value is write-only, it can never be read back
curl -X POST -H "Authorization: Bearer $JWT" \
-d '{"name": "API_TOKEN", "kind": "secret", "value": "s3cr3t"}' \
https://zerolith.io/api/env-vars
# attach it to a function (rolls a new revision)
curl -X PATCH -H "Authorization: Bearer $JWT" \
-d '{"env_var_ids": ["<env-var-id>"]}' \
https://zerolith.io/api/functions/<id>Updating a variable's value automatically rolls a new revision of every function using it. Deleting a variable still attached to a function is refused (409) until it is detached.
Secret values are write-only: stored only in the cluster, never readable back through the API. The FAAS_ prefix is reserved for the platform.
Databases
Every account can provision a private database: network-accessible SQLite, replicated continuously to object storage. It lives in your isolated space, is never publicly exposed, and is reachable only by your own functions.
It sleeps just like a function. With no query for the configured idle window (10 minutes by default, adjustable from 6 seconds to 1 hour) the instance stops: you pay no compute at all, only the storage tier you hold, because your data sits in object storage and never moves. The next query wakes the database automatically and pays a few seconds of cold start, restore included. A longer idle window avoids paying that wake-up between two spaced-out requests. If you would rather have consistently flat latency, always-on keeps one instance up permanently, at the corresponding compute cost. Both settings can be changed at any time, without moving the data.
Attach the database to a function and the platform injects these two variables into its pod. The token is never displayed, it is mounted from a write-only secret.
# Python # Node.js import os const url = process.env.DATABASE_URL; url = os.environ["DATABASE_URL"] const tok = process.env.DATABASE_AUTH_TOKEN; token = os.environ["DATABASE_AUTH_TOKEN"]
# main.py — the libSQL client ships with the runtime
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"],
)
db.execute("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT)")
db.execute("INSERT INTO notes (body) VALUES (?)", ["hello"])
rows = db.execute("SELECT body FROM notes").rows
db.close()
return {"notes": [r[0] for r in rows]}A database attached to a function cannot be deleted (409): detach it first. The storage tier is a hard cap; writes fail past it.
The database server cannot call your functions, so migrations are explicit. Deploy the migrator with the “migrate” kind — no service, no route, no hostname, so private by construction — designate it, then trigger it on demand from the UI, the API or an MCP agent.
Billing: the database instance costs the same per GB-second as a function of the same size (and nothing while it sleeps), plus the storage tier you hold, prorated per month. Raising the tier takes effect immediately; it cannot be lowered.
Backups and retention: generate a portable SQL export of your database at any time, downloadable for 24 h and restorable anywhere (gunzip -c export.sql.gz | sqlite3 mine.db). The service is in beta, so keep your own backups. If your balance stays at zero, the data is kept for 7 days and then PERMANENTLY DELETED; topping up before the deadline cancels it. Deleting a database also erases its data from object storage, but your exports are kept.
Dedicated page: how databases work, who can access them, backup and cost →
Custom domains
Serve a function on a hostname you own: api.example.com instead of the platform address. The platform terminates TLS for it and obtains the certificate itself, so you never upload one.
Add the domain from the function's page and publish the two records it shows you:
- a TXT under _zerolith-challenge.your-domain, holding a token that proves you control the zone;
- a CNAME from your hostname to the function's own platform address; no new record from us, and the target follows the platform if that address ever moves.
Nothing is routed on an unproven claim. The platform re-checks periodically; once the TXT record is seen it provisions the route and orders the certificate, which is usually ready within a minute. Only then does the domain report that it is serving.
Once verified, the TXT record has done its job and can be removed, since it is read exactly once. The CNAME is what carries traffic and must stay.
Up to 10 domains per account. Wildcards are not supported: the certificate is obtained over HTTP-01, which cannot validate a `*` name. Hostnames the platform already serves cannot be claimed.
A claim that is never verified is released after 14 days, so a hostname you stop wanting does not stay reserved for ever.
Releasing a domain stops it serving immediately. Because re-adding it means ordering another certificate, the hostname cannot be claimed again for 60 minutes.
Sizes and limits
Sizes are resource presets (memory × CPU). The price follows the reserved memory and CPU, multiplied by execution time:
Execution timeout configurable from 1 to 600 s. Scale from 0 (scale-to-zero) up to your function's max scale; min ≥ 1 keeps warm instances, billed continuously.
Per-account quotas:
| Deployed functions | 50 |
| Code size per function | 768 KiB |
| Environment variables | 100 |
| … of which secrets | 50 |
| Single value / total budget | 4 KiB / 512 KiB |
| API request body | 2 MiB |
| Invocations per account (at the edge) | 600 / min |
Throughput is controlled at the edge, before your function even wakes. Public functions called without a key get their own per-function counter, so a spike on a public webhook does not drain the rest of the account's budget.
Lifecycle & scaling
A function scales up and down on its own (Knative). While idle it runs at zero instances and costs nothing; the first request triggers a brief cold start, then subsequent calls are served warm.
timeout = max duration of a single execution; stable window = idle time before scaling back to zero instances.
Every deployment (new code, config or environment variable change) creates a new immutable revision of the function. Traffic only switches to it once it is ready to serve, so an update causes no downtime.
Tunable from the function form: timeout bounds a single execution (1–600 s); the stable window is the idle time before scaling back to zero; min scale at 0 enables scale-to-zero (on demand), ≥ 1 keeps warm instances (billed continuously); max scale caps concurrency. Billing only runs during execution (reserved memory × time), plus invocations.
Observability & metrics
Every function exposes live metrics: requests per second, latency (p50 / p95 / p99), active instances, cold starts, CPU and memory. The app's Status page displays them, and the same API is open to your scripts:
# live snapshot for all your functions (rolling window, default 5 min)
curl -H "Authorization: Bearer $JWT" \
"https://zerolith.io/api/functions/metrics?window_seconds=3600"
# time series for one function — one point per step (feeds the Status graphs)
curl -H "Authorization: Bearer $JWT" \
"https://zerolith.io/api/functions/<id>/series?window_seconds=3600&step_seconds=60"Returned fields: rps, latency_p50_ms / p95 / p99, instances, cold_starts, cpu_millicores, memory_mb. Rolling window adjustable from 1 minute to 7 days (default 5 min). These metrics are measured live, nothing is stored.
On the billing side, the usage API returns the account totals (requests, GB-seconds, cost) and the detail of every billed window:
# billed requests, GB-seconds and cost — CURRENT MONTH by default
curl -H "Authorization: Bearer $JWT" https://zerolith.io/api/usage/summary
# account lifetime, or any explicit range (since/until are half-open)
curl -H "Authorization: Bearer $JWT" "https://zerolith.io/api/usage/summary?period=all"
curl -H "Authorization: Bearer $JWT" \
"https://zerolith.io/api/usage/summary?since=2026-06-01T00:00:00Z&until=2026-07-01T00:00:00Z"
# every billed window, filterable per function, paginated with limit/offset
curl -H "Authorization: Bearer $JWT" \
"https://zerolith.io/api/usage/windows?function_id=<id>&limit=100&offset=100"Usage windows are the billing source of truth: they are kept even after the function is deleted.
Cron triggers
Any function can be fired on a schedule with a cron expression (UTC), through the API:
curl -X POST -H "Authorization: Bearer $JWT" \
-d '{"cron": "*/15 * * * *"}' \
https://zerolith.io/api/functions/<id>/schedulesCron invocations go through the same credit gate as external calls: a dry account wakes nothing.
Agent driving (MCP)
zerolith.io is also an MCP server with OAuth 2.1: connect Claude or any MCP client to your account and deploy, edit and invoke your functions in natural language, with no key to copy.
The server exposes 41 tools, all scoped to your account. Read tools require the mcp:read scope; the others require mcp:write and a verified account:
| Tool | Scope | Description |
|---|---|---|
| whoami | mcp:read | Authenticated account and credit balance. |
| get_catalog | mcp:read | Languages, size presets, deploy defaults and pricing. |
| list_functions | mcp:read | List your deployed functions. |
| get_function | mcp:read | One function: config, URL, attached env vars and current source code. |
| deploy_function | mcp:write | Deploy a new function. |
| update_function | mcp:write | Update a function (code and/or config). |
| delete_function | mcp:write | Delete a function. |
| invoke_function | mcp:write | Invoke a deployed function (credit-gated). |
| get_function_metrics | mcp:read | Live metrics (rps, latency, instances, CPU/memory). |
| get_function_logs | mcp:read | Recent stdout/stderr of one function, newest first; optional substring filter. |
| get_database_logs | mcp:read | Recent sqld engine log of one database; platform storage identifiers are redacted. |
| get_usage_summary | mcp:read | Balance and billed totals (requests, GB-seconds, cost). |
| list_env_vars | mcp:read | List the account's environment variables. |
| get_env_var | mcp:read | One variable (value readable for config; never for a secret). |
| create_env_var | mcp:write | Create a variable (config or secret). |
| update_env_var | mcp:write | Replace a variable's value. |
| delete_env_var | mcp:write | Delete a variable (409 if still attached to a function). |
| list_custom_domains | mcp:read | List your custom domains and the DNS records to publish. |
| add_custom_domain | mcp:write | Attach a domain name to a function (returns the DNS records to create). |
| delete_custom_domain | mcp:write | Release a custom domain and remove its route. |
| create_presigned_url | mcp:write | Create a URL that invokes one function until a deadline, with no API key. |
| list_presigned_urls | mcp:read | List a function's presigned URLs (never the tokens themselves). |
| revoke_presigned_url | mcp:write | Revoke a presigned URL before its deadline. |
| list_databases | mcp:read | List your private databases. |
| get_database | mcp:read | One database: tier, size, scaling mode and attached functions. |
| create_database | mcp:write | Create a private database (the token is never returned). |
| delete_database | mcp:write | Delete a database (409 if still attached). |
| attach_database | mcp:write | Attach a database to a function (injects the variables). |
| detach_database | mcp:write | Detach a database from a function. |
| get_database_metrics | mcp:read | Live status: CPU, memory, instances and disk used vs the tier. |
| set_database_size | mcp:write | Change a database's CPU/memory preset. |
| set_database_tier | mcp:write | Raise a database's storage tier (upgrade only — it cannot be lowered). |
| rotate_database_token | mcp:write | Rotate a database's access token: the old one is withdrawn and attached functions redeployed. |
| set_database_always_on | mcp:write | Keep a database always on, or let it sleep. |
| set_database_stable_window | mcp:write | Change how long a database stays up while idle before sleeping. |
| export_database | mcp:write | Start a SQL dump of the database (one export per hour per account). |
| get_database_export | mcp:write | The latest export's status, with its download link once it is ready. |
| list_database_exports | mcp:write | A database's export history; only the newest is still downloadable. |
| set_database_migrator | mcp:write | Designate one of your functions as the schema migrator. |
| unset_database_migrator | mcp:write | Remove the migrator designation (the function stays attached). |
| run_migration | mcp:write | Run the designated migration function, on demand. |
The whole sequence, from wiring the MCP server to calling the function. The last beat really calls a deployed function; nothing runs until you start the demo.
Security & isolation
Every account runs in its own Kubernetes space, walled off by a network policy that denies everything by default. Your functions, environment variables and database live there together and do not leave: another account's pods have no route to yours.
Calls are authenticated at the edge, before a container even starts: an invalid key, a suspended account or an empty balance are refused without ever waking your code, and therefore without billing you. Your secrets are read back by nobody, not even you: neither the API, the dashboard nor an AI agent returns their value. API keys are stored hashed and shown once, at creation.
Dedicated page: isolation, edge authentication, secrets, agents and data →