Use when writing any applet; read this first.
An applet is a directory. main.ts is the entry. Relative imports work without configuration, and .html, .css, .svg, .md and .txt files import as strings.
main.ts exports named handlers, one per way the applet can be called. fetch is required; the other two are there when the applet wants them:
import { sql, kv, blob, email, ai, browser, secret, log, page } from "@std";
import type { InboundEmail, ScheduledEvent } from "@std";
export async function fetch(request: Request): Promise<Response> { ... } // every HTTP request
export async function scheduled(event: ScheduledEvent): Promise<void> { ... } // the applet's schedule
export async function inbox(message: InboundEmail): Promise<void> { ... } // mail to <applet>@<domain>scheduled and inbox return nothing: a run that returns has succeeded, a run that throws is recorded as failed with the error. Everything an applet takes from the platform comes from the one @std import above, and each section below covers some of it.
| Shape | Exports | Files |
|---|---|---|
| JSON API or webhook | fetch |
main.ts |
| scheduled job | fetch, scheduled |
main.ts |
| mailbox | fetch, inbox |
main.ts |
| web app | fetch |
main.ts, client/main.tsx, optionally shared/ |
An applet named x answers at https://x<suffix> and receives mail at x@<domain>; help() gives the suffix. Derive both from the request rather than writing them into the source:
const url = new URL(request.url); // https://x.example.com/path
const [name, ...domain] = url.hostname.split(".");
const address = `${name}@${domain.join(".")}`; // x@example.com
const sibling = `${url.protocol}//other.${domain.join(".")}`;Whether anything calls scheduled or inbox is a setting, not source: the schedule and the email switch are changed with applet_configure or on the editor's Settings page, take effect without a deploy, and survive a redeploy. Code and settings meet only at run time: a schedule on an applet with no scheduled export, or mail to one with no inbox, is a failed run in requests_list saying so.
templates_list, thenapplet_createwith a template and any files to lay over it.applet_deployto a new name also creates an applet.files_readfor the source,files_editfor a few lines,applet_deployfor the complete file set. Each is one atomic version;applet_configurerolls back.applet_fetchfor an HTTP request as yourself,applet_runto fireschedulednow.requests_listfor every run with its status and error,logs_listfor the lines it wrote.
A change is not done until step 4 shows it working. A build that breaks a rule below refuses the deploy with a message naming the file, and the version that was live stays live.
The build refuses:
main.tsimporting fromclient/, orclient/importing@std- a third-party import without the
npm:prefix, or anyhttp:orhttps:import - two different version ranges for one package
- a dynamic
import()of annpm:specifier
The runtime holds to:
sqlandkvare synchronous: noawait, and a handler that only touches them is atomicblob,email,aiand outboundfetch()are asynchronous, and anyawaiton them lets another request in- a run has 10 seconds of CPU and 100 subrequests, and cannot hold a WebSocket
- a
fetch()to another applet reaches only apublicone; egressnoneblocks everyfetch()and nothing else secret("NAME")throws when unset;blob.getandkv.getreturnundefinedfor a missing key
Use when starting an applet from nothing.
A template is a complete applet to start from. templates_list describes them, applet_create takes one by name and lays any files given over its own. A template is optional: applet_deploy creates an applet from a complete file set when its name is new. A new applet's description is the docstring at the top of its main.ts.
| Template | Shape | Shows |
|---|---|---|
basic-html |
web page | index.html and style.css imported as text and served by path, Pico for styling, a client/main.ts script at /main.js |
preact-app |
web app | a Hono router on the server with export const fetch = app.fetch, a Preact page with Tailwind's classes generated by UnoCSS at run time |
sql-form |
web app with storage | the same, with a form posting to an /api route that writes and reads sql |
chat |
web app with AI | ai.stream piped to the browser as a streamed response, and a shared/ module |
mailbox |
web app with mail | an inbox export, email.send, and a Preact client over /api routes |
For a JSON API, a webhook or a scheduled job, start from basic-html or deploy a single main.ts: the web templates only add a client.
Use when the applet serves a page with its own browser code.
main.ts is the server entry and client/main.tsx or client/main.ts is the browser entry. The build bundles the client with Preact's automatic JSX, embeds it in the server module and serves it at /main.js, which is what page({ title }) loads. There is no build step to configure and no static file directory: a file the page needs is imported as text and returned from a route.
// main.ts
import { page } from "@std";
import { Hono } from "npm:hono@4";
const app = new Hono();
app.get("/api/greeting", (c) => c.json({ greeting: "hello" }));
app.get("*", () => page({ title: "Greeter" })); // the HTML shell: <div id="app"> and /main.js
export const fetch = app.fetch; // Hono: export app.fetch, never app// client/main.tsx
import { render } from "npm:preact";
import { useEffect, useState } from "npm:preact/hooks";
function App() {
const [greeting, setGreeting] = useState("…");
useEffect(() => {
fetch("/api/greeting").then((r) => r.json()).then((b: { greeting: string }) => setGreeting(b.greeting));
}, []);
return <h1>{greeting}</h1>;
}
render(<App />, document.getElementById("app")!);Without a framework, a plain page imports its own HTML and serves it, with client/main.ts as the script:
import html from "./index.html"; // holds <script type="module" src="/main.js">
import css from "./style.css";
export function fetch(request: Request): Response {
if (new URL(request.url).pathname === "/style.css")
return new Response(css, { headers: { "content-type": "text/css; charset=utf-8" } });
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
}Types shared by both sides go in shared/, which either may import. page({ title, script }) takes another script path when the shell should load something other than /main.js.
main.tsnever imports fromclient/;client/never imports@std;shared/is for both- the client talks to the server over routes the applet defines,
/api/...by convention, on the same origin, so no CORS is needed npm:packages work inclient/as on the server, bundled for the browser- a request to the applet is the whole page: there is no separate asset host, so
fetch("/api/x")in the browser is the same applet
Use when the applet keeps data between requests.
Every applet has its own SQLite database, reached through sql and kv, and its own blob store through blob. Nothing is declared: the first statement creates the table. All three survive new versions and rollbacks, and are removed with the applet.
sql and kv are synchronous. Rows come back as records:
import { sql, kv } from "@std";
type Note = { id: number; body: string; created_at: string };
const setup = `CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')))`;
export async function fetch(request: Request): Promise<Response> {
sql.execute(setup); // idempotent, run it on every request
if (request.method === "POST") {
const { body } = await request.json<{ body: string }>();
const { lastInsertRowid } = sql.execute({ sql: "INSERT INTO notes (body) VALUES (?)", args: [body] });
kv.set("last-note", lastInsertRowid);
return Response.json({ id: lastInsertRowid });
}
const { rows } = sql.execute<Note>("SELECT id, body, created_at FROM notes ORDER BY id DESC");
const last = kv.get<number>("last-note"); // undefined when unset
return Response.json({ notes: rows, last });
}sql.execute(statement)takes a string or{ sql, args }and returns{ rows, rowsAffected, lastInsertRowid }. A failure throwsSqlError, whose message includes the statementsql.batch([...statements])runs several in one transaction; the first failure rolls back all of themkv.get(key),kv.set(key, value),kv.delete(key)andkv.list({ prefix, limit }), which returns[key, value]pairs. Values are anything structured clone can carrykvfor settings, cursors and caches;sqlfor anything you will query
blob is asynchronous object storage for larger values, files and images:
import { blob } from "@std";
await blob.setJSON("settings", { theme: "dark" });
const settings = (await blob.getJSON<{ theme: string }>("settings")) ?? { theme: "light" };
await blob.set("images/logo.png", bytes, { contentType: "image/png" });
const file = await blob.get("images/logo.png"); // a Response, or undefined
if (file !== undefined) return file; // serve it as is, or file.text(), .json(), .arrayBuffer()
const { keys, truncated } = await blob.list({ prefix: "images/", limit: 100 });
await blob.delete("images/logo.png");set accepts text, bytes, a Blob or a stream and replaces an existing value. list returns keys in sorted order, 1000 by default. Deleting a missing key succeeds. Keep a blob to a few megabytes: it is held in memory on the way in and out.
From outside the applet, sql_read and sql_write run statements, kv_list, kv_get, kv_put and kv_remove handle entries with values as JSON text, and blobs_list, blobs_get, blobs_put and blobs_remove handle blobs. blobs_put takes text or base64, and blobs_get returns readable text and a five-minute download link for the original bytes. That is how a table gets created, a setting seeded, or an image the applet serves put in without code to upload it. The editor has a page per store.
- always pass values as
args; never interpolate them into SQL CREATE TABLE IF NOT EXISTSat the top of the handler, so setup is idempotent and a fresh applet works on its first request. Add columns withALTER TABLE ... ADD COLUMNin atry/catch- do database work in straight-line synchronous code. A handler that only touches
sqlandkvis atomic from entry to exit; anawaitonfetch(),blob,aioremaillets another request in, so re-read after it - a select after an insert sees the row, and the response is held until the writes it made are durable
getJSONandgetreturnundefinedfor a missing key; default it rather than checking existence first
Use when the applet runs on a timer.
An applet has one schedule, a five-field cron expression at minute resolution, set with applet_configure or on its Settings page. Expressions run in UTC: convert "9am Eastern" to UTC before writing it, and pick a time that is close enough year-round, since daylight saving is not handled. The settings refuse an expression they cannot parse.
Two jobs on different timers are one expression that covers both and a branch on the event. sendDigest, fetchItemsBetween and handle are the applet's own functions:
import { kv, log, type ScheduledEvent } from "@std";
// schedule: 0 * * * *
export async function scheduled(event: ScheduledEvent): Promise<void> {
const hour = event.scheduledTime.getUTCHours(); // event.cron is the expression that fired
if (hour === 9) await sendDigest();
await poll();
}
async function poll(): Promise<void> {
const since = kv.get<string>("polled-at") ?? new Date(Date.now() - 24 * 3600 * 1000).toISOString();
const until = new Date().toISOString();
const items = await fetchItemsBetween(since, until);
for (const item of items) handle(item);
kv.set("polled-at", until);
log.info("polled", { since, count: items.length });
}Keep the cursor in kv and query through that cutoff, so items arriving during the fetch wait for the next run.
applet_runfiresschedulednow with the same event; use it to test rather than waiting for the timer- a run that throws is a failed row in
requests_listwith the error; a run that returns is a success, sologanything worth seeing later - a schedule on an applet with no
scheduledexport is a failed run saying so - the schedule survives redeploys and rollbacks; only
applet_configurechanges it
Use when the applet sends mail, or runs when mail arrives.
Mail arrives once the applet's email switch is on, set with applet_configure. The applet's address is <applet>@<domain>, and email.send always sends from it. In this example, store is the applet's function for saving an attachment.
import { email, log, sql, type InboundEmail } from "@std";
// InboundEmail: { id, from, to: string[], cc: string[], subject, text: string | null, html: string | null,
// headers: Record<string, string>, attachments: { id, filename, contentType, size }[] }
export async function inbox(message: InboundEmail): Promise<void> {
log.info("received", { from: message.from, subject: message.subject });
for (const attachment of message.attachments) {
const file = await email.attachment(message.id, attachment.id); // a Response with the bytes
await store(attachment.filename, await file.arrayBuffer());
}
const id = await email.send({
to: message.from,
subject: `Re: ${message.subject}`,
text: "Got it, thanks.",
});
log.info("replied", { id });
}email.send({ to, subject, text, html, replyTo }) returns the message id and throws when the mailer refuses it. Attachments of a received message are metadata only; the bytes come on demand through email.attachment(message.id, attachment.id) and stay private to the applet.
- anyone on the internet who knows the address can run
inbox, so treatmessage.fromand the body as untrusted, like a webhook body - delivery is at most once: a failed handler is not retried, and the sender gets no bounce
- outbound mail to anyone but a verified destination address counts against a quota of 3,000 a month; inbound is free
- mail bodies are not kept by the platform:
emails_listis metadata only, so an applet that wants a message later stores it insql - test
inboxby sending a real message to the address, thenrequests_listandlogs_list
Use when the applet calls a language model.
ai is chat completions in the OpenAI format through OpenRouter. The request and the response are OpenAI's shapes unchanged, so their documentation applies and any other option, temperature or tools, passes through. model is an OpenRouter model id; without one the platform's default is used, which help() names.
Here messages is the applet's conversation and process handles each streamed text chunk.
import { ai, page, type ChatMessage } from "@std";
const completion = await ai.chat({ messages: [{ role: "user", content: "hello" }] });
const text = completion.choices[0]?.message.content;
for await (const chunk of ai.stream({ messages })) process(chunk.choices[0]?.delta.content ?? "");Streaming to the browser is the stream re-encoded as a text body:
async function* reply(messages: ChatMessage[]): AsyncGenerator<Uint8Array> {
const encoder = new TextEncoder();
for await (const chunk of ai.stream({ messages })) yield encoder.encode(chunk.choices[0]?.delta.content ?? "");
}
export async function fetch(request: Request): Promise<Response> {
if (new URL(request.url).pathname !== "/api/chat") return page({ title: "chat" });
const { messages } = await request.json<{ messages: ChatMessage[] }>();
return new Response(ReadableStream.from(reply(messages)), { headers: { "content-type": "text/plain; charset=utf-8" } });
}- the key never reaches the applet, and no secret is needed: the platform holds one key for every applet
- both calls throw when OpenRouter refuses the request, and
ai.streamthrows if OpenRouter fails after the stream has started aiworks with egressnone; it is not afetch()- every call is one line in
logs_list,ai chatwith the model, duration, finish reason and usage, orai chat failedwith the reason, under the run that made it - a public applet lets anyone spend the shared key, and nothing counts calls per applet; put a guard on a public route that calls
ai
Use when the applet needs a page as a browser shows it: one built by JavaScript, or a screenshot.
browser loads a URL in a headless browser and hands back what a script run inside the page returns. evaluate takes a JavaScript expression as a string and returns its value, which must be JSON-shaped; html and text are that with a fixed expression. screenshot returns a PNG. Every call takes waitUntil: the default domcontentloaded reads the page as served, and networkidle2 waits for the page's own requests to settle, which a page built by JavaScript after load needs. Each of these opens and closes its own browser session, which takes a few seconds, so an applet that shows the same page often keeps the result in kv and refreshes it on a schedule. A plain fetch is faster when the page does not need a browser.
A flow that navigates, a search then its first result, holds one session with open. The session has the same calls without the URL, plus goto. Close it in a finally: an open session bills until it has idled for two minutes.
import { browser, kv } from "@std";
const titles = await browser.evaluate<string[]>(
"https://news.ycombinator.com",
"[...document.querySelectorAll('.titleline > a')].map((a) => a.textContent)",
);
const text = await browser.text("https://example.com", { waitUntil: "networkidle2" });
const png = await browser.screenshot("https://example.com", { fullPage: true });
await kv.put("front-page", titles);
const page = await browser.open("https://duckduckgo.com/html/?q=applets");
try {
const first = await page.evaluate<string>("document.querySelector('.result__a').href");
await page.goto(first);
await kv.put("first-result", await page.text());
} finally {
await page.close();
}- the browser identifies itself as an automated one and honours
robots.txt; a site that refuses bots refuses it too - the platform's Browser Run plan includes a few hours of browser time a month, and a call is billed for the seconds it holds a browser open
- a failed call throws with the browser's error and is logged as
browser evaluate failedorbrowser screenshot failed
Use when the applet needs a key, or to find out what a run did.
import { log, secret } from "@std";
export async function fetch(request: Request): Promise<Response> {
const token = secret("WEBHOOK_TOKEN"); // throws when not set
if (request.headers.get("authorization") !== `Bearer ${token}`) return new Response(null, { status: 401 });
const body = await request.json<{ event: string }>();
log.info("webhook", { event: body.event }); // levels: debug, info, warn, error
return Response.json({ ok: true });
}An applet's secrets are its owner's: a provider key, a webhook token. secrets_set and secrets_remove manage them, secrets_list shows names only, and a value is never returned once set. Setting one restarts the applet's worker with the new set, storage kept.
log.<level>(message, data) writes one structured line, visible in logs_list and on the Logs page within a second, under the run that wrote it. requests_list is every run, HTTP, scheduled, manual and inbox, with status, duration and the error of a throw; the visitor of a throwing handler gets a plain 500 carrying the run's id, never the error text. emails_list is the metadata of mail in and out. All three are kept for 7 days.
- a log line can carry a secret; only the owner reads logs, but do not log a secret's value
- a handler that throws is recorded with its error; a handler that catches and returns 200 is not, so
log.errorwhat it caught
Use when the applet imports a third-party package.
The import specifier is the dependency declaration; there is no package.json and no lockfile:
import { Hono } from "npm:hono@4"; // server
import { z } from "npm:zod@3.23.8"; // exact version pins it
import { render } from "npm:preact"; // client/ imports the same wayPackages are installed flat, with no peer dependencies and no .wasm or .node files. Import a peer with its own npm: specifier. A loose range like npm:zod@3 resolves once and stays pinned while the applet's set of dependencies is unchanged; adding or removing any dependency resolves every loose range again. applet_get shows a version's installed packages. To resolve the current set again, pass fresh: true to applet_deploy or use 're-resolve' on the Settings page.
- every third-party import carries the
npm:prefix;http:andhttps:imports are refused - one version range per package across the applet; two different ranges are refused
- imports are static:
importandexport ... fromonly. A dynamicimport()of annpm:specifier fails as unresolved - write an exact version to pin a package
Use before relying on the network, CPU time or another applet.
- a request gets 10 seconds of CPU and 100 subrequests, so a loop or a fan-out ends early
- there is no filesystem; state goes in
sql,kvorblob - an applet cannot hold a WebSocket; use polling or a streamed response
- a
fetch()to another applet's hostname carries no session and no key, so only apublicapplet answers it. Build the target host from your own request URL. A loop of calls between applets fails with 508 - egress
noneblocks everyfetch(), another applet's included, and nothing else:email,blob,logandaistill work, andemail.sendreaches an outside recipient - a source file is at most 1,900,000 bytes, and a blob should stay under a few megabytes
sqlandkvare unavailable outside a handler: module-level code cannot touch storage