--- title: Turn Zero Cloud description: Start using Turn Zero Cloud from your AI coding tool: connect it, deploy a first application, and find the guide for every other task. slug: / order: 1 visibility: public redirects_from: [/concepts/what-turn-zero-cloud-is] --- # Turn Zero Cloud Turn Zero Cloud is the complete production backend that hosts and runs applications built with AI coding agents such as Claude Code and Codex, with everything included on one predictable plan. It connects to your AI coding tool through a Model Context Protocol (MCP) server. Once connected, your AI can code, build, and test backends, connect them to your app, and host your code in the cloud. Your AI provisions backend services, stores secrets by name, deploys your application, and reads its logs and stats directly. Turn Zero lets you keep coding with AI the way you do now. You can deploy straight to production, or turn on a development environment and promote from it, all from your chat session. ## Where to start 1. [Connect Claude Code or Codex](/getting-started/connect-your-tool) to the platform and sign in. 2. Use your AI coding tool to create and deploy apps. For example, type: "Create and deploy to turnzero a simple AI chat web app that lets me ask questions, with account sign in." ## Features ### Built from the ground up for AI coding tools | Feature | | | --- | :---: | | MCP server for Claude Code and Codex | ✓ | | Tools and APIs provide hints to your AI | ✓ | | Documentation your AI reads directly, written for AIs | ✓ | | Context for each action: what it needs before your AI calls it | ✓ | | Skills: ready procedures your AI can run | ✓ | | An integration guide written for your AI in every feature package | ✓ | | Test doubles, so your AI's tests run offline | ✓ | | A library of feature packages that your AI adds to your project and uses | ✓ | | Scaffolding for your AI | ✓ | | A management plane written for your AI: ask "Why did it fail?" and it reads the logs, counters, and deploy steps | ✓ | | Permanent deletes require your approval | ✓ | | Send feedback to Turn Zero from your chat session | ✓ | ### Deploy and run | Feature | | | --- | :---: | | Node.js hosting for your built app, at its own address under ai.host | ✓ | | Your own domain for a Pro application's production environment, with two DNS records | ✓ | | A production environment, and a development environment when you want one: deploy, promote, roll back, restart, version history, pause and resume | ✓ | | A health check before each new version takes over; a failed deploy leaves the running version serving | ✓ | | Unattended deploys from a continuous integration (CI) job, on a limited token | ✓ | | Local runs on your own machine, before you deploy | ✓ | | Scheduled jobs, with each run's outcome recorded | ✓ | | Egress firewall: your app reaches only the hosts you declare, watched first, then enforced | ✓ | | Request limits: a time limit on every request | ✓ | | Per-address and per-user rate limits on every request | ✓ | | [WebSockets](/guides/use-websockets) for apps that declare them, open beyond the request deadline within their limits, and streamed responses up to it | ✓ | | Runtime isolation: each app runs in its own container, with its own credentials, secrets, database, and files | ✓ | | One API for your AI coding tool, your scripts with limited, revocable tokens, and the turnzero.ai dashboard | ✓ | | A Free, Standard, or Pro plan for each application, and its usage for the month, read from your tool or the dashboard | ✓ | | Export of each application's database and files, and account deletion with your approval | ✓ | ### Backend feature packages | Feature | | | --- | :---: | | Accounts for your app's users: sign-in with Google, GitHub, a passkey, an emailed code, or a work account (Microsoft SSO), with sessions, invitations, and open or invitation-only sign-up | ✓ | | End-user management from your AI session: invite, list, suspend, and delete your app's users | ✓ | | Database: PostgreSQL for each environment, with migrations | ✓ | | File storage: named areas | ✓ | | Short-lived upload and download links, so your app moves files straight to and from storage | ✓ | | Secrets stored by name without passing through your chat, write-only; an outside API's key is replaced without a redeploy | ✓ | | External API calls on your stored keys, streaming included | ✓ | | [AI allowance](/concepts/glossary#ai-allowance) included; also bring your own AI key | ✓ | | Logging, and hosting safeguards: request checks and time limits | ✓ | | Push notifications to iPhone and Android | ✓ | | Email from your app: confirmations, receipts, and invitations, without running a mail provider (Pro plan) | ✓ | | Issue tracking and feedback inside your app | ✓ | ### Web app backends | Feature | | | --- | :---: | | Your web client's files served beside your backend, at the same address: its page, scripts, styles, fonts, and images, with no backend action per file | ✓ | | Browser sign-in held in one session cookie on your app's own address: HttpOnly, Secure, and SameSite=Lax, carrying a signed session token the Account package verifies | ✓ | | Ready-made sign-in pages on your app's own address, under `/__account/`, so your AI builds no sign-in screens | ✓ | | A sign-in gate for invitation-only and work-account apps: a visitor without a session goes to the sign-in page, and an API call is refused 401 | ✓ | | Cookie isolation: your app's own cookies stay on its own address, the platform removing any `Domain` attribute | ✓ | ### Mobile and desktop app backends | Feature | | | --- | :---: | | Native sign-in: standard mobile OAuth, Sign in with Apple, and Google sign-in | ✓ | | Sessions that last months, and token authentication on every request | ✓ | | Universal and app links | ✓ | | A minimum supported app version | ✓ | | From inside your app, a user registers a passkey, ends a session, and deletes the account | ✓ | | Desktop apps for macOS and Windows sign in through the system browser, as [native clients](/concepts/glossary#native-client) (OAuth/PKCE) | ✓ | ## The served version These pages describe one version of Turn Zero Cloud. Its number is in the page manifest, on the [management contract's source page](/reference/sources/schemas/management-api), and in every action's result as `contract_version`. ## How it works Your AI coding tool writes the app; Turn Zero Cloud runs it, with hosting, database, sign-in and scaling included. Your tool writes, builds, and tests the application on your computer. The platform installs its runtime dependencies, builds the hosting image, runs it in production at the application's address, and records its logs, counters, and usage. A development environment is optional: once [turned on](/guides/turn-on-development-and-promote), each deploy runs there first and a promote moves a version to production. [Applications and environments](/concepts/applications-and-environments) explains the environments. Three kinds of caller use the same actions: your AI coding tool through the MCP server, a script or pipeline through the HTTP API with a [minted token](/guides/mint-a-token), and you through the signed-in website. Each page shows the actions it uses in backticks, such as `deploy` and `store_secret`, as they appear in your tool. Each application has one plan; the [pricing page](https://turnzero.ai/pricing) gives prices and included capacity, and [How the plans differ technically](/concepts/how-the-plans-differ-technically) compares where each plan runs. ## What the platform does not do The platform does not build your source, approve destructive actions for you, or make your application portable on its own. ## Machine surfaces of this documentation This documentation is published at `https://turnzero.ai/cloud/`. An AI coding tool can read it without a browser through four machine surfaces: - **The index**, at `https://turnzero.ai/cloud/llms.txt`, has one line per page with the page's title, address, and description. - **The whole text**, at `https://turnzero.ai/cloud/llms-full.txt`, contains every page except the generated reference, which is read page by page. - **The page manifest** is at `https://turnzero.ai/cloud/docs.json`. - **The Markdown copy** of a page is at the page's address followed by `index.md`. A fetch tool that summarizes what it reads, such as Claude Code's WebFetch, can leave pages out of its summary of the index, which lists every page. To find the page for a task, read [Find the guide for a task](/getting-started/find-a-guide), search the whole text, or, signed in, call `read_documentation` with a `query`. At the origin root, `https://turnzero.ai/llms.txt` opens with what Turn Zero Cloud is and the site's public pages, then indexes this [documentation tree](/concepts/glossary#documentation-tree), the pages under `/cloud/`. It also indexes the Turn Zero Docs tree, the pages under `/tzdocs/`. `https://turnzero.ai/llms-full.txt` contains the whole text of every documentation tree that every account can read. `https://turnzero.ai/sitemap.xml` lists every public page's address, the pages of this tree among them. Each hand-written page there carries the date its text last changed. The navigation beside each page lists pages alone, so these files do not appear in it, and their addresses do not change. Every account can read this tree. The Turn Zero Team (tzTeam) tree is open only to an account with tzTeam access. Turn Zero's production site is public, open to every visitor and to search engines. Its development site is private. Where a site is private, what a request receives depends on how it is sent: - A browser opening a page, a request that sends `Sec-Fetch-Mode: navigate` or `Sec-Fetch-Dest: document`, receives the invitation notice. The response is `401` with a `WWW-Authenticate` header that mentions the request header `Turnzero-Show-Page`. - The same browser request for a machine surface receives `404` with `not found`, never the notice. - Any other request, such as a tool's fetch, a script, or `curl`, receives a documentation page or a machine file, marked `X-Robots-Tag: noindex, nofollow`. The site's own pages, such as `/` and `/pricing`, answer it `401` unless it sends `Turnzero-Show-Page`; the terms and the privacy policy answer it. - A request that sends `Turnzero-Show-Page` with any non-empty value always receives the page or the file, and a Turn Zero Cloud [bearer token](/concepts/glossary#bearer-token) always receives a machine file. A tool signed in to Turn Zero Cloud reads the index as the resource `docs://cloud/llms.txt` and the whole text as `docs://cloud/llms-full.txt`. With the `read_documentation` action it also reads one page, one section, or the sections that match a few words, so it needs no download. [Reading this documentation as a tool](/concepts/reading-this-documentation-as-a-tool) explains reading a long page in parts, going straight to a section, and searching. ## Related The [glossary](/concepts/glossary) defines each term this documentation uses, once. [What happens without asking](/concepts/what-happens-without-asking) lists what your tool does on its own and which actions it asks you about first. The [refusals page](/reference/refusals) lists all of the platform's refusals, with the cause and remedy of each; a backend package's own failures are listed on its package page. tzTeam is the companion plugin for keeping requirements, decision records, and raised items in files you own. Its documentation is a separate tree on this site, at `/tzteam/`, open to an account with tzTeam access. --- title: What your computer needs description: Check what your machine needs before you connect to Turn Zero Cloud; local application builds also need Node.js 24 and npm. slug: /getting-started/prerequisites order: 1 visibility: public redirects_from: [/getting-started/what-your-computer-needs] --- # What your computer needs Connecting to Turn Zero Cloud needs Claude Code or Codex and a browser, where a person signs in the first time your tool connects. It installs no local server. Building your application on your machine also needs Node.js and npm, as the list below says. ## The list - **Claude Code or Codex.** Turn Zero Cloud is tested with these two AI coding tools, and [Connect Claude Code or Codex](/getting-started/connect-your-tool) gives the setup for each. - **Node.js 24 or later.** Your app builds and runs locally on the same Node.js 24 the platform runs it on, and the [turnzero command](/concepts/turnzero-command) needs it too. - **npm.** It ships with Node.js and installs your app's dependencies. - **The turnzero command.** Install it once from npm, as [Install the turnzero command](#install-the-turnzero-command) shows. Your AI coding tool runs it to deploy from your computer. Nothing else to install: no SDK, compiler, signing certificate, or console. Git isn't needed either, and a machine may not have it, so check for `git` before you run a Git command such as `git diff`. Deploying needs no Git repository, and no step on these pages runs the `git` program. Where the folder is inside a repository, the command reads its commit without Git, as [What it reads](/concepts/what-the-turnzero-command-reads-and-prints#what-it-reads) says. ## Check your Node.js version These pages' commands are written for bash or zsh on macOS and Linux. On Windows, read [Windows PowerShell](#windows-powershell) first. Check the Node.js and npm versions. ```bash node --version npm --version ``` The first prints `v24` or higher. If it does not, install Node.js 24 or later from nodejs.org or through a Node.js version manager, then run both again. On a machine where you cannot run an installer, for example one without an administrator password, use the archive instead. On nodejs.org, download the Node.js 24 archive for your system: a `.tar.gz` file for macOS or Linux, a `.zip` file for Windows. Check its SHA-256 against the `SHASUMS256.txt` file listed beside it, with `shasum -a 256 ` on macOS, `sha256sum ` on Linux, or `Get-FileHash ` on Windows. Unpack it into your home folder. It unpacks into a folder named for its version, such as `node-v24.11.0-darwin-arm64`. Add that folder's `bin` folder to your `PATH`, or on Windows the unpacked folder itself. Set it in your shell's profile file on macOS and Linux, or in your user's environment variables on Windows, so every new terminal finds it. Then open a new terminal and run both checks again. ## Install the turnzero command Install it once with `npm install -g turnzero`. Your AI coding tool runs this line itself where its tool allows, or you run it in a terminal of your own. The install keeps that version until you install again, and `turnzero --version` prints it. Version 0.14.0 is the first that runs as `turnzero` and has `turnzero login`. On macOS and Linux, a global install can be refused with `EACCES` where Node.js came from its installer or a system package, because npm's global folder then belongs to the system. Don't use `sudo`. Give npm a global folder of your own instead, as npm's documentation advises: 1. Run `npm config set prefix ~/.npm-global`. 2. Add `~/.npm-global/bin` to your `PATH` in your shell's profile file. 3. Open a new terminal and run the install again. A Node.js version manager avoids the refusal too, since its global folder is already yours. Where the command is not installed, a line still runs through `npx`, which comes with npm. Write `npx --no-update-notifier -y turnzero@latest` in place of `turnzero`. Such a line prints nothing while npm fetches the package, often for several seconds the first time; the command's first line names its version. ## Windows PowerShell On Windows, install PowerShell 7 and run your commands in it. It needs none of the changes below. Install it with winget, which current Windows 10 and Windows 11 include. ```text winget install --id Microsoft.PowerShell --source winget ``` Then open PowerShell 7 (`pwsh`) instead of Windows PowerShell. For your AI coding tool: Windows PowerShell 5.1, the PowerShell that Windows includes, needs five changes to the commands on these pages. - **npm and the turnzero command.** In Windows PowerShell, the default setting blocks scripts, and `npm` and the installed `turnzero` are scripts there. Run `npm.cmd` with the same arguments, and `turnzero.cmd` in place of `turnzero`. Each line of the turnzero command that a tool call returns has a Windows form, which already has `turnzero.cmd`. A line that no tool call returns, such as the hash line or the unattended deploy line, is written with `turnzero.cmd` by hand. Through npx, write `npx.cmd` in place of `npx`. - **A script of your own.** The same policy blocks a `.ps1` file your tool writes, and the policy is your machine's, since the platform ships no PowerShell script. Run it for one process with `powershell -NoProfile -ExecutionPolicy Bypass -File

