| name | build-artifact-app |
| description | Use when building a real interactive web app to host on Artifact Studio (apps go live at <slug>.jasonv.app), not just publishing a file you already made. Covers designing it, building it as a multi-file React app from esm.sh with no build step, adding the optional KV backend, and deploying + redeploying with the artifact CLI. Use whenever the task is "build and ship an app/tool/page" and the output should be a live URL. |
Build an Artifact Studio app
Design, build, and ship a real app to a live URL at <slug>.jasonv.app. Apps are multi-file static sites served full-page on their own origin with network access, so they load dependencies straight from a CDN. There is no build step. For publishing a one-off file you already made (an SVG, a Markdown page), use the share-artifact skill instead.
Default to a multi-file React app from esm.sh. A single self-contained HTML file is the exception, for something trivial. Real interactivity or more than ~100 lines means React + esm.sh.
1. Design first
Don't jump to code. Spend a moment on what you're building and how it should feel.
- If the ask is open-ended, sketch the idea before coding (a brainstorming skill helps if you have one).
- Make it feel built, not templated. A clear visual direction plus the small details that read as care: concentric border radii,
tabular-nums on changing numbers, a subtle scale-on-press, staggered enter animations. (If you have frontend-design or make-interfaces-feel-better skills, use them.)
- Decide: does it need to store data across visits? If yes, you need the KV backend (step 3).
2. Build — no-build or a real build step
Two ways, same deploy. The host only serves static files, so pick whichever fits:
- No-build (htm + esm.sh) — a folder of
index.html + JS modules that import deps from a CDN at runtime. Zero tooling, instant. Great for quick/simple apps. Mind the htm gotchas below.
- Real build step (Vite + React + TypeScript) — author in
.tsx with real JSX, types, and HMR; vite build emits a dist/ you deploy. No htm gotchas. Reach for this once the app is non-trivial or you want types. Jump to "Real build step" below, or copy examples/guestbook.
No-build: multi-file React from esm.sh
A folder with index.html at the root, plus JS modules. Pin esm.sh versions. Each app is served at the root of its own origin (<slug>.jasonv.app), so relative (./app.js), root-absolute (/app.js), and CDN (https://…) paths all resolve — relative is safest.
index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>My App</title>
<link rel="stylesheet" href="./styles.css" />
<script type="importmap">
{ "imports": {
"react": "https://esm.sh/react@19.0.0",
"react-dom/client": "https://esm.sh/react-dom@19.0.0/client",
"htm": "https://esm.sh/htm@3.1.1"
} }
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="./app.js"></script>
</body>
</html>
app.js uses htm for markup, not JSX. There's no transform step, so JSX would not run.
import React, { useState } from "react";
import { createRoot } from "react-dom/client";
import htm from "htm";
const html = htm.bind(React.createElement);
function App() {
const [n, setN] = useState(0);
return html`<button onClick=${() => setN(n + 1)}>count ${n}</button>`;
}
createRoot(document.getElementById("root")).render(html`<${App} />`);
Gotchas that will bite you:
| Gotcha | Fix |
|---|
<slug> shows up literally in output | HTML entities in htm template literals render as text. Use a JS expression: ${"<slug>"}. |
Multi-line <pre>/code mashes onto one line | htm collapses whitespace (incl. newlines) between elements, so one <span> per line gets concatenated. Render the block as one string with real \n: html`<pre>${code}</pre>` where code = ["line1","line2"].join("\n"). (String children keep their newlines; only inter-element whitespace is dropped.) |
Styled <button>/<input> text is dark/invisible | Buttons & inputs do not inherit color — they default to the browser's (dark) UA color. Set color: var(--ink) (or your theme color) explicitly on every interactive element. |
| App crashes white-screen with React error #62 | An inline style="..." string. React wants style as an object, and htm passes the attribute through verbatim. Use style=${{ marginTop: "10px" }}, or just a CSS class. |
Bare <script> JSX | No build step. Use htm, or precompiled output. |
| Asset path confusion | Each app owns its origin (<slug>.jasonv.app), so ./foo.js, /foo.js, and CDN URLs all work. Prefer relative. |
import "react" fails | Pin it in the importmap (react@19.0.0), don't rely on bare specifiers resolving. |
No index.html at root | / 404s. The root document must be index.html. |
Real build step: Vite + React + TypeScript
When you want JSX/.tsx, types, and HMR, use a normal Vite app and deploy its build output — the htm gotchas above don't apply (real JSX handles style objects, whitespace, and entities correctly).
bun create vite@latest my-app -- --template react-ts
cd my-app && bun install
bun run build
artifact deploy ./dist --slug my-app --visibility public
- Set
base: "./" in vite.config.ts so the built index.html references relative ./assets/... (resolves on the app's own origin).
- Put
llms.txt and any other root files in public/ — Vite copies them to the dist root.
- The loop becomes: edit
src → bun run build → artifact deploy ./dist. Everything else — versions, --staging, rollback, the KV backend below — is identical; you're just deploying a folder.
- Working example with both KV modes:
examples/guestbook (real .tsx).
3. Optional KV backend
If the app stores data, provision a per-app key-value store, then read/write from the frontend (same origin):
bun run cli backend <slug>
await fetch("/api/kv/scores/top", {
method: "PUT", headers: { "X-Data-Key": KEY }, body: JSON.stringify(value),
});
Per-user data (ART-5). Send an X-End-User header and the rows become private to that visitor.
Mint a per-visitor secret once and keep it in localStorage — different visitors can't see each
other's data, and the same visitor gets theirs back on return:
const EU = localStorage.getItem("eu") ?? (() => { const v = crypto.randomUUID(); localStorage.setItem("eu", v); return v; })();
const h = { "X-Data-Key": KEY, "X-End-User": EU };
await fetch("/api/kv/notes/draft", { method: "PUT", headers: h, body: JSON.stringify(text) });
const { value } = await (await fetch("/api/kv/notes/draft", { headers: h })).json();
It's capability-based, not a login: the id is a secret per device (not cross-device, not authenticated).
Drop X-End-User for shared state (leaderboards, global counters); include it for "my stuff."
The data key only exists after the app does, so it's a three-step bootstrap: deploy once, run
backend <slug> to get the key, embed it, then deploy again. After that, normal redeploys.
4. Add an llms.txt (do this by default)
Every app should ship an llms.txt — a short markdown manifest served at <slug>.jasonv.app/llms.txt so another agent can understand and drive your app without reading the source. It's the agent-native README. Write one for every app unless it's truly throwaway.
For a multi-file app, just put llms.txt in the folder — it deploys with everything else and is served at /llms.txt automatically (a folder file always wins). For a single-file share, pass --llms ./llms.txt. (Under the hood it's also a first-class llmsTxt field on the API/MCP, ≤64 KB, and it honors the app's visibility — an unlisted app's llms.txt needs the ?k= token.)
Template:
# <App name>
> One-sentence description of what this app does.
Two or three sentences: what a visitor or agent can do here, and how it behaves.
## Routes
- `/` — the main view
- `/<other>` — (list any additional pages)
## Data API
- KV collection `<name>`: key = `<shape>`, value = `<shape>`. Per-visitor (X-End-User) or shared.
- (omit this whole section if the app has no backend)
## Notes for agents
- How to drive it programmatically: query params, expected inputs, anything non-obvious.
Keep it current: update llms.txt whenever routes or the data model change, and redeploy.
5. Deploy
Credentials: set ARTIFACT_API_KEY (mint one at studio.artifacts.jasonv.dev → Settings; the share-artifact skill has the details). The API base defaults to the hosted studio.
bun run cli deploy ./my-app --slug my-app --visibility public --title "My App"
Prints https://my-app.jasonv.app. Unlisted links carry a ?k= token.
Redeploy = a new immutable version. Re-run deploy with the same --slug: same URL, same token, but each deploy is a saved version. Files you drop from the folder are just absent in the new version (no prune). Leave --visibility off to keep the current setting.
artifact rollback <slug> [version] — undo a bad deploy (live points back at an earlier version).
artifact deploy <dir> --slug x --staging → preview at x--staging.jasonv.app without touching live, then artifact promote x.
artifact versions <slug> — list versions (live/staging marked).
The loop is build → deploy → tweak → deploy, with rollback as the safety net and --staging when you want to review before going live.
6. Verify before you hand it over
Load the live URL in a browser, confirm it renders and the console is clean, and screenshot it. Don't claim it works unseen. The most common live failure is a wrong esm.sh path or a relative-path 404, both visible in the console.
Eyeball these — they're the gotchas above, made visible (a clean console won't catch them):
- Console + network clean (no failed module/asset loads).
- Code/preformatted blocks render on separate lines, not mashed onto one (htm whitespace collapse).
- All text is readable — no dark-on-dark, especially button/tab/card labels (the no-
color button trap).
- No literal
< / > / & showing as text anywhere (the htm entity trap).
- Every interactive element (buttons, tabs, inputs) actually responds.
curl -s https://<slug>.jasonv.app/llms.txt returns your manifest (not "Not found") — the agent-readable description shipped.
Caches lie when verifying a fix. After a redeploy, a browser (and a headless/automation browser's persistent cache) can keep serving the old app.js/styles.css and make a correct fix look broken. Before concluding a fix didn't land, check the source of truth: curl -s https://<slug>.jasonv.app/app.js | grep <your-change>. If the server has it, it's just cache — hard-refresh (Cmd/Ctrl+Shift+R).