AI
AI lets your app think, read, look, listen, speak and draw. You call a job, not a model: the platform picks the model, sends it the right shape, and hands you back a plain answer. It’s for the app’s intelligence (a support reply, a summary, a generated thumbnail), distinct from the models that power the build agent itself (see your models).
The jobs
Section titled “The jobs”| Job | What it does | Runs |
|---|---|---|
write |
text in, text out — answer, summarise, rewrite, extract | 1 |
read |
send a document — pdf, word, spreadsheet, html — get back its text | 2 |
see |
send an image, get back what it says about it | 2 |
hear |
send audio, get back the transcript | 2 |
say |
send text, get back the spoken MP3 | 2 |
draw |
send a prompt, get back the image | 10 |
screen |
check user-submitted text before you publish it | 1 |
Calling a job
Section titled “Calling a job”Every job is an import. There is no endpoint to remember, no request to shape, and no response to unwrap:
import { write, writeJson, writeStream, read, see, hear, say, draw, screen, AiError } from "ai";
const answer = await write("Summarize this in one line: …");| Call | Answers |
|---|---|
write(prompt, {system, max_tokens}) |
the answer, as a string |
writeJson(prompt, schema, {system}) |
an object conforming to your JSON Schema |
writeStream(prompt, {system}) |
an async iterator of text deltas |
read(file) |
{text, words} — the document as markdown |
see(file, prompt, {max_tokens}) |
what the image shows, as a string |
hear(file, {lang}) |
{text, words} — the transcript |
say(text, {lang}) |
the spoken audio, as a Blob |
draw(prompt) |
the picture, as a Blob |
screen(text) |
{safe, categories} |
Each job answers the value itself. write() is the string, not { text }. writeJson()
is your object, not { json }. Exactly three answer more than one value: read() and
hear() give {text, words}, and screen() gives {safe, categories}.
write(), writeJson() and writeStream() take a prompt or a conversation — a string, or
the array of {role, content} turns a chat has built up:
await write(prompt, { system: "Answer in one sentence." });await write([ { role: "user", content: "hi" }, { role: "assistant", content: "hello" },]);A job that fails throws an AiError carrying .status and the server’s own message — 401
nobody is signed in, 400 the input is malformed or too long, 413 the file is too big, 429
slow down, 402 the month’s budget is gone, 502 inference itself failed. On a 402, .budget
is {used, monthly}; everywhere else it’s null.
try { const answer = await write(prompt);} catch (err) { if (err.status === 402) showUnavailable(); else showError(err.message);}The module talks to these routes for you. They’re the truth for one caller only —
server.mjs, which has no module graph and calls them through env.PLATFORM.fetch():
| Method & path | Body | Returns |
|---|---|---|
POST /api/_ai/write |
{"prompt":"..."} or {"messages":[{"role","content"}]} — optional "system", "stream":true, "json":{...schema} |
{"text":"..."} — or {"json":{...}} when you asked for "json" |
POST /api/_ai/read |
FormData: "file" = the document (pdf, docx, xlsx, pptx, html, csv, txt) |
{"text":"...","words":n} — the document as markdown |
POST /api/_ai/see |
FormData: "file" = the image, "prompt" = what to ask about it |
{"text":"..."} |
POST /api/_ai/hear |
FormData: "file" = the audio |
{"text":"...","words":n} |
POST /api/_ai/say |
{"text":"...","lang?":"en"} |
the MP3 itself (audio/mpeg) |
POST /api/_ai/draw |
{"prompt":"..."} |
the image itself (image/jpeg) |
POST /api/_ai/screen |
{"text":"..."} |
{"safe":true or false,"categories":["..."]} |
AI transforms, it never stores
Section titled “AI transforms, it never stores”That’s the whole rule. You hand a job a value and it hands one back, and nothing you send or receive is kept anywhere unless you keep it. There is no base64 in this API and no key to name — one call in, one answer out.
Sending a file — read, see and hear take the File or Blob a file input, a drop
zone or a recorder already handed you. Pass it straight in:
const { text, words } = await read(input.files[0]);const caption = await see(input.files[0], "What is in this picture?");const { text } = await hear(recorded);Uploads are capped at 20 MB. There’s no FormData to assemble and no content-type to set —
setting one by hand is what breaks a browser upload, which is exactly the trap the import
removes.
Getting a file — say and draw answer a Blob, and throw rather than handing back a
broken picture, so there’s nothing to check and nothing to decode:
const picture = await draw("a fox in the snow");img.src = URL.createObjectURL(picture); // shows it now — gone on reloadA blob: URL lives only in the open page. If the picture or the audio has to survive a
reload — an avatar, a post image, a saved clip — hand it to Files and
store that key in your record:
import { uploadFile } from "files";
await uploadFile("public/hero.jpg", picture);Deciding what’s worth keeping is your app’s job, not the platform’s. Nothing accumulates in your bucket that you didn’t put there.
Ask your documents
Section titled “Ask your documents”read turns a pdf, word doc, spreadsheet, presentation, html page or csv into markdown text.
That’s the front door to every ask-my-documents app, and the rest of the shape is capabilities
you already have — Files to keep the original,
Data to store and rank the text,
search to make that ranking understand meaning.
Do it in this order.
1. Keep the file, then read it once. Extraction costs a run and takes real time, so it belongs at upload, not on every question:
import { uploadFile } from "files";
await uploadFile(key, file);const { text, words } = await read(file);2. Store it in chunks, not whole. Split on paragraph breaks into pieces of a few hundred to a couple of thousand characters, and write each as its own record:
{ "collections": { "chunks": { "searchable": { "fields": ["text"] }, "fields": { "text": "string", "file": "string", "page": "number" } } }}A record’s searchable text is indexed up to 8,000 characters and the rest is dropped. So a whole document in one record is a document you can’t search past its first few pages — and it fails silently, returning plausible answers drawn from only the opening section. Chunking isn’t an optimisation here; it’s the difference between working and quietly not.
3. Answer from the top chunks. Rank with q, then send those — not the document — to
write:
import { collection } from "data";
const { items } = await collection("chunks").list({ q: question, limit: 8 });const context = items.map((i) => i.text).join("\n\n");
const answer = await write(`Context:\n${context}\n\nQuestion: ${question}`, { system: "Answer only from the context. Say so if it isn't there.",});Never paste a whole document into write: text in is capped at 16,000 characters, so a long
one is refused outright and a medium one crowds out the question. Retrieve, then ask.
Turning search on makes that same q rank by meaning as well as
by words — which is what lets a question worded nothing like the document still find the right
passage. The code above doesn’t change.
One thing to handle: a scanned pdf with no text layer comes back nearly empty. Check
words before you store anything, and tell the person their file is an image rather than
indexing nothing and looking broken later.
Streaming and strict JSON
Section titled “Streaming and strict JSON”writeStream yields the answer as it arrives. There’s no event stream to parse, no partial
line to buffer and no end-of-stream frame to watch for:
let answer = $state("");for await (const delta of writeStream(prompt)) answer += delta;Reach for it whenever a person is waiting on a long answer; use write when you only need the
finished one.
writeJson makes the model conform to a JSON Schema and hands back the object — no parsing
prose, no stripping code fences:
const { total, date } = await writeJson("Pull the total and the date out of this receipt: …", { type: "object", properties: { total: { type: "number" }, date: { type: "string" } },});Answers are capped at 512 tokens by default; raise it with max_tokens, up to 4096.
Screen anything a stranger typed
Section titled “Screen anything a stranger typed”screen checks user-submitted text before you publish it or feed it to write — a public
collection, a comment, a review, a display name. One call, and it costs a single run:
const { safe } = await screen(comment);if (!safe) return; // don't store it, don't show itThe budget
Section titled “The budget”How it’s built
Section titled “How it’s built”AI runs on Workers AI in your account — no resource to provision, just the binding. It turns on Data with it, which is where the run ledger lives. Because AI never stores anything it needs no bucket of its own; if your app wants to keep what a job returned, that’s Files, turned on the same way as always. The agent enables what your feature needs when you ask for it.