Notes

    ``` The script calls the backend on the same address, so the browser sends the user's sign-in cookie with each request and no cross-origin setting is needed. This one reads `GET /notes`, the route of the service [Build a database-backed service](/getting-started/build-a-database-backed-service) writes. ```js // client/app.js fetch('/notes') .then((response) => response.json()) .then((notes) => { const list = document.getElementById('notes'); for (const note of notes) { const item = document.createElement('li'); item.textContent = note.title; list.append(item); } }); ``` A web client is optional. An application without one, such as the backend of a mobile app, ships no `client/` folder, and every request reaches its own routes. [Build a mobile app's backend](/getting-started/build-a-mobile-app-backend) walks through that shape. ### 2. Know what the platform serves and what reaches your routes The [serving router](/concepts/glossary#serving-router) responds to a `GET` for a client file itself, ahead of your routes. So a page, a script, a font, or an image costs no backend action and never wakes your application. A `GET` of `/` or `/index.html` returns `client/index.html`, and `/app.js` and `/styles.css` return the shell's script and stylesheet. These requests reach your application instead: - every `HEAD` and `POST`, and every other method, even on a client file's path; - a path that names no client file, such as `/notes` or a single-page application's own route `/notes/7`; - a path holding `%`, since the router matches the path exactly as the browser sends it and never decodes it; - a request the platform's file store, where the deploy keeps your client files, does not respond in time, as step 5 explains. Your routes keep every path that names no client file. Sending only `/api` and a few declared paths to your application would take away every page it renders on its own routes. Where a page should load at a path of its own, such as `/notes/7`, your server responds to that path with `client/index.html` itself. The router has no fallback page and returns no folder's index file. ### 3. Keep names and sizes within the limits The deploy reads each name below `client/` before it writes anything. A name is served only where each part between slashes holds ASCII letters, digits, `-`, `_`, `.`, and `~` alone. No part may open or end with a dot. A name may hold up to 32 parts and 512 characters, and stand outside the platform's own folders `__account` and `__router`, whatever the letter case. The deploy skips any other name, such as `.DS_Store`, `.env`, a name with a space, or a name with an accented letter. A skipped file is never served from the platform. A version's client files are bounded. A zip past any bound is refused 400 `artifact_layout_invalid`, naming the bound, before a version is added: | Bound | Value | | --- | --- | | Files served | 2,000 | | One file | 25 MiB | | All files together | 50 MiB | Long audio or video belongs in a [storage area](/guides/store-files), which serves it to a browser through a download grant. Where `client/` holds a file beyond the shell, or a name the deploy skipped, the deploy's response carries `client_notice`. It counts the files the router will serve, names up to twenty beyond the shell, and names up to twenty skipped files, then counts the rest. It refuses nothing. Your tool reads it after each deploy, because a file your backend once served behind a check of its own is public from that deploy on. ### 4. Know the headers, the revalidation, and the compressed forms Each client file is returned with these headers: - The type, `Content-Type`, from the platform's table of file extensions, such as `text/javascript` for `.js` and `.mjs`, `font/woff2` for `.woff2`, and `image/webp` for `.webp`. A text or JSON type carries `; charset=utf-8`. An extension the table does not name gets `application/octet-stream`. - The header `X-Content-Type-Options: nosniff`, so a browser uses that type and never guesses another. - The header `Cache-Control: no-cache`, so a browser asks again before it reuses a file, and a deploy's new files reach it at once. - The tag, `ETag`, the file's SHA-256 hash in quotes. A browser that asks again names that tag in its `If-None-Match` header. Where the file has not changed, the router returns 304 with no body, and nothing is sent again. An unchanged file keeps its tag across deploys, so a returning visitor downloads only what changed. The deploy also writes a gzip form of each text, `.svg`, `.json`, `.map`, `.webmanifest`, and `.wasm` file of at least 1 KiB, where gzip makes it a tenth smaller or more. A request whose `Accept-Encoding` accepts gzip receives that form. The response then carries `Content-Encoding: gzip`, its own tag, and `Vary: Accept-Encoding`. The platform sets no content security policy on a client file. Set one in `client/index.html` where the page needs it. ### 5. Know what a client file counts, and who may read it A client file beyond the shell counts its bytes toward your plan's `data_transfer_bytes`. It counts no backend action and nothing toward the [per-address limits](/concepts/egress-firewall-and-request-limits). A 304 counts nothing. The shell's three files count nothing at all. While `backend_actions` or `data_transfer_bytes` is over the plan's quantity, the shell is still served, and every other client file is refused 429 `usage_over_quota` ([Plan and usage](/guides/plan-and-usage#5-what-an-over-state-stops)). So the page shows the refusal from the shell alone. Keep the code that reads a refused call and shows its message in `client/app.js`, never in a module it imports. Where the platform's file store does not respond, the router holds that failure for 30 seconds. In that time, a request for a file beyond the shell reaches your application as an ordinary request. It counts a backend action and toward the per-address limits. Under an `invited` or `workforce` audience, every client file needs a signed-in visitor, the page's shell among them. A browser without a session goes to the sign-in page first. A path the audience lists in `session_free_paths` is the exception ([The manifest](/concepts/the-manifest#the-audience-kinds)). ### 6. Serve the files yourself in a local run A [local run](/guides/run-your-application-on-your-machine) starts your backend alone, and nothing serves `client/` for it. These lines serve the client from your own server while `APP_ENVIRONMENT` is `local`, the value a local run sets. On the platform they serve nothing, since the router responds first. While the file store fails (step 5), these lines still return `false` there, and your own routes respond. A test runs this sample over a client folder and checks a served file, a skipped name, and a path outside the folder. ```js // src/client_files_local.mjs: a local run alone. import { readFile } from 'node:fs/promises'; import { extname } from 'node:path'; const folder = new URL('../client/', import.meta.url); const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.woff2': 'font/woff2', }; const part = /^[A-Za-z0-9_~-]([A-Za-z0-9._~-]*[A-Za-z0-9_~-])?$/; const reserved = ['__account', '__router']; export async function serveClientFile(req, res) { if (process.env.APP_ENVIRONMENT !== 'local' || req.method !== 'GET') return false; const path = new URL(req.url, 'http://localhost').pathname; const name = path === '/' ? 'index.html' : path.slice(1); const segments = name.split('/'); if (name.length > 512 || segments.length > 32) return false; if (reserved.includes(segments[0].toLowerCase())) return false; if (!segments.every((segment) => part.test(segment))) return false; try { const body = await readFile(new URL(name, folder)); res.writeHead(200, { 'content-type': types[extname(name).toLowerCase()] ?? 'application/octet-stream' }); res.end(body); return true; } catch { return false; } } ``` The server asks it first, as the router does, and goes on to its own routes where it returns `false`: ```js // src/main.mjs import { createServer } from 'node:http'; import { serveClientFile } from './client_files_local.mjs'; createServer(async (req, res) => { if (await serveClientFile(req, res)) return; // Your routes, the health path among them. res.writeHead(404).end(); }).listen(process.env.PORT); ``` A name the deploy would skip is never served by these lines either. ### 7. Serve a page from your own routes, and take a form post Your server can build a page itself, on a route of its own, with no file under `client/`. The router passes your response's status, `Content-Type`, and body to the browser unchanged. So a route can return `text/html` as well as JSON. Give such a page a path that names no client file. A `GET` for a client file's path is served from `client/` and does not reach your server (step 2). Where the application ships `client/index.html`, `/` is that file, so a page your server builds needs another path, such as `/guestbook`. A form with `method="post"` sends its fields in the body as `application/x-www-form-urlencoded`. The post reaches your server, since only a `GET` is served from `client/`. The router forwards a body up to its [request size limit](/concepts/egress-firewall-and-request-limits#the-request-size-limit) and refuses a larger one 413 `request_too_large`. Your server reads the body and parses it with `URLSearchParams`. Set a smaller limit of your own, since a form's fields are short. Each page request and each post reaches your application. So each counts a backend action and counts toward the [per-address limits](/concepts/egress-firewall-and-request-limits#the-per-address-and-per-user-limits). After a post, respond `303` with a `Location` header. The browser then requests that address with `GET`, so a reload of the page does not send the form again. The router passes the `Location` header to the browser unchanged and does not follow it. A relative `Location`, such as `/guestbook`, is enough for a page on the same hostname. The browser resolves it against the address it posted to. So the same redirect works on the production hostname, on the development hostname, on your own domain, and in a local run. Build an absolute address from `APP_PUBLIC_HOST` where the address leaves the page, such as a link in an email or a return address you give another service. `APP_PUBLIC_HOST` carries no scheme, so the address is `https://` followed by it. In a deployed copy, never build one from the request's `Host` header, which names the platform's internal address for the environment ([What the router sets on each request](/backend-packages/nodejs-runtime#what-the-router-sets-on-each-request)). The router passes the request's `Cookie` header to your server and your `Set-Cookie` headers to the browser. It removes any `Domain` attribute, so each cookie belongs to the hostname that set it alone. Under an `invited` or `workforce` audience, a post without a signed-in session is refused 401 `authentication_required` before it reaches your server, unless its path is under a session-free prefix your manifest lists ([The manifest](/concepts/the-manifest#the-audience-kinds)). Outside such a prefix, a page request without one goes to the sign-in page first. Escape every value a visitor sent before you write it into the page, as the sample's `escape` does. The text then shows as text and never runs as markup. This server builds a guestbook page at `/guestbook` and takes its form post there. It keeps the entries in memory to stay short. A running copy's memory empties when the copy stops or restarts, and each copy holds its own, so keep real entries in a [database](/getting-started/build-a-database-backed-service). Its health path is `/health`. A test runs this sample as a server and checks the page, a form post and its redirect, an escaped entry, and a body past its limit. ```js // src/guestbook.mjs: a page the server builds at /guestbook, and the form that posts to it. import { createServer } from 'node:http'; const FORM_LIMIT = 16 * 1024; // A form's fields are short: read far less than the router forwards. const entries = []; // In memory to keep the sample short. const escape = (text) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); const page = () => ` Guestbook

    Guestbook

      ${entries.map((entry) => `
    • ${escape(entry)}
    • `).join('')}
    `; // The posted fields, or null for a body past FORM_LIMIT. The whole body is read either way. async function readForm(req) { const chunks = []; let size = 0; for await (const chunk of req) { size += chunk.length; if (size <= FORM_LIMIT) chunks.push(chunk); } return size > FORM_LIMIT ? null : new URLSearchParams(Buffer.concat(chunks).toString('utf8')); } const port = Number(process.env.PORT ?? 8080); createServer(async (req, res) => { const { pathname } = new URL(req.url, 'http://localhost'); if (req.method === 'GET' && pathname === '/health') return res.writeHead(200).end('ok'); if (pathname !== '/guestbook') return res.writeHead(404).end(); if (req.method === 'GET') return res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(page()); if (req.method !== 'POST') return res.writeHead(405, { allow: 'GET, POST' }).end(); if (!String(req.headers['content-type']).startsWith('application/x-www-form-urlencoded')) return res.writeHead(415).end(); const form = await readForm(req); if (form === null) return res.writeHead(413).end(); const note = form.get('note')?.trim().slice(0, 200); if (note) entries.push(note); // 303: the browser requests /guestbook with GET, on the hostname it posted to. res.writeHead(303, { location: '/guestbook' }).end(); }).listen(port, () => console.log(`listening on ${port}`)); ``` ### 8. Deploy and check the page, a font, and a script Your tool deploys the application as [Deploy an application](/getting-started/deploy-an-application) describes, and reads `client_notice` in the response. A client with fonts or images deploys with the deploy line, since `deploy_files` accepts text files only ([Deploying from a chat](/getting-started/deploy-an-application#deploying-from-a-chat-whose-shell-cannot-reach-the-platform)). Once the deploy ends `deployed`, it requests three files on the environment's hostname with `GET`: ```sh curl -s -D - -o /dev/null https:/// curl -s -D - -o /dev/null https:///vendor/katex/fonts/KaTeX_Main-Regular.woff2 curl -s -D - -o /dev/null -H 'Accept-Encoding: gzip' https:///vendor/katex/katex.min.js ``` Each response is 200, with the file's type, `nosniff`, and an `ETag`, and the script's response is in gzip. Check with `GET`, never with `curl -I`, which sends `HEAD`, and `HEAD` reaches your application. Under an `invited` or `workforce` audience, check in a signed-in browser instead. ## Expected result A request for `/` returns the page, and every file under `client/` that the deploy admitted returns at its own path, with its type, its tag, and a gzip form where one was written. Every other request reaches your application's routes. A page your server builds comes from its own route, and its form post ends in a `303` to a path on the same hostname. A file beyond the shell counts toward data transfer and never toward backend actions, except while the file store fails (step 5). ## Refusals This table lists the refusals these steps most often meet; the [refusals page](/reference/refusals) lists all of the platform's refusals, with the cause and remedy of each. A backend package's own failures are listed on its package page. The deploy's own refusals are in [Deploy an application](/getting-started/deploy-an-application#refusals). | Refusal | Status | Cause | Remedy | | --- | --- | --- | --- | | `artifact_layout_invalid` | 400 | The zip's client files pass a bound: more than 2,000 files, one file over 25 MiB, or more than 50 MiB together. The `detail` names the bound. | Ship fewer or smaller client files, keep long media in a storage area, and deploy again (step 3). | | `not_deployed` | 404 | The environment has no deployed version yet, so there is no page to serve. | Deploy to the environment, then request the page again. | | `usage_over_quota` | 429 | The application is over its traffic quantity this month, so every client file beyond the shell is refused. | Show the message from the shell alone (step 5). A larger plan or a raised quota ends the refusal at once. | ## Related - [Deploy an application](/getting-started/deploy-an-application) builds the zip, uploads it, and deploys it. - [What the deploy zip holds](/concepts/what-the-deploy-zip-holds) gives the zip's limits and the deploy's notices. - [Store files](/guides/store-files) keeps files your users upload, and long audio or video, which the page fetches through a download grant. - [Plan and usage](/guides/plan-and-usage) reads the data transfer your client files count. - [Add sign-in to your app](/guides/add-sign-in-to-your-app) lets the page's users sign in. - [Build a mobile app's backend](/getting-started/build-a-mobile-app-backend) covers an application with no web client. - The turnzero command's [web-app template](/concepts/turnzero-command#the-web-app-template) writes a new app whose three shell files are already in `client/`, with sign-in, a database, and an AI model wired. --- title: Call an AI model under the allowance description: Generate text from your backend under the plan's included AI allowance, with no provider key, read what is left, and fall back when it runs out. slug: /guides/call-an-ai-model-under-the-allowance order: 25 visibility: public redirects_from: [] --- # Call an AI model under the allowance Prompt: > Have the backend write a one-line summary of each new note with AI. I don't have a key for any AI service. Also works: - "Use the AI that comes with my plan." - "Suggest a title for each upload." ## What your tool does - Reads the `use-ai-allowance` [skill](/concepts/glossary#skill) (`read_context`, id `skill:use-ai-allowance`) and follows it. - Writes the transport from the [settings a deployed copy receives](/concepts/the-settings-a-deployed-copy-receives), as step 2's sample does. - Adds the package and its version to the manifest's `packages` list at the next `submit_manifest`. - Asks you, before it writes the fallback, whether the application should answer without AI or call an AI service on a key of your own once the month's allowance runs out. It asks nothing else. ## What you need - An application on a plan with an AI allowance. [Plan and usage](/guides/plan-and-usage) says how to read and change the plan. - No provider account and no provider key. ## Before your AI starts This section is for your AI coding tool: what it checks and gathers before it begins. You don't need to do these steps yourself. - Your tool connected and signed in ([Connect Claude Code or Codex](/getting-started/connect-your-tool)). - The application's identifier from `list_applications`, and its plan's quantity from `read_plan_quotas`, the `gemini-allowance` row, in micro-dollars. - The backend's folder, the one that contains `package.json`. ## Steps ### 1. Take the package Your tool finds the `ai_allowance` [library](/concepts/glossary#library) entry with `list_library`, reads it with `read_library_entry`, and takes it as [Use the library](/guides/use-the-library) describes. The package lands in `app/lib/ai_allowance/`, declared in `package.json` as `"@turnzero/ai_allowance": "file:lib/ai_allowance"`. The call goes through the platform's own route, on a key the platform keeps. The application declares no `egress` entry and no upstream for it, and stores no key. ### 2. Call generateText from a route `generateText(transport, request, options)` sends one request and returns the answer. The transport is your function: it adds the platform's origin and the application's credential as the bearer. A deployed copy reads both from its settings, and a [local run](/guides/run-your-application-on-your-machine) receives the development credential, so the same transport works there. This server responds to `POST /summaries` with a one-line summary of the note in the request body. It logs what is left of the month's allowance in dollars, and returns `null` once it is drawn. Its health path is `/health`. A test runs this sample over the package's test double and checks the summary, the request it sends, and the fallback. ```js // src/server.mjs: POST /summaries returns a one-line summary of the note in the body. import { createServer } from 'node:http'; import { AllowanceRefusal, AllowanceUpstreamError, generateText } from '@turnzero/ai_allowance'; // The platform's origin and this copy's credential, from the settings it receives. const origin = process.env.TURNZERO_CLOUD_GATEWAY_URL ?? process.env.TURNZERO_CLOUD_API; const transport = (path, init = {}) => fetch(origin + path, { ...init, headers: { ...init.headers, authorization: `Bearer ${process.env.TURNZERO_CLOUD_APP_TOKEN}` } }); async function summarize(note) { try { const answer = await generateText(transport, { system: 'Summarize the note in one line.', messages: [{ role: 'user', content: note }], maxOutputTokens: 200, }); // The allowance figures are micro-dollars, 1,000,000 to the dollar: log them in dollars to the cent. const { used, quota } = answer.allowance; const dollars = (micros) => `$${(micros / 1_000_000).toFixed(2)}`; console.log(`AI allowance: ${dollars(quota - used)} of ${dollars(quota)} left before this call`); return answer.text; } catch (error) { // The month's allowance is drawn, the platform's key is not in place, or the provider is out of capacity. const drawn = error instanceof AllowanceRefusal && ['allowance_exhausted', 'allowance_unavailable'].includes(error.refusal); const providerFull = error instanceof AllowanceUpstreamError && error.quota; if (drawn || providerFull) return null; // Or call your own declared upstream here. throw error; } } const port = Number(process.env.PORT ?? 8080); createServer(async (req, res) => { if (req.method === 'GET' && req.url === '/health') return res.writeHead(200).end('ok'); if (req.method !== 'POST' || req.url !== '/summaries') return res.writeHead(404).end(); let note = ''; for await (const chunk of req) note += chunk; try { const summary = await summarize(note); res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ summary })); } catch (error) { console.error(error); res.writeHead(502).end(); } }).listen(port, () => console.log(`listening on ${port}`)); ``` [The client](/backend-packages/ai-allowance#the-client) on the AI Allowance package page explains every field of the request, among them `system`, the roles in `messages`, `responseSchema`, `maxOutputTokens`, and `thinkingBudget`. ### 3. Stream or hold a conversation For a streamed answer, pass `onText` in the options: each piece of text arrives in order, and the returned `text` is the whole answer. To stream it on to a browser, write each piece to your response as it arrives. The [serving router](/concepts/glossary#serving-router) passes a response whose headers are sent and cuts it when the request's [execution window](/concepts/egress-firewall-and-request-limits#the-execution-window) ends. For a chat, keep the conversation's turns in your own store and send them as `messages`. Add each answer that has text as an `assistant` turn, and leave an empty answer out. Every turn you send counts as input again on each call, so send a recent window that starts on a user turn. ### 4. Read what is left Every answer's `allowance` field has `used`, the micro-dollars drawn this month before the call, `quota`, the plan's quantity in micro-dollars, and `resetsAt`, the first instant of the next UTC month. A micro-dollar is a millionth of a dollar, so the sample divides by 1,000,000 to log dollars. `read_usage` returns the month's draw as the measure `ai_allowance_usd_micros`, in micro-dollars. Each call draws Google's published prices for the model it names: input tokens at its input rate, output and thinking tokens at its output rate, and image tokens at its image-output rate. Input the provider serves from its own cache draws the model's cached-input rate, or its input rate where the model has none. [Plan and usage](/guides/plan-and-usage#1-choose-a-plan) says what a plan's quantity is worth to a chat application at the default model's prices, and [AI Allowance](/backend-packages/ai-allowance#what-it-provides) states the count. Production, development, and local runs draw on the same monthly quantity. When the month's draw first passes eighty percent of it, the platform writes the event `usage warning` into the application's log stream. ### 5. Fall back when the allowance runs out Choose the fallback by the refusal's name, never by its message, as the sample's `catch` does. Never retry an exhausted allowance inside the month: every further call is refused, and each refusal is counted. To answer from an AI service on your own key instead, declare it as [Call an external API with an API key](/guides/call-an-external-api-with-an-api-key) describes and call it in the `catch`. [The boundary and the fallback](/backend-packages/ai-allowance#the-boundary-and-the-fallback) gives the whole rule. ### 6. Test over the double The package ships a [test double](/concepts/glossary#test-double) at `@turnzero/ai_allowance/testing`. It takes the place of the allowance route behind the unchanged `generateText`, so a test needs no gateway, no key, and no provider. A test runs this sample and checks that the primed answer comes back. ```js import { generateText } from '@turnzero/ai_allowance'; import { createAiAllowanceDouble } from '@turnzero/ai_allowance/testing'; const double = createAiAllowanceDouble({ quota: 1_000_000 }); double.prime({ text: 'A note about lunch.', usage: { input: 40, output: 12 } }); const answer = await generateText(double.transport, { prompt: 'Summarize this note in one line: lunch at noon' }); ``` To run step 2's own transport, replace the global `fetch` with `double.fetch` in the test. [Test your application locally](/guides/test-your-application-locally) shows the double in a suite, its refusals among them. ### 7. Deploy Your tool deploys as [Deploy an application](/getting-started/deploy-an-application) describes. The deploy injects the platform credential the transport sends, bound to its environment, so the call takes no environment header. ## Expected result `POST /summaries` returns the model's one-line summary, and the log shows the dollars left. After a call, `read_usage` shows `ai_allowance_usd_micros` above its earlier figure. Once the allowance is drawn, the call is refused `allowance_exhausted` and the route responds through its fallback. ## Refusals This table lists the refusals these steps most often meet; the [refusals page](/reference/refusals) lists all of the platform's refusals, with the cause and remedy of each. A backend package's own failures are listed on its package page. | Refusal | Status | Cause | Remedy | | --- | --- | --- | --- | | `allowance_exhausted` | 429 | This UTC month's allowance is used up; the refusal includes `resets_at`, `used`, and `quota`. | Wait for `resets_at`, move to a larger plan, or call your own upstream. Retrying this month is refused and counted each time. | | `allowance_unavailable` | 503 | The platform has no key for the allowance yet. | Call your own declared upstream, and report it if it persists. | | `allowance_application_required` | 403 | The call used an account-wide credential, such as a session or an [account-scoped token](/concepts/glossary#account-scoped-token). | Call under the application's platform credential, or a token for the application with the environment header. | | `allowance_busy` | 429 | The application already has the most allowance calls in progress that the platform allows. | Retry after one of them ends. | | `allowance_path_refused` | 403 | The request is not a `POST` to a generate method of a model whose name opens with `gemini-`. | Call through the AI Allowance package's `generateText` function. Leave out `model`, or name a Gemini model, any whose name opens with `gemini-`. | | `plan_quantity_unset` | 409 | The application's plan has no `gemini-allowance` quantity set. | Wait for platform staff to set it, or move to another plan. `read_plan_quotas` shows it as a null `quantity`. | ## Related - [AI Allowance](/backend-packages/ai-allowance): every request and answer field, the count in dollars, and the fallback rule. - [Generate an image under the allowance](/guides/generate-an-image-under-the-allowance): a picture on the same route, and what each one costs. - [Call an external API with an API key](/guides/call-an-external-api-with-an-api-key): an AI service on your own key, the usual fallback. - [Use the library](/guides/use-the-library): taking the package into the project. - [Test your application locally](/guides/test-your-application-locally): the double in a suite. - [Plan and usage](/guides/plan-and-usage): what stops while the allowance is over its quantity. - [The settings a deployed copy receives](/concepts/the-settings-a-deployed-copy-receives): the origin and the credential the transport reads. - The turnzero command's [web-app template](/concepts/turnzero-command#the-web-app-template): a new app that takes this package and calls `generateText` from a route, as step 2 shows. --- title: Receive a signed webhook description: Store a webhook's signing secret, bind it to a setting, check each delivery's signature in your handler, and confirm deliveries in the logs. slug: /guides/receive-a-signed-webhook order: 26 visibility: public redirects_from: [] --- # Receive a signed webhook Prompt: > Stripe should tell the backend when a payment goes through. Set up the webhook. Also works: - "Accept the payment provider's callbacks." - "Receive GitHub's push events on the backend." ## What your tool does - Reads the `store-key` [skill](/concepts/glossary#skill) (`read_context`, id `skill:store-key`) and follows it to store each environment's secret. - Binds the stored name to a [setting](/concepts/glossary#setting) in the [manifest](/concepts/glossary#manifest)'s `settings`, and submits the manifest again with `submit_manifest`. - Writes the route that checks each delivery's signature against that setting, as step 4's sample does. - Under an `invited` or `workforce` [audience](/concepts/glossary#audience), lists the route's path in the audience's `session_free_paths`. - Deploys, [promotes](/concepts/glossary#promote) where the application has a development environment, and confirms your test delivery with `read_logs`. - Asks you for each environment's signing secret as a file, never the value, or, where the sender asks you to choose it, writes a random one into a file unprinted. It gives you the route's address to register with the sender. ## What you need - The signing secret the sender issued for each environment, from the sender's dashboard, or one you choose where the sender asks you to ([step 2](#2-store-the-shared-secret)). Stripe issues one for test mode and another for live mode. - An application that deploys ([Deploy an application](/getting-started/deploy-an-application)). A new one starts from the `hello-world` template, the scaffold line with no `--template`, and step 4's route is added to it. The `database-service` template's `--resource` writes routes that create, read, update, and delete records, which a webhook receiver does not use. Where the deliveries must be kept, start from `database-service` and replace its routes with step 4's ([`--template`](/concepts/turnzero-command#starting-a-new-service)). ## Before your AI starts This section is for your AI coding tool: what it checks and gathers before it begins. You don't need to do these steps yourself. - Your tool connected and signed in ([Connect Claude Code or Codex](/getting-started/connect-your-tool)). - `list_applications` returns the application's identifier, and `read_status` names its environments. - Each secret's file sits outside the application's folder, because a deploy's zip includes every file of that folder. ## Steps The example is a Stripe webhook at the path `/webhooks/stripe`, the secret [Store a secret](/getting-started/store-a-secret#worked-example-a-webhook-signing-secret) also works through. ### 1. Choose the environments Each environment receives its own deliveries and checks them with its own secret. Production takes the sender's live secret. A development environment, once turned on, takes the test one ([Turn on a development environment and promote](/guides/turn-on-development-and-promote)). ### 2. Store the shared secret The sender signs each delivery with the secret, and your route checks the signature with the same secret. So the stored value is the one the sender issued. Some senders ask you to choose the secret instead, as GitHub's webhooks do. Then your tool writes a random value for each environment into its own file, outside the application's folder and in a folder that exists, without printing it: ```text node -e "require('node:fs').writeFileSync(process.argv[1],require('node:crypto').randomBytes(32).toString('hex'),{mode:0o600,flag:'wx'})" "../secrets/webhook-secret-production.txt" node -e "require('node:fs').writeFileSync(process.argv[1],require('node:crypto').randomBytes(32).toString('hex'),{mode:0o600,flag:'wx'})" "../secrets/webhook-secret-development.txt" ``` Your tool stores each environment's value from its file as below. You then copy each file's value into that environment's webhook at the sender, so both sides hold the same secret. Delete the files once the sender holds them. Your tool calls `store_secret` with the name `stripe-webhook-secret`, the application, the environment, and the secret's file as `value_file`. It runs the line the call returns, once for each environment. [Supply the value without exposing it](/getting-started/store-a-secret#2-supply-the-value-without-exposing-it) says who runs the line. Never store this secret with `generate`. A created value is never returned, so no sender can hold it, and no delivery could carry a valid signature. ### 3. Bind it to a setting Your tool adds the binding to the manifest's `settings` and submits the whole manifest again with `submit_manifest`. ```json "settings": { "STRIPE_WEBHOOK_SECRET": { "secret": "stripe-webhook-secret" } } ``` The response's `settings` rows show whether the name is stored for each environment. [Bind a stored value to a setting](/guides/author-the-manifest#bind-a-stored-value-to-a-setting) gives the rules for the member. ### 4. Check the signature in the route The route reads the body as raw text, checks the `Stripe-Signature` header against the setting, and only then parses the JSON. Another sender names its own header and signing scheme in its documentation, and the check keeps the same shape. A delivery without a valid signature is refused with 400 and changes nothing. A test runs this sample against a signed delivery, a changed body, an old timestamp, and a missing header. ```js import { createHmac, timingSafeEqual } from 'node:crypto'; // True where the header signs this raw body with the secret, within five minutes. export function signatureIsValid(rawBody, header, secret, nowSeconds = Math.floor(Date.now() / 1000)) { if (typeof header !== 'string' || !secret) return false; const fields = header.split(',').map((field) => field.split('=')); const timestamp = Number(fields.find(([key]) => key === 't')?.[1]); if (!Number.isInteger(timestamp) || Math.abs(nowSeconds - timestamp) > 300) return false; const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest(); return fields.some(([key, value]) => key === 'v1' && /^[0-9a-f]{64}$/.test(value ?? '') && timingSafeEqual(Buffer.from(value, 'hex'), expected)); } // The route at POST /webhooks/stripe. export async function handleStripeWebhook(req, res) { const chunks = []; for await (const chunk of req) chunks.push(chunk); const rawBody = Buffer.concat(chunks).toString('utf8'); if (!signatureIsValid(rawBody, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET)) { console.log('webhook refused: no valid signature'); res.writeHead(400).end(); return; } const event = JSON.parse(rawBody); console.log(`webhook ${event.type} ${event.id}`); res.writeHead(200).end(); } ``` The check needs the body exactly as it arrived, so a framework must not parse it first. In Express, mount the route with `express.raw({ type: 'application/json' })` and pass `req.body.toString('utf8')` to `signatureIsValid`. Where no sender's documentation gives a scheme, as for a sender you write yourself, use the one this sample checks. The sender puts `t=,v1=` in one header: `t` a timestamp in seconds, and `v1` the lowercase hex HMAC-SHA256 of `.` under the shared secret. The route recomputes the HMAC, compares it with `timingSafeEqual`, and refuses a timestamp more than five minutes from its clock. A sender may deliver the same event more than once, so the route acts on each event's `id` once. Each delivery writes one console line, which step 7 reads. ### 5. Open the path under a private audience Under a `public` audience, every request reaches the route already. Under `invited` or `workforce`, a request without an end-user session is refused, and a sender has none. So your tool lists the route's path in the audience's `session_free_paths`, for example `{"kind": "invited", "session_free_paths": ["/webhooks/stripe"]}`. The platform lets every request to a listed path through without checking who sent it. That is why step 4's check is the route's own. [Choose who may sign up](/guides/make-sign-up-invitation-only#1-choose-who-may-sign-up) gives the list's rules. ### 6. Deploy, promote, and register the address Your tool deploys as [Deploy an application](/getting-started/deploy-an-application) describes. A deploy reads the value stored for its environment and injects it as `STRIPE_WEBHOOK_SECRET`. A promote reads the production value the same way. Your tool then gives you the route's address on each environment's hostname, such as `https:///webhooks/stripe`. You register it in the sender's dashboard, the live address with the live secret and the test address with the test one. ### 7. Confirm a delivery You send a test event from the sender's dashboard. Your tool then calls `read_logs` for that environment with the source `container`, which returns the route's console lines. A line that opens `webhook` and names the event's type means the delivery passed the check. A line that reads `webhook refused` means the signature did not match: the stored secret is not the one the sender used for that address. [Read logs and counters](/guides/read-logs-and-counters) describes the sources. Before a sender is set up, prove the check in a local run. Give the setting a test value of your own in `.env`, as [Run your application on your machine](/guides/run-your-application-on-your-machine#3-start-the-application) describes. Send the local route one delivery signed with that value and one with no signature. The signed one carries the header `Stripe-Signature: t=,v1=`, where `` is the current Unix time and `` is the HMAC-SHA256 of `.` under the value. Sign and send it from one Node script, such as with `fetch`: a shell's quoting can change the body's bytes after signing, and the check then refuses a correct signature. The console then shows one `webhook` line and one `webhook refused` line. Where your own system is the sender, it signs with the value in the file you stored from, on the machine that holds the file. The platform signs no test delivery with a stored secret, since any token could forge one: sign with your own value as above and verify with step 4's sample, as no library package does. ## Expected result A delivery the sender signed reaches the route, passes the check, and answers 200, and its console line names the event. A delivery with a missing or wrong signature answers 400 and changes nothing. Each environment checks its own deliveries with its own secret. ## Refusals This table lists the refusals these steps most often meet; the [refusals page](/reference/refusals) lists all of the platform's refusals, with the cause and remedy of each. A backend package's own failures are listed on its package page. | Refusal | Status | Cause | Remedy | | --- | --- | --- | --- | | `setting_secret_missing` | 409 | A `deploy` or `promote` found a bound name not stored at its environment's scope; `detail` names the setting and the name, and `next` the `store_secret` call to make. | Store the value there with `store_secret` for the application and the environment, as [Store a secret](/getting-started/store-a-secret#2-supply-the-value-without-exposing-it) describes, then deploy or promote again. | | `manifest_invalid` | 400 | The manifest does not match the schema; `violations` lists the failing paths. A `session_free_paths` entry that breaks a rule is refused at its own place in the list. | Correct each path and resubmit the whole manifest. | | `authentication_required` | 401 | Under an `invited` or `workforce` audience, a delivery reached a path the audience does not list as session-free. | List the route's path in the audience's `session_free_paths`, submit the manifest again, and deploy. | ## Related - [Store a secret](/getting-started/store-a-secret) stores, binds, and rotates the signing secret, with the same example worked through. - [Author the manifest](/guides/author-the-manifest) covers the `settings` member and the audience. - [Add sign-in to your app](/guides/add-sign-in-to-your-app) sets the audience and its session-free paths. - [Read logs and counters](/guides/read-logs-and-counters) reads the route's console lines. - [Turn on a development environment and promote](/guides/turn-on-development-and-promote) adds the environment the test secret goes to. --- title: Test sign-in with test end users description: Create test end users, sign one in through your app's own sign-in pages with no mailbox, test the signed-in routes, then remove them. slug: /guides/test-sign-in-with-test-end-users order: 27 visibility: public redirects_from: [] --- # Test sign-in with test end users Prompt: > Test my app's signed-in pages without me signing in. Also works: - "Sign a test user in to the development environment and check the account page." - "Run the native app's sign-in, refresh, and sign-out with no person." - "My local run needs a signed-in session to test `/me`." A test end user is an end user your tool creates on one of your application's [realms](/concepts/glossary#realm), at an address at `synthetic.turnzero.ai`, which receives no mail. Your tool reads its sign-in code through a management action instead of a mailbox. It signs in through your application's own sign-in pages, so your code runs exactly as it does for a person. ## What your tool does - Reads the `add-sign-in` skill (`read_context`, id `skill:add-sign-in`) and follows its rule on test end users. - Signs a test end user in with `curl` and a cookie jar file in the system's temporary folder, or from a browser, and never prints the session's value. - Tests the signed-in routes you name, then removes the test end users unless you ask to keep them, and deletes the jar whatever the tests' outcome. - Asks you to approve `admit_test_end_users` in the browser only for a test on production, after development is on or from a token you minted outside the `workforce` audience. From such a token, it first offers to add a development environment and test there instead. ## What you need - An application whose manifest declares the `accounts` service ([Add sign-in to your app](/guides/add-sign-in-to-your-app)). - Your approval in the browser, only for a test on production, after development is on or from a token you minted outside the `workforce` audience. ## Before your AI starts This section is for your AI coding tool: what it checks and gathers before it begins. You don't need to do these steps yourself. - A connected, signed-in tool ([Connect Claude Code or Codex](/getting-started/connect-your-tool)), acting as the account that owns the application. No other account's credential reaches these actions. - Whether the application has a development environment, from the environments `read_status` lists. - For backend tests that need no live sign-in, the Account package's double first ([Test with the double](/backend-packages/account#test-with-the-double)). Test end users are the live half: the sign-in pages, the session cookie, and the verification. ## Steps ### 1. Choose the environment Most applications start with production alone. On such an application, your tool's own signed-in session, the connection you signed in to, creates test end users on production and reads their codes with no approval. Each creation writes one line to the platform's record, naming the application and how many it created. A token you minted cannot use this route. On an application with a development environment, test there: a development realm accepts test end users from its creation. When `create_environment` adds development ([Turn on a development environment and promote](/guides/turn-on-development-and-promote)), it first removes production's test end users and closes production to your tool's session; where it removed any, its answer says how many. Deleting the development environment opens production to it again. To test production after that, take the approval in [Test on production with an approval](#test-on-production-with-an-approval). Under the `workforce` audience, only your tool's own signed-in session creates test end users and reads their codes. Development needs no approval; production needs one once development exists. A token you minted is refused there, even after an approval. A minted token rests in a pipeline or on a machine, so it could sign someone in past your organization's sign-in with no person present. First set the realm's `entra` route to the tenant your audience declares ([Add work-account sign-in](/guides/add-work-account-or-apple-sign-in#2-add-work-account-sign-in)): until it names that tenant, no one signs in, test end users included. A test end user's address is at `synthetic.turnzero.ai` and has no `source`, so a route that admits only tenant addresses refuses it. Test with a route that admits any signed-in user. Prove your code's check for tenant addresses offline, over the Account package's double, as [Test with the double](/backend-packages/account#test-with-the-double) shows. ### 2. Create the test end users Call `create_test_end_users` with the application, the environment, and one to twenty labels, such as `tester-one`; [create_test_end_users](/reference/actions/create-test-end-users) gives the form a label takes and every member of the answer. A test checks these members against the action's request. ```json { "application": "", "environment": "production", "labels": ["tester-one"] } ``` Each label becomes the verified address `