diff --git a/src/docs/src/examples.js b/src/docs/src/examples.js
index 112990cfe..a0afa3879 100644
--- a/src/docs/src/examples.js
+++ b/src/docs/src/examples.js
@@ -986,6 +986,18 @@ const examples = [
slug: 'workers-exec',
source: '/playground/examples/workers-exec.html',
},
+ {
+ title: 'Build an API',
+ description: 'Deploy a worker whose routes store notes in the calling user\'s own key-value store with user.puter, and call it with puter.workers.exec(). Run and modify this example directly in your browser.',
+ slug: 'workers-build-an-api',
+ source: '/playground/examples/workers-build-an-api.html',
+ },
+ {
+ title: 'Update a worker in place',
+ description: 'Ship new code to a deployed worker at the same URL by overwriting its source file with Puter.js workers API. Run and modify this example directly in your browser.',
+ slug: 'workers-deploy-from-code-update',
+ source: '/playground/examples/workers-deploy-from-code-update.html',
+ },
],
},
];
diff --git a/src/docs/src/playground/examples/workers-build-an-api.html b/src/docs/src/playground/examples/workers-build-an-api.html
new file mode 100644
index 000000000..e43c2bcf1
--- /dev/null
+++ b/src/docs/src/playground/examples/workers-build-an-api.html
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
diff --git a/src/docs/src/recipes/workers-build-an-api.md b/src/docs/src/recipes/workers-build-an-api.md
new file mode 100644
index 000000000..ad4fe1fe5
--- /dev/null
+++ b/src/docs/src/recipes/workers-build-an-api.md
@@ -0,0 +1,217 @@
+---
+title: Build an API with a Worker
+description: "Learn how to write the routes of a serverless worker with Puter.js, reading requests, storing data in the calling user's own account and sending back JSON or errors."
+tags: [workers, kv, auth]
+order: 38
+---
+
+A [serverless worker](/Workers/) is JavaScript that runs in the cloud instead of
+in the user's browser. It answers HTTP requests at its own URL, such as
+`https://notes-api.puter.work`, and you write it as a list of routes, each one a
+path and the function that handles it.
+
+When your app calls a worker with
+[`puter.workers.exec()`](/Workers/exec/), the worker knows which user is calling
+and gets that user's Puter account as `user.puter`. A route can then read and
+write the user's own [key-value store](/KV/), files and AI, the same way your
+app does in the browser. The data stays in the user's account, and the usage is
+billed to them under the [User-Pays model](/user-pays-model/), so you get a
+backend without paying for its storage.
+
+## Define a Route
+
+The `router` object is available in every worker. Register a route with the
+method's name and a path, and return what the caller should get back:
+
+```js
+router.get('/me', async ({ user }) => {
+ if (!user) {
+ return new Response('sign in required', { status: 401 });
+ }
+
+ const { username } = await user.puter.getUser();
+ return { username };
+});
+```
+
+There is one method per HTTP verb: `router.get()`, `router.post()`,
+`router.put()`, `router.delete()` and `router.options()`.
+
+`user` is only there when the request came through
+[`puter.workers.exec()`](/Workers/exec/), so every route that uses it starts by
+checking for it.
+
+## Store Data in the User's Account
+
+To save something for the caller, write it with `user.puter.kv`. Read the JSON
+body with `request.json()`:
+
+```js
+router.post('/notes', async ({ request, user }) => {
+ if (!user) {
+ return new Response('sign in required', { status: 401 });
+ }
+
+ const { text } = await request.json();
+ if (typeof text !== 'string' || text.length === 0) {
+ return new Response('text is required', { status: 400 });
+ }
+
+ const note = { id: crypto.randomUUID(), text, at: Date.now() };
+ await user.puter.kv.set(`notes:${note.id}`, note);
+ return note;
+});
+```
+
+Each user's notes land in their own store, so one user can never read or
+overwrite another's, whatever the request body says. These are the same keys
+your app sees through [`puter.kv`](/KV/) in the browser, so the app can also
+read a note directly.
+
+## Read the Request
+
+Each handler receives one object. Its `request` is a standard
+[`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request), and its
+`params` holds the parts of the path you marked with a colon:
+
+```js
+router.get('/notes/:id', async ({ params, user }) => {
+ if (!user) {
+ return new Response('sign in required', { status: 401 });
+ }
+
+ const note = await user.puter.kv.get(`notes:${params.id}`);
+ if (!note) {
+ return new Response('note not found', { status: 404 });
+ }
+
+ return note;
+});
+```
+
+Captured values are always strings, so convert them yourself when you need a
+number.
+
+Query strings are not part of the path. To read one, parse the request URL:
+
+```js
+router.get('/notes', async ({ request, user }) => {
+ if (!user) {
+ return new Response('sign in required', { status: 401 });
+ }
+
+ const q = new URL(request.url).searchParams.get('q') ?? '';
+ const rows = await user.puter.kv.list('notes:', true);
+
+ return rows
+ .map(row => row.value)
+ .filter(note => note.text.includes(q));
+});
+```
+
+## Send a Response
+
+A handler that returns a plain object or array is sent as JSON, and one that
+returns a string is sent as text. To choose the status code or the headers,
+return a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response),
+as the `401`, `400` and `404` answers above do.
+
+The worker adds CORS headers to every response, so your app can call it from
+any origin without extra setup.
+
+## Handle Unknown Paths
+
+A wildcard route matches the rest of the path. Register one last, so it only
+runs when no other route matched, and use it to answer with a `404`:
+
+```js
+router.get('/*path', async ({ params }) => {
+ return new Response(`no route for /${params.path}`, { status: 404 });
+});
+```
+
+The wildcard needs a name, such as `*path`. A bare `*` is read as a literal
+character and does not match anything else.
+
+## Routes Without a User
+
+A worker is also an ordinary HTTP endpoint, so it can serve callers that have
+never heard of Puter: a webhook from another service, a `curl` in a script, or a
+page that doesn't load Puter.js. These routes never touch `user`.
+
+A route can do its work and answer, with nothing to store:
+
+```js
+router.get('/slugify', async ({ request }) => {
+ const text = new URL(request.url).searchParams.get('text') ?? '';
+
+ return text
+ .toLowerCase()
+ .trim()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '');
+});
+```
+
+Any HTTP client can call it:
+
+```sh
+curl "https://notes-api.puter.work/slugify?text=Hello%20World"
+# hello-world
+```
+
+To keep what an outside service sends you, store it in your own account with
+`me.puter`, which is available to every request whoever made it:
+
+```js
+router.post('/webhooks/payments', async ({ request }) => {
+ const event = await request.json();
+
+ await me.puter.kv.set(`payments:${event.id}`, event);
+ return { received: true };
+});
+```
+
+A worker can also call other APIs with `fetch()`, which lets it reshape a
+third-party response before your app sees it:
+
+```js
+router.get('/weather/:city', async ({ params }) => {
+ const res = await fetch(`https://wttr.in/${encodeURIComponent(params.city)}?format=j1`);
+ const data = await res.json();
+
+ return { city: params.city, tempC: data.current_condition[0].temp_C };
+});
+```
+
+Anyone who knows the URL can call a route like these, so don't put anything
+behind one that should be private.
+
+## Call It From Your App
+
+Once the worker is [deployed](/Workers/#deployment), call it with
+[`puter.workers.exec()`](/Workers/exec/). It takes the same arguments as
+`fetch()` and adds the signed-in user's session, which is what gives the worker
+`user.puter`:
+
+```js
+const API = 'https://notes-api.puter.work';
+
+const res = await puter.workers.exec(`${API}/notes`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text: 'Buy milk' }),
+});
+const note = await res.json(); // { id: '5f0c…', text: 'Buy milk', at: 1788827048741 }
+
+const notes = await (await puter.workers.exec(`${API}/notes?q=milk`)).json();
+```
+
+A plain `fetch()` of the same URL carries no session, so `user` is undefined
+and the notes routes answer `401`. It works for
+[routes without a user](#routes-without-a-user), which is how
+callers without Puter.js reach them.
+
+To keep data that every user reads and writes together, such as a leaderboard,
+store it with `me.puter` instead. See
+[Store server-side data](/recipes/store-server-side-data/).
diff --git a/src/docs/src/recipes/workers-deploy-from-code.md b/src/docs/src/recipes/workers-deploy-from-code.md
new file mode 100644
index 000000000..f99ff11af
--- /dev/null
+++ b/src/docs/src/recipes/workers-deploy-from-code.md
@@ -0,0 +1,129 @@
+---
+title: Deploy a Worker from Your App
+description: "Learn how to create, update, list and delete serverless workers from your own code with Puter.js, instead of publishing them by hand."
+tags: [workers]
+order: 42
+---
+
+You can publish a [serverless worker](/Workers/) by hand from puter.com. You
+can also do it from code, with the [Workers API](/Workers/#workers-api). Your
+app then creates a worker, updates its code and removes it, the same way it
+writes files.
+
+This lets you build tools that ship backends for their users, such as an app
+builder that gives each project its own API.
+
+## Create a Worker
+
+A worker is deployed from a JavaScript file in your Puter account. Write the
+code with [`puter.fs.write()`](/FS/write/), then pass the file to
+[`puter.workers.create()`](/Workers/create/) along with a name:
+
+```js
+await puter.fs.write('notes-api.js', `
+ router.get('/me', async ({ user }) => {
+ const { username } = await user.puter.getUser();
+ return { username };
+ });
+`);
+
+const deployment = await puter.workers.create('notes-api', 'notes-api.js');
+
+deployment.url; // 'https://notes-api.puter.work'
+```
+
+Give it 5 to 30 seconds before the first request, while the worker spreads to
+every edge server. Then call it with
+[`puter.workers.exec()`](/Workers/exec/), which sends the signed-in user along
+so the route can use `user.puter`:
+
+```js
+const res = await puter.workers.exec(`${deployment.url}/me`);
+await res.json(); // { username: 'grace' }
+```
+
+[Build an API with a worker](/recipes/workers-build-an-api/) covers what to put
+in the routes.
+
+Worker names are global, like subdomains, and are stored in lowercase. A name
+can use letters, numbers, hyphens and underscores. When another account already
+has the name, `create()` rejects, so pick another one or use
+[`puter.randName()`](/Utils/randName/). The account deploying the worker needs a
+verified email address.
+
+## Update Its Code
+
+A worker keeps its name and URL for as long as it exists. To change what it
+runs, overwrite its source file. [`puter.workers.get()`](/Workers/get/) tells
+you where that file is:
+
+```js
+const info = await puter.workers.get('notes-api');
+
+await puter.fs.write(info.file_path, updatedCode);
+```
+
+Writing the file redeploys the worker at the same URL, so anything already
+calling it keeps working.
+
+Don't create a new worker with a new name to ship a change. The old one stays
+online at its old URL, and every caller has to be pointed at the new one.
+
+## Find Your Workers
+
+To show the workers in your account, call
+[`puter.workers.list()`](/Workers/list/):
+
+```js
+const workers = await puter.workers.list();
+
+for (const worker of workers) {
+ console.log(worker.name, worker.url);
+}
+```
+
+To look up one worker by name, use [`puter.workers.get()`](/Workers/get/). It
+resolves to `undefined` when there is no worker with that name, which makes it
+a quick existence check before a deploy:
+
+```js
+const existing = await puter.workers.get('notes-api');
+
+if (!existing) {
+ await puter.workers.create('notes-api', 'notes-api.js');
+}
+```
+
+## Keep Workers Apart
+
+Inside a worker, [`me.puter`](/Workers/router/#integration-with-puter-js)
+reaches the key-value store and files of the app the worker runs as. When your
+app creates workers, they run as your app by default, so they all share that
+data with each other and with your app.
+
+To give each worker its own data, pass `sandbox: true`:
+
+```js
+await puter.workers.create('project-alpha-api', 'alpha.js', { sandbox: true });
+await puter.workers.create('project-beta-api', 'beta.js', { sandbox: true });
+```
+
+Each worker now runs as its own app, so a key one of them writes is not visible
+to the other. Decide this before the worker stores anything, because changing
+it later does not move data that was already written.
+
+## Delete a Worker
+
+To take a worker offline, call [`puter.workers.delete()`](/Workers/delete/)
+with its name:
+
+```js
+await puter.workers.delete('notes-api');
+```
+
+Its URL stops answering, and the name is free to use again. The source file
+stays in your account.
+
+The link also works the other way. Deleting a worker's source file with
+[`puter.fs.delete()`](/FS/delete/) deletes the worker too, so keep the file for
+as long as the worker should stay online.