Skip to content

Project assets

Assets are the files your project is made of — a logo, a favicon, a hero image, a product video, a demo track, a brochure PDF, a brand font. You upload them once, and the agent builds your app with the real files instead of placeholders. They’re served straight from your app’s origin at /api/_assets/<path>.

They sound alike and are deliberately different doors:

  • Files is for your app’s end users, at runtime — a visitor uploads a profile photo, a customer attaches a receipt. Your app writes and reads them through /api/_files.
  • Assets is for you, the owner, at build time — you hand the agent the files it should design with. You upload them from the console’s Assets area, and because they don’t wait on a running app, a favicon can be in place before the first build — which is exactly when it’s wanted.

The agent can read assets but never writes them; they’re yours.

Every asset is served read-only from your app:

Method & path Body Returns
GET /api/_assets/:path the file bytes (206 for a Range request)
HEAD /api/_assets/:path those headers alone

Nothing else — no PUT, no DELETE from inside the app; uploading and removing happen in the console. That table is the wire, and it’s what the app’s server.mjs calls. Inside the app the agent never writes those URLs by hand — it imports them:

import { assetUrl, assetGate, readAsset, AssetError } from "assets";
const logo = assetUrl("logo.svg");
// → "/api/_assets/logo.svg"

assetUrl(path) is the URL to put in an src, an href or a @font-face; it escapes each path segment, and handing it either logo.svg or /api/_assets/logo.svg gives back the same URL. readAsset(path) fetches the bytes as a Blob.text() a .md or .vtt, or createObjectURL an image — and throws an AssetError carrying .status and .reason if the file won’t open. assetGate(path) is the gate check, below.

Every response carries cache-control: public, max-age=60, must-revalidate and an etag, so a path is cached briefly and then revalidated — replacing a file at the same path reaches every visitor within the minute, at the cost of a conditional request. Paths are stable on purpose: the agent writes /api/_assets/logo.svg into your app, and re-uploading logo.svg has to keep that reference working.

Responses also carry accept-ranges: bytes and answer a Range request with 206, so audio and video seek — and play at all on iOS Safari, which refuses a media URL that ignores Range.

Every asset carries one of three gates, which you set on its tile in the Assets area. It’s the same URL either way — the app worker decides, per request, whether to hand the bytes over.

Gate Opens for Needs
Public anyone with the URL
Signed in any signed-in visitor Sign-in
Bought X a visitor who has bought that Stripe product — or subscribes to it Sign-in + Payments

A visitor who isn’t signed in gets 401; one who is, but hasn’t bought the product, gets 403 with the product id. The app never reads those status codes itself — a locked file dropped straight into a <video> is just a broken player with nothing to click, so the agent asks assetGate() first and renders the way out:

const gate = await assetGate("course.mp4");
if (gate.ok) play(assetUrl("course.mp4"));
else if (gate.reason === "sign-in") linkTo("/login");
else if (gate.reason === "purchase") offerToBuy(gate.productId);

gate.reason is "sign-in", "purchase", "missing" or "error", and on a purchase it carries the productId that /api/_pay/checkout needs. The check asks for a single byte, so probing a 5 GB video costs nothing; when it answers ok it also hands back the file’s contentType and size.

Gated responses are cache-control: private, no-store: a paid file is never handed to a CDN, and Range requests are gated too, so nobody seeks past the paywall.

A purchase entitles a visitor to the Stripe product, not to an amount — see Payments. Changing a gate re-stamps the file in place; the path and the bytes don’t move. The gate sticks to the path: re-uploading a file replaces its bytes and nothing else, so updating a paid video never opens it up.

A subscription product gates an asset too. The entitlement it grants is a lease paid through the end of the current period, so a subscription that stops renewing closes the file on its own — you don’t have to revoke anything, and a lost webhook can’t leave the content standing open.

Your project’s mark is the one spot of color Typillar shows you — the favicon in the browser tab of the built app, and the project’s chip in your console. Set it by clicking the mark in a project’s header and dropping in an image: Typillar crops it square, stores it as icon.png, and wires <link rel="icon"> into every page for you — kept in place across every build. Until you set one, the mark is your project’s initial. You never write the favicon link yourself.

An end user’s uploaded .svg at /api/_files is forced to download as an attachment — an inline SVG can carry script, and served on your app’s origin it could run and steal a signed-in session. An owner’s .svg at /api/_assets serves inline, rendered normally.

Same bytes, same MIME, opposite answer — because trust follows who uploaded a file, not what it is. You already write your app’s JavaScript; an inline SVG you uploaded grants you nothing you didn’t already have. An anonymous visitor’s inline SVG would be a brand-new way to run script on your origin. So the two doors judge the same file differently, on purpose.

Max size 5 GB per asset
Accepted types images (png, jpg, jpeg, gif, webp, avif, svg, ico), video (mp4, m4v, webm, mov), audio (mp3, m4a, aac, wav, ogg, flac), documents (pdf, txt, md, csv, vtt, json, xml), fonts (woff, woff2, ttf, otf)

The extension decides the content-type, so name files with the right one.

Big files never pass through Typillar. When you upload, the control plane hands your browser a short-lived token and the URL of a small upload worker running in your own Cloudflare account — and the bytes go straight from your browser into your R2 bucket. Files under 90 MB upload in one request; larger ones are split into parts and reassembled by R2, which is how a single asset can reach 5 GB. Typillar only records the finished file’s name, size and type.

Assets live in a Cloudflare R2 bucket in your own account, created the moment you turn Assets on — the same moment every resource-owning capability provisions, not on first upload. R2 is a separate Cloudflare subscription — a free tier, but one you enable once on your account before the bucket can be created. If it isn’t on yet, the console hands you a link straight to the dashboard switch; flip it and try again.

The agent sees exactly what you’ve uploaded through its list_assets tool, so it references real paths and never invents one that isn’t there. Upload a hero video and it drops in a <video>; hand it a logo and it places an <img> — always pointing at files it can confirm exist. (The favicon is the exception: that’s your project mark, which Typillar wires up, not the agent.)