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>.
Assets vs. Files
Section titled “Assets vs. Files”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.
Serving
Section titled “Serving”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.
Who can open it
Section titled “Who can open it”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.
The project mark
Section titled “The project mark”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.
Why your .svg serves inline here
Section titled “Why your .svg serves inline here”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.
Limits
Section titled “Limits”| 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.
How uploads work
Section titled “How uploads work”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.)