cd /news/developer-tools/artefact-canvas-frame-heberger-vous-… · home topics developer-tools article
[ARTICLE · art-113440] src=zerolith.io ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Artefact, canvas, frame : héberger vous-même la page générée

Zerolith, a serverless platform, now lets developers host AI-generated HTML pages directly from functions, giving them a permanent URL, a private SQLite database, and four access-control options (API key, signed URL, public, custom domain). The feature addresses the limitation of AI artifacts like Claude's Artefact, ChatGPT's Canvas, and Dust's Frame, which lack self-hosting capabilities. A live dashboard example using Open-Meteo data is available at demo-dashboard-fn-539b8643bc44462c815a5ad5a423d976.api.zerolith.io.

read11 min views1 publishedAug 16, 2026
Artefact, canvas, frame : héberger vous-même la page générée
Image: Zerolith (auto-discovered)

Un artefact montre une idée en dix secondes, puis reste dans la conversation. Une fonction rend la même page HTML, avec une URL qui est la vôtre, une base SQLite privée, et quatre façons de choisir qui peut l'ouvrir : clé d'API, URL signée, public, domaine sur mesure.

Vous avez sans doute déjà fait ça : décrire un graphique à un assistant, et recevoir dix secondes plus tard une page qui marche, avec ses courbes, ses couleurs et son survol. Chaque éditeur a son nom pour cet objet. C'est un artefact chez Claude, un canvas chez ChatGPT, chez Gemini et dans Le Chat de Mistral, Grok Studio chez xAI, un Lab chez Perplexity, une Frame chez Dust. Tous font très bien la même chose : montrer une idée avant de décider si elle vaut un projet.

Puis vient la question suivante, et elle tue la démo : « tu peux m'envoyer le lien ? »

Un lien, il y en a parfois un. On publie un artefact, on partage une Frame par un lien à jeton, l'app d'un Lab se déploie sur sa propre page. Mais cette page vit chez l'éditeur, dans un bac à sable : les chiffres qu'elle affiche sont ceux qu'on a écrits dedans, elle n'a aucun endroit à elle où écrire, et « qui peut l'ouvrir » se limite à ce que le produit propose, publique ou réservée à votre espace de travail.

Le manque n'est pas dans le rendu, il est dans l'hébergement. Or une fonction sait très bien renvoyer une page, et pas seulement du JSON. Avec, en plus, les trois choses qui manquent à un artefact : une URL qui est la vôtre, une base de données, et une porte dont vous choisissez la serrure.

Une fonction peut répondre autre chose que du JSON #

Le contrat du handler laisse choisir le type de contenu. Un dictionnaire part en application/json

, une chaîne en text/plain

, mais ce sont des défauts, pas des impositions : un Content-Type

dans les en-têtes les remplace.

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

C'est tout ce qu'il faut. Déployez, ouvrez l'URL : votre navigateur affiche une page. Pas de bundler, pas de npm install

, pas d'étape de build, pas de serveur statique à côté ; un fichier et une réponse HTTP suffisent.

Le même levier sert pour un SVG (image/svg+xml

), un flux RSS (application/rss+xml

), un .ics

, un CSV que le navigateur propose d'enregistrer. Une fonction n'est pas condamnée à être une API.

Le vrai exemple : un tableau de bord avec ses données #

Une page statique n'apporte rien de plus qu'un fichier posé quelque part. L'intérêt commence quand la page va chercher ses données au moment où on la charge.

L'exemple ci-dessous interroge open-meteo (aucune clé d'API), calcule la géométrie de deux graphiques, et renvoie un document autonome. httpx

est déjà dans l'image du runtime.

La fonction tourne, et vous pouvez l'ouvrir tout de suite : le tableau de bord, en direct. Changez ?city=

, ajoutez &format=json

pour les données brutes ; le pied de page vous dit quel pod vous a répondu et s'il venait de démarrer à froid. Le handler complet est aussi dans la galerie d'exemples de la documentation, avec les autres fonctions publiques.

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": "fr"})
        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()

Et le graphique. Pas de bibliothèque : une courbe SVG est une liste de points transformée en chaîne de caractères, et cette transformation tient en quinze lignes de Python.

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))   # une échelle à valeurs rondes
    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="Température">'
            f'<path d="{path}" fill="none" stroke="#1fa3a7" stroke-width="2" '
            f'stroke-linejoin="round"/></svg>')

Le handler assemble : quatre tuiles de valeurs courantes, la courbe des vingt-quatre prochaines heures, des barres d'amplitude jour par jour, le CSS et une vingtaine de lignes de JavaScript pour le survol.

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

def handler(request):
    city = (request.query.get("city") or "Paris").strip()[:80]
    data = fetch(city, 7)
    if data is None:
        return 404, page_introuvable(city), HTML
    if request.query.get("format") == "json":
        return 200, data                       # les mêmes données, brutes
    return 200, page(data), HTML

Deux détails qui comptent plus qu'ils n'en ont l'air.

Les graphiques sont déjà dessinés quand la page arrive. Le SVG est dans le HTML, pas construit par du JavaScript au chargement. Il n'y a donc ni écran de chargement, ni saut de mise en page, ni page blanche si un script échoue ; le JavaScript n'ajoute que l'infobulle au survol. Un crawler, un lecteur d'écran ou un curl

voient le même contenu que vous.

La même fonction sert la page et les données. ?format=json

renvoie le dictionnaire brut. Ce n'est pas une coquetterie : c'est ce qui fait que le tableau de bord n'est pas un cul-de-sac le jour où quelqu'un veut brancher autre chose dessus.

Ce que ça coûte #

Dessiner les graphiques sur le serveur ne coûte rien de mesurable : un SVG est une chaîne de caractères, et l'assemblage des 15 Ko de la page disparaît devant l'appel à l'API tierce. Les sortir du serveur pour les faire dessiner par le navigateur ne ferait donc rien gagner, au prix d'un bundle à charger.

Côté facture, small

réserve 0,125 Gio et 0,1 vCPU, aux tarifs publics de 0,000004 €/Gio-seconde et 0,0000125 €/vCPU-seconde :

mémoire     0,125 Gio × 0,000004 €  =  0,0000005  €/s
processeur    0,1 vCPU × 0,0000125 € =  0,00000125 €/s
                                       ────────────────
                                        0,00000175 €/s

Et là, comme pour un cron, il faut compter honnêtement : vous ne payez pas la seule durée du handler. Un pod ne s'éteint pas dès la réponse envoyée. L'autoscaler observe une fenêtre de stabilité (60 s par défaut) puis laisse un délai de grâce, si bien qu'une visite isolée coûte de l'ordre de 90 secondes de pod, soit 0,00016 €.

C'est la mauvaise nouvelle et la bonne à la fois : un tableau de bord regardé une fois par jour coûte un demi-centime par mois, et un tableau de bord consulté vingt fois dans l'après-midi ne coûte presque rien de plus, parce que les vingt visites tiennent dans le même pod. Le pire cas est la visite solitaire, pas l'affluence.

Trois pièges, payés en le faisant #

Échapper tout ce qui vient de la requête. Le nom de ville arrive du client et finit dans le HTML. Sans html.escape

, ?city=<script>…

est une faille XSS réfléchie servie depuis votre domaine. C'est la différence la plus importante entre une page rendue par un serveur et une page rendue dans une conversation : la vôtre a des visiteurs que vous ne choisissez pas.

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

La géométrie qui marche aujourd'hui casse sur d'autres données. Les étiquettes du pic et du creux se posaient proprement sur la météo du jour. Sur une série plate, les deux tombaient au même pixel ; à Tromsø, l'étiquette du minimum passait sous la colonne des graduations. Aucune de ces deux villes n'était dans le jeu d'essai. Rejouer le rendu sur des cas extrêmes (série plate, températures négatives, amplitude de 60 degrés, deux jours au lieu de dix) coûte cinq minutes et trouve ce qu'un coup d'œil ne trouve pas.

Le Content-Type est la moitié du sujet. Il vaut la peine de le vérifier avec

curl -I

plutôt que dans le navigateur : un text/plain

affiche votre page comme du code source, et le seul symptôme est « ça ne marche pas ». Nous avons appris celui-là de l'intérieur. Jusqu'au 16 août 2026, le runtime Python écrasait silencieusement l'en-tête du handler, et le runtime Node en émettait deux. C'est corrigé, et c'est exactement le genre de défaut qu'aucun test unitaire ne voit : il est dans l'octet qui part sur le réseau.## Contrôler qui peut ouvrir la page

Un artefact partagé, c'est un réglage à deux positions : privé, ou accessible à qui a le lien. Une fonction est privée par défaut, puisqu'elle veut une clé d'API, ce qu'un navigateur ne sait pas envoyer quand on lui donne un simple lien. Vous choisissez comment l'ouvrir, et il y a quatre régimes, un par usage, réglables fonction par fonction.

La clé d'API, dans l'en-tête. Authorization: Bearer <clé>

. C'est le régime par défaut, celui des appels de machine à machine : un script, un backend, un agent. La clé n'est stockée que sous forme de hachage chez nous et n'est montrée qu'une fois.

Une URL signée. La fonction reste privée et vous émettez un lien à échéance, révocable, valable pour cette fonction seulement. C'est le seul moyen d'envoyer une page à quelqu'un qui n'a rien à installer :

curl -X POST -H "Authorization: Bearer $JWT" \
     -d '{"name": "rapport-client", "expires_at": "2026-08-23T09:00:00Z"}' \
     https://zerolith.io/api/functions/<id>/tokens

C'est ce qu'il faut pour un tableau de bord envoyé à un client : le lien s'ouvre dans n'importe quel navigateur, expire tout seul, et se révoque sans toucher au reste. Le décompte de débit d'un lien signé est tenu par (compte, fonction)

, donc un lien qui part en boucle ne peut pas étouffer vos autres fonctions.

Publique. La fonction répond à tout le monde, sans identification, avec la même limitation de débit cloisonnée. C'est le bon choix pour une démo qu'on met dans un billet ou une conférence.

Votre propre domaine. rapport.macompagnie.com

pointé sur la fonction : un enregistrement TXT pour prouver que le domaine est à vous, un CNAME pour le trafic, et le certificat est émis puis renouvelé tout seul. La page cesse de ressembler à une démo hébergée ailleurs. C'est facturé au domaine et au mois ; le tarif est sur la page tarifs, la procédure dans la documentation.

Les serrures se combinent : un domaine à vous et une URL signée, une fonction publique et une autre privée dans le même compte.

Brancher une base de données sur la page #

Un artefact ne retient rien : rechargez-le, il repart de l'état où il a été écrit. Une fonction, elle, peut avoir sa propre base : une base SQLite privée, dans votre espace, qui dort quand personne ne l'interroge et se réveille à la première requête. On l'attache en une commande, et deux variables d'environnement apparaissent dans le pod :

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 visites(vue_le) VALUES (datetime('now'))")
        total = db.execute("SELECT count(*) FROM visites").rows[0][0]
    return 200, page(total), HTML

À partir de là, la page ne se contente plus d'afficher, elle enregistre : un formulaire de réponse, un vote d'équipe, une file d'attente, l'historique de ce que le cron a relevé cette nuit. Le tableau de bord montre alors vos données plutôt que celles d'une API publique. La marche à suivre complète est dans l'article précédent, et la base de données privée a sa page.

Ce que ça ne remplace pas #

Les limites sont réelles :

Il n'y a pas de CDN. Chaque chargement traverse la fonction. Pour un tableau de bord interne ou une démo, très bien ; pour une page d'accueil à fort trafic, non.Ce n'est pas un framework. Pas de routeur, pas de composants, pas d'état client. Au-delà de quelques écrans, vous voudrez un vrai front, et vous le déploierez ailleurs.Il faut déployer. Un artefact apparaît dans la conversation ; ici il y a une fonction à créer et un domaine à pointer si vous en voulez un. C'est quelques minutes, et c'est le prix de tout le reste.

Le point d'équilibre est assez net : une page, des données fraîches, un lien à envoyer. Un rapport hebdomadaire, un tableau de bord d'astreinte, la visualisation d'une table, une démo client, la sortie d'un cron rendue lisible.

Et l'assistant, dans tout ça #

Il ne disparaît pas de l'histoire, il change de place. Le serveur MCP expose le déploiement comme un outil : un agent écrit le handler, le déploie, l'appelle, lit les logs et corrige, dans la même conversation. La fonction de cet article a été déployée comme ça.

Gardez donc l'artefact, le canvas ou la Frame pour ce qu'ils font le mieux : écrire la page en dix secondes et la regarder. Ce qui change ensuite, c'est ce qui survit à la conversation, une URL qui est la vôtre, une serrure que vous choisissez, une base qui retient, un tarif à la seconde, et une page qui affichera les données de demain sans qu'on la réécrive.

Les tarifs sont sur la page tarifs et le contrat complet du handler dans la documentation.

Essayez sur votre propre compte #

L'inscription vient avec du crédit, de quoi déployer, planifier et mesurer tout ce qui précède sans sortir de carte bancaire.

Commencer »

── more in #developer-tools 4 stories · sorted by recency
── more on @zerolith 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/artefact-canvas-fram…] indexed:0 read:11min 2026-08-16 ·