About 9pm
An operating guide for the 9pm.ai CLI after installation and authorization, aimed at developers using an agent to manage app deployments. It covers authentication, app inventory, scoped keys, and deployment through an MCP connector in sandboxes.
Key features
- 01CLI login and key management guidance
- 02List apps and inspect deployment status
- 03Pre-deployment checks and deployment guidance
- 04Scoped keys and permission boundaries
- 05MCP connector guidance for sandbox environments
Use cases
- 01Inspect account apps and choose the intended project
- 02Set up restricted keys for unattended workers
- 03Run project checks before deployment
- 04Deploy apps from a sandbox through the MCP connector
MCP support
9pm.ai provides an MCP connector at `https://api.9pm.ai/mcp` for identity, app management, deployment, and checking deployment status and app URLs. The user must add and authorize it in the agent platform settings.
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ tmrw-realityos/ 9pm-skill --skill 9pmAfter installing, type/9pmin Claude Code to confirm it loaded.
Details
From the project README9pm.ai
Product guidance for working with 9pm.ai after the CLI is installed and the user has authorized access. Not the one-time setup prompt.
If this skill is not installed where your harness reads skills (or needs updating), run npx skills add tmrw-realityos/9pm-skill --skill 9pm -g — a user-level install with a shared copy at ~/.agents/skills/9pm/SKILL.md (needs npm-registry and github.com egress; a trailing PromptScript "Failed to install" line is noise — the install succeeded). Keep the -g: without it the installer writes into the user's project, and this file must never be copied into a user's project directory.
Operating Rules
- Inspect before changing.
- Ask before creating files for a new app or modifying an existing app.
- Deploy what the user built. Prefer the smallest compatibility change that preserves their stack and intent.
- Deploy the working copy the user is editing — 9pm uploads the directory you point at, not a git branch. When the user is on a branch or in a git worktree, confirm the deploy path resolves to that checkout before running (see Previews and Branches).
- Never refactor an app — especially its storage layer — to fit 9pm.ai without explicit user approval. If an app already has a working database, attach storage to it (see Persistence) rather than rewriting it.
- Run the app's existing build/test checks before deploying when available.
- Run
9pm deploy <dir> --checkbefore the real deploy when supported. - An app can belong to an organisation instead of the person deploying it. Deploy into one with
--owner <organisation-slug>(the account must be an admin or owner of it) and list its apps with9pm apps --owner <organisation-slug>; without the flag, everything is the account's own. Organisations are created and their members invited from the dashboard. 9pm deployruns the project's own build locally — for framework apps that meansnpm install(and its lifecycle scripts), the framework build, ordocker buildexecute on your machine. Only deploy directories you trust, the same way you'd trust running that project's build.- Report the deployed URL and any follow-up risks clearly.
Authentication
This chat agent has no secure secret input. Anything pasted into the conversation is logged in the model provider transcript, tool-call history, and prompt cache. Do not request the user's API key. Do not accept one if offered. If the user pastes a key anyway, stop, tell them to treat it as compromised, and instruct them to revoke and reissue from the dashboard.
Authentication paths, in order of preference:
9pm loginfor an existing account — including one the user just created from an invite email. Device-code flow; the token is stored in native protected storage. Plaintext file storage is opt-in only when native storage is unavailable. The agent never sees the key. Running9pm loginagain keeps a stored credential that still works instead of creating another;9pm login --freshreplaces it and revokes the old one.- A brand-new account is created in the browser, never from the terminal.
9pm signupcannot create an account, whether public signup is open or not: it refuses immediately (it does not hang waiting for an approval) and prints the next step, which is the sign-up page while public signup is open, or requesting an invite while it is closed. Once the account exists, use9pm login. An invite link cannot be redeemed through9pm signup; the one-time link in the email is the only way to use it. - User-set environment in their own terminal:
export NINEPM_API_KEY=9pm_.... Warn that the value can land in shell history. NINEPM_API_KEYoverrides stored login for CI.- A key for CI or an unattended server is minted from a signed-in machine with
9pm api-key create --name <name>; the token is printed once, so the user runs this in their own terminal and stores the value themselves.9pm api-key lsand9pm api-key revoke <key-id>manage them; an account holds at most 10 at a time, login tokens not counted.
Scoped keys for unattended workers
Prefer a scoped key over a full-access one for any worker that runs unattended, so a leaked key cannot delete the account's other apps. 9pm api-key create --preset <preset> picks what it may do:
monitorreads app status and deployment history, on the apps named with--project <app>(repeat the flag).deployadds deploying, setting environment variables and adding a database, on the--projectapps.provisionadds creating apps, inside one automation (--automation <id>). Automations group the apps a provision key reaches:9pm automation create <name>,attach <id> <app>,detach,apps,ls,rename, andrm <id> --yes, which revokes the automation's keys and keeps its apps.full(the default) does everything, on every app.
Deploy permission on an app is effectively access to that app's runtime secrets and data: a key that can replace an app's code can read anything the app can. Scoping protects the other apps, not the ones a key may deploy to. No scoped key can delete apps, change access, manage domains or files, mint keys, manage automations, act for an organisation (--owner) or read an app's logs, databases or analytics; those need a full-access login, and creating scoped keys and automations needs one too. When a worker on a scoped key needs 9pm logs, ask the user to run it signed in as themselves; do not give the worker a full-access key for it.
--expires sets a fixed deadline, as an ISO 8601 date-time with its zone (2026-12-31T00:00:00Z) or a relative 30d / 12h, at most a year ahead. It does not replace the inactivity rule: every key lapses after 90 days without use, and a key with --expires lapses at whichever comes first. A quiet automation's key can lapse that way and its worker then stops with an "expired" refusal; 9pm login does not fix that. Recover by minting a replacement from a full-access login with the same preset and apps, or for a provision key for the same automation (its apps are still attached), then revoke the old key.
When a scoped key is refused, the CLI says why. A missing permission is named along with the presets that include it. A "not found" means the app does not exist or this key does not reach it: check the key's apps under API keys in the dashboard Settings, or from a CLI signed in with the user's full-access login. 9pm whoami with a scoped key shows its prefix, preset and expiry rather than the account's plan and usage.
The endpoint defaults to https://api.9pm.ai unless the user specifies another. When a command needs it set explicitly:
export NINEPM_API_URL=https://api.9pm.ai
Preflight:
9pm doctor
9pm whoami
Fallback only when whoami is unavailable and the user has set NINEPM_API_KEY themselves:
curl -fsS "$NINEPM_API_URL/v1/sites" -H "authorization: Bearer $NINEPM_API_KEY" >/dev/null
Inventory
Before deploying or modifying anything, list what the user already has so you don't collide with an existing app slug or operate on the wrong project:
9pm apps
9pm apps --json
The slug column is the handle for every subsequent command (deploy, access, delete, env, files --project). active means the app has a finalized deployment; no-deploy means it was created but never finalized.
Traffic analytics
Every deployed app has a built-in Analytics view in the dashboard (per app, selectable 24h / 7d / 30d window): views, unique visitors, top referrers, countries, human-vs-crawler split, and the paths that returned 404. It's captured automatically — nothing to add to the app and no third-party script. Visitor counts are privacy-preserving (a daily rotating hash, no raw IP, no cookies). Point the user to their app's page in the dashboard to see it.
Versioning
9pm doctor reports a CLI: line comparing installed vs latest. If the user hits an "unknown command" error or behavior that doesn't match this guidance, suspect an out-of-date CLI and run:
9pm latest
Upgrade with:
npm i -g ninepm@latest
The CLI also checks by itself after a successful 9pm deploy (at most one registry probe per day) and prints an Update available: ... line when the installed version is behind. Treat that line as the cue to upgrade now — and to refresh this skill (see Keeping this skill fresh below). Set NINEPM_NO_UPDATE_CHECK=1 to silence the check.
The CLI is the npm package ninepm on the public npm registry. Run it ad-hoc with npx ninepm <args> (no install), or install a persistent 9pm binary with npm i -g ninepm:
npx ninepm <args>
npx ninepm is the simplest path in a restricted sandbox: the public npm registry is on the default "Trusted" allowlist, so it runs without trusting any 9pm host to fetch the CLI (see Sandboxed Environments). Reads are anonymous; no token needed to install.
Keeping this skill fresh
This guide ships alongside the CLI and goes stale the same way. Whenever the CLI reports being behind — the post-deploy Update available: line, 9pm latest, or 9pm doctor — also refresh the installed skill with the same command that installs it:
npx skills add tmrw-realityos/9pm-skill --skill 9pm -g
To check for drift without reinstalling, run 9pm doctor — its Skill: line compares the installed copy's Skill guide stamp against the public source — or compare this file's stamp line yourself against the copy at https://raw.githubusercontent.com/tmrw-realityos/9pm-skill/main/skills/9pm/SKILL.md, the same source the install command uses, trusted by default in sandboxes. Any difference means refresh. (The copy served at https://9pm.ai/skills/9pm/SKILL.md tracks platform releases and can briefly lag that source, so don't use it as the freshness reference.)
Skill guide stamp: 2026-10-22.4
Sandboxed Environments
If you run inside a sandbox with restricted network egress (the Claude cloud sandbox, or the Claude Code CLI bash sandbox - both default to a "Trusted" allowlist), some steps fail until the relevant hosts are trusted.
Get the CLI without touching the allowlist: run it via npx ninepm <args> (or npm i -g ninepm for a persistent binary). The public npm registry is on the Trusted default, so this needs no extra trust.
Deploying without touching the allowlist: the 9pm connector
In a cloud sandbox the network policy is applied when the environment is built, so an agent already inside cannot change it - and the user may not want to change it at all. For that case 9pm publishes an MCP connector at https://api.9pm.ai/mcp. Connector traffic is routed through the agent platform's own servers instead of out of the sandbox, so it reaches the platform on default "Trusted" settings with nothing added to Allowed domains.
Adding it is a one-time step the user performs outside the session, in their agent platform's connector settings, followed by a browser sign-in to approve access. The tools then appear in every later session. You cannot add it from inside the sandbox.
Check before you send the user through that setup: deploying through the connector has to be enabled per environment. Where it is not, the identity and app-management tools still work but deploy_begin answers with an instruction to trust api.9pm.ai instead of an upload URL - in which case the allowlist below is the only route and the connector saves nothing. If you already have the connector, one deploy_begin call tells you which case you are in.
It covers identity (whoami), listing and creating apps, deploying, deployment status and app URL. A deploy runs as begin, upload, finalize:
deploy_beginwith the app slug and a manifest of the built files (path, size, lowercase-hex sha256, content type). It returns adeployIdand presigned upload URLs for the files whose content the platform does not already have - at most 50 per response. When it also returns anextCursor, calldeploy_upload_targetswith that cursor and keep going until no cursor comes back: those later pages are the rest of your files, and a build with more than 50 new files cannot finalize if you skip them. A container app gets a single URL for the image tar instead.- PUT each file's bytes to its own URL from inside the sandbox.
deploy_finalizewith thedeployId. It ingests in batches, so if it reports it is still uploading, call it again until it publishes and returns the live URL. For a container app it returns once the tar has been read back; pollget_deployment_statusuntil the app is up.
What it does not cover:
- Worker-runtime apps. The connector refuses them rather than mis-publishing. Trust
api.9pm.aiand deploy with the CLI instead. - Static builds over 500 files in one deploy. Same remedy.
- Everything else in this skill - environment variables, secrets, access modes, managed files, delete - is CLI-only. The connector is an extra deploy path, not a replacement for the CLI.
Trusting the API host
Host to trust for deploying:
api.9pm.ai- the deploy API the CLI calls (9pm login,deploy,apps, ...). This is the one host to add: the CLI reaches the platform here, and it is not on the Trusted default. (The CLI comes from public npm and this skill from GitHub - both already Trusted - so no other 9pm host needs allowlisting.)- Container deploys (
--runtime container) build the image locally, so they pull base layers from Docker Hub (registry-1.docker.io/auth.docker.ioare on the Trusted default; the layer-blob CDN may need adding). The built image is then uploaded toapi.9pm.ai(the/v1/container-uploads/<id>/imagePUT - same host as above). The push to the managed image registry happens server-side, so no registry host needs allowlisting in the sandbox.
How you trust them depends on the environment:
- Claude cloud (claude.ai/code): set this in the environment's network settings (the cloud icon), not in any settings file. Choose Custom network access and add the hosts in the Allowed domains field. The policy is applied when the environment is (re)built, so set it before the session - an agent already inside the sandbox cannot change it mid-session, and
~/.claude/settings.jsonis not loaded in cloud sessions. See https://code.claude.com/docs/en/claude-code-on-the-web. - Claude Code CLI (local bash sandbox): add the hosts to
sandbox.network.allowedDomainsin.claude/settings.json(wildcards like*.9pm.aiwork). See https://code.claude.com/docs/en/sandboxing.
Apply the allowlist from outside the sandbox (the cloud environment settings, or your CLI settings) before the first run - an agent already inside the sandbox cannot change it mid-session. npx ninepm gets you the CLI with no allowlisting, but api.9pm.ai still has to be trusted from outside before any deploy.
This skill itself is published to a public GitHub mirror (raw.githubusercontent.com/tmrw-realityos/9pm-skill/main/skills/9pm/SKILL.md), which a sandbox trusts by default - so fetching the guidance needs no allowlisting even when 9pm.ai is not trusted.
Command Approval
Separate from the network egress above: a coding agent harness may ask the user to approve every 9pm command before it runs, so a single deploy turns into a dozen approval prompts. This is the agent's own command-approval gate, not a 9pm setting - nothing about 9pm is inherently untrusted, the agent just has no standing rule that it is safe to run. Pre-trust the 9pm command surface in the agent's permission config so the routine deploy flow runs hands-off, while the one destructive command still asks first. The mechanism is harness-specific - apply only the branch that names your harness.
Claude Code only: add this to .claude/settings.local.json in the user's project, merging into any existing permissions block rather than replacing it:
{
"permissions": {
"ask": [
"Bash(9pm delete *)",
"Bash(9pm deployments rm *)",
"Bash(9pm deployments delete *)",
"Bash(9pm files delete *)",
"Bash(9pm files share *)",
"Bash(9pm env rm *)",
"Bash(9pm access *)"
],
"allow": ["Bash(9pm *)"]
}
}
ask rules take precedence over allow, so the routine surface (deploy, apps, doctor, logs, whoami, env ls, files reads, ...) runs hands-off while every destructive or access-changing command still prompts first: 9pm delete (destroys an app), 9pm deployments rm/deployments delete (removes a deployment), 9pm files delete, 9pm files share (mints a link to a file - and with --public, one that needs no sign-in), 9pm env rm, and any 9pm access change. Use ask, not deny - deny would block those commands outright instead of prompting for them.
This trusts the installed 9pm binary. If you run the npx ninepm form instead, add the same rules with an npx ninepm prefix (including Bash(npx ninepm delete *) under ask); they match npx ninepm <args> directly.
Any other harness (Cursor, Codex, anything else): do not create .claude/settings.local.json - only Claude Code reads it, and to any other harness it is stray clutter in the user's project. There is no clean per-binary allowlist on these harnesses: trust 9pm through that agent's own approval-policy or sandbox settings, or approve commands as they appear.
Supported App Shapes
- Static web builds with an output directory containing
index.htmland assets. Common examples: Vite, React, Vue, Svelte, Solid, Astro static output, vanilla HTML/CSS/JS, and static exports. Each individual file is limited to 25 MiB. A single asset larger than that — most often a large WebAssembly engine binary from a Godot/Unity/Flutter web export — cannot be hosted on the static or server runtime; the deploy is rejected up front at9pm deploy --checkand at create time with anasset_file_too_largeerror naming the file and the limit. This is a hard, deterministic limit, not a transient failure — retrying will not help. Split or externally host the oversized file, or package the app as a container instead. - Single-page apps with client-side routing, deployed from their build output directory with
--spa, so a deep link or a refresh on a client-side route loads the app (see Pages that do not exist below). - Runtime apps with a bundled JavaScript server entry that exports a
fetch-compatible handler. Common examples: Hono-style apps, small API backends, and framework runtime adapters that produce one bundled entry. - Framework apps deploy with zero flags: running
9pm deployin a Next.js or Vite project detects the framework and runs the full pipeline itself — installing dependencies if needed, running the production build, then uploading. The build may add an adapter config file to the project; commit it if the CLI says so. Build errors stream through verbatim — they are the framework's own; fix the app code and re-run.--no-buildskips detection;--bundle <dir>deploys a pre-assembled bundle (entryworker.jsplus anassets/directory). Astro/SvelteKit are not auto-built yet —9pm deploysays so and tells you what to do: run the framework's Worker-runtime adapter build yourself (9pm deploynames the adapter to run and the exact deploy command), then deploy the output with9pm deploy . --bundle <adapter output dir>(Worker bundle) or9pm deploy <static output dir> --no-build(static output). Known Next.js limitation: routes that usenext/og(ImageResponse) pull inyoga.wasm, which the runtime build cannot resolve, so the build fails on it. Pre-render those Open Graph images to static files (or drop the dynamic OG routes) before deploying. - Container apps with a
Dockerfilewhen the app cannot fit the static or server runtime. The CLI builds locally forlinux/amd64, requires--port, accepts--health-path, and supports provider-neutral--region(auto,us-east,us-west,europe,asia,south-america,middle-east,oceania,africa). The app must listen on0.0.0.0:$PORTand expose an HTTP server — read thePORTenvironment variable, never hardcode the selected--port: on some placements the platform itself holds the selected port and hands the app a different one throughPORT, so a hardcoded port collides with it and the deploy fails its health gate with no obvious cause. The filesystem is ephemeral by default. A persistent disk (--volume <gb>, mounted at/data), an always-running instance (--always-on), and custom sizing (--cpus <n>,--memory <size>like4g) are available only on placements that support them and may need platform enablement — if they are not enabled, the deploy fails fast with acontainer_capability_unavailableerror rather than silently dropping the option. - Apps that need managed storage when deployed with
--with-db. Server/runtime code receives a managed storage handle from the runtime; do not expose secrets in browser code. - Apps that need per-user accounts when deployed with
--with-auth— 9pm-managed end-user sign-in (email one-time codes), with the current user injected into the app. Implies--with-db. See End-User Accounts. - Private apps using 9pm.ai access commands when the user requests restricted access.
Calling one deployed app from another. A runtime (server) app cannot reach another 9pm.ai app over its public hostname. Such a request never leaves the platform's internal network, so it fails with an edge error even though the other app is up and answering everyone else normally — nothing in the calling app's logs says why, and the other app looks healthy from outside. Call the other app from the browser instead, or from a container app's own runtime; both of those go out to the internet like any other client and work normally. 9pm deploy warns when it spots another app's hostname in server code, but it can only see hostnames written literally — one assembled at runtime or read from an environment variable will not be caught. This does not apply to the platform's own API (api.9pm.ai), which server code may call normally.
Public files of a runtime app. With --entry, choose which files are public: put the browser files (HTML, CSS, images, client scripts) in one directory and pass --assets <dir> — only that directory is served, from the app's root (public/index.html answers on /), and nothing else in the deploy directory is uploaded. Pass --no-assets when the entry serves everything itself. Always pass one of the two for a new runtime app: without either, every file in the deploy directory except the entry is downloadable by anyone — server helpers, config, backups like index.js.orig. The deploy warns when that is the case and names the files. The app remembers the choice, so a later deploy without the flag keeps it; pass the other flag (or a different directory) to change it. 9pm deploy --check lists every file a deploy would make public, and its summary counts the files scanned apart from the files selected for upload; --check itself uploads nothing. This does not apply to --bundle deploys, which serve only their assets/ directory, or to static sites, which are public by definition.
Server code size. Server code is limited to 9.9 MB compressed (gzip). That budget covers the deployed server modules together — for a framework bundle it is the total of every module, not a per-file limit — and static assets do not count toward it, since they are served from the asset store. 9pm deploy --check prints the measured compressed size on a passing check as a Server code (…): N bytes compressed of M allowed. line, so read it from there to see the headroom; both --check and a real deploy reject an over-budget bundle before the app is created. Apps needing more server code than that should deploy as containers instead. This applies to both server-runtime shapes above — a single --entry module and a --bundle build alike.
How a --runtime worker app shares requests with its static files. Every file in the deploy folder except the entry is published as a public static file; the entry itself is not served. A request for a path that is literally one of those files (/menu.html, /app.js) is answered straight from the static files and never reaches the handler. Every other request — including / and any client-side route — goes to the handler first, and the static files are only its fallback: when the handler returns null or undefined (no Response), a GET/HEAD of / serves index.html. What any other unmatched GET/HEAD path gets depends on the app's missing-pages mode (see Pages that do not exist below): index.html with --spa, a 404 with --no-spa. Whatever Response the handler returns, a 404 included, is sent as it is in every mode. So return null for routes the handler does not own, never its own 404: a catch-all 404 hides the homepage, and in --spa mode every deep link too. A catch-all 404 can therefore hide the homepage after an upgrade. To serve a static file from inside the handler (for example after a check), env.ASSETS.fetch(new Request(new URL("/index.html", request.url), request)) reads the app's static files by their literal path — /index.html, not / — and is present only when the deploy has static files. Because literal file paths bypass the handler, app code cannot protect a static file: keep anything private out of the deploy folder's static files and serve it from a handler route that checks the user.
Persistence
Most apps that store data already use a local database or files. Deploy what they have — do not rewrite the storage layer to fit the platform unless the user explicitly asks. Two persistence options, in order of least disruption:
- Persistent disk for a container app (
--volume <gb>), where enabled. This is the smallest change for a container that already has a local database (SQLite file, embedded store, on-disk data): the disk mounts at/data, so point the app's database/data directory at/dataand keep its current code. A container's filesystem is otherwise ephemeral, so on-disk data is lost between deploys/restarts without a volume. A redeploy keeps the existing disk automatically (omit--volume), and a larger--volumethan the current disk grows it in place with its data preserved; sizes never shrink — a smaller--volumeis refused; to recreate a disk smaller, drop it first with--no-volume --allow-data-wipe(which destroys its data), then deploy again with the smaller size (fresh empty disk). Note this needs a placement that supports volumes and may require platform enablement — if--volumereturnscontainer_capability_unavailable, the disk option is off for this account; fall back to managed SQL below rather than guessing. - Managed SQL (
--with-db). Always available, maintenance-free, and the right choice for server-runtime apps with no disk of their own or when a volume is not enabled. For a container app that already has its own database, switching to managed SQL means rewriting its data access against the SQL bridge below — propose that as a deliberate change and get the user's approval first; never silently refactor an app's storage just to deploy it.
Managed SQL
- Server-runtime apps deployed with
--with-dbreceive aDBbinding. Use prepared statements:env.DB.prepare("SELECT ...").bind(...).all(). - Container apps deployed with
--with-dbreceiveNINEPM_SQL_URLandNINEPM_DB_BINDINGS, plusNINEPM_SQL_TOKENwhere the bridge requires one. SendPOSTrequests toNINEPM_SQL_URLwith JSON shaped like{ "binding": "DB", "sql": "SELECT 1", "params": [] }. WhenNINEPM_SQL_TOKENis set, also sendAuthorization: Bearer <that token>— a bridge that expects it refuses every request without it (401 {"error":"unauthorized"}), so omitting it looks like a broken database rather than a missing header. Read the URL and the token from the environment on each call rather than hardcoding either: the URL differs between placements, and the token is reissued on every deploy. - Keep SQL compatible with SQLite. For local dev, use a local SQLite/Postgres adapter behind a tiny repository layer and switch to the 9pm.ai binding or SQL bridge only in the deployed runtime.
- Initialize schema idempotently with
CREATE TABLE IF NOT EXISTSunless the app already has a migration system. - The managed database belongs to the app, not to a deploy: every redeploy of the same slug keeps it and its rows, including a redeploy that omits
--with-db. Only9pm deleteremoves it.
End-User Accounts
When an app needs per-user accounts — sign-in, "my stuff" views, data scoped to whoever is logged in — 9pm provides this as a managed feature. Reach for it before wiring up an external identity provider: it removes the same login/session/identity plumbing 9pm already removes for hosting and storage. Offer it whenever the user describes per-user data or a login.
Enable it at deploy time:
9pm deploy ./app --name "App Name" --slug app-name --with-auth
--with-auth implies --with-db (per-user data needs a managed database) and is one-way — enabling it is enable-only. Once enabled it stays on for every later deploy of the app, with or without the flag; it can't be turned off. v1 sign-in is email one-time codes only; there is no managed third-party identity provider yet (for that, see the external-provider fallback in the Deploy Workflow).
What you get:
- 9pm hosts the entire sign-in flow at
/__9pm/authon the app's own domain — the app does not build login UI or handle session secrets. A hosted login page is served at/__9pm/auth, and the flow is also available as endpoints the app's own UI can call:POST /__9pm/auth/request-code({ email }, emails a code),POST /__9pm/auth/verify({ email, code }, starts a session),GET /__9pm/auth/me(returns the current user or null), andPOST /__9pm/auth/logout. - Send people back to where they were. When a signed-out visitor hits a page that needs sign-in, link or redirect to the hosted page with a
returnparameter:`/__9pm/auth?return=${encodeURIComponent(location.pathname + location.search)}`(server-side, build the same value from the request URL's path and query). Always URL-encode it. After a successful sign-in the page goes there, query string included (and a#fragment, if you addlocation.hashto the value). Withoutreturnthe user lands on/— on an app whose homepage does not link to that page, that strands them. Only a path on the app's own domain is accepted: a full URL, another domain, or a/__9pm/...path falls back to/. - The app reads the current user from one place. Worker-runtime apps read
ctx.useron the request context — the third argument of the handler,fetch(request, env, ctx)—{ id, email, isOwner }when signed in,nullwhen signed out. Container apps read theX-9pm-Userrequest header (JSON{ id, email, isOwner }, absent when signed out); 9pm verifies and sets this header, so trust it and ignore any client-supplied value of the same name.GET /__9pm/auth/mereturns the same shape. - The user
idis stable for the same person on that app, so use it as the per-user foreign key in the app's own managed tables (see Managed SQL) — e.g.WHERE user_id = ?. 9pm owns the identity; the app owns its data. isOwneridentifies the app's owner —truewhen the signed-in user is the person who deployed the app, with no configuration. When the user asks for an owner-only or admin area, gate those routes onctx.user.isOwnerserver-side (never in client code, never on a client-supplied value). It marks exactly one person; additional admins or roles are the app's own data.
Composing with access modes (see Access): managed accounts work on a public app and on private_otp / private_idp apps. On a private app the visitor is already identified by the access gate, so ctx.user is populated with no second login and the email-code endpoints aren't used. It cannot be enabled on a private_service app — there is no human behind a service token.
Sign-in behavior and edge cases — relevant when the app builds its own UI against the endpoints (the hosted page at /__9pm/auth already handles all of these):
- Codes expire after 10 minutes and are single-use; requesting a new code invalidates the previous one. An expired or wrong code makes
verifyreturn400with codeinvalid_codeand a user-ready message ("The code is incorrect or has expired.") — offer a "request a new code" action on this error. - Resend cooldown: one code per address per 60 seconds. Inside the window
request-codereturns429(rate_limited). The earlier code is still valid, so render this as "check your inbox — your code is on the way", not as a failure. Broader limits also apply (per-address hourly, per-visitor, and per-app caps; all429). - Five wrong attempts invalidate the code — the error stays the same generic
invalid_codeby design. request-codealways returns202 {"ok":true}, whether or not the address is known or the email could be delivered — accounts can't be enumerated through this endpoint, so don't treat a 202 as proof of delivery. If someone mistypes a stranger's address, the recipient's email says it can be safely ignored; an account is only created on the first successfulverify.- Surface the
error.messagefrom400/429responses directly — they're written for end users.
Native and mobile clients
A native client (an iOS or Android app, a desktop app, a CLI) has no browser cookie jar, so it uses a bearer transport for the same session credential. Same account, same ctx.user / X-9pm-User, no backend changes: the app's server code reads the current user from the one place it already does, whether the request came from a browser or a phone.
- Sign in exactly as above —
POST /__9pm/auth/request-code, thenPOST /__9pm/auth/verify— but send the headerX-9pm-Session-Transport: beareron the verify call. The response is then{ user, token, expiresAt }with noSet-Cookie. Storetokenin the platform's secure storage (Keychain on iOS, Keystore on Android), never in plain preferences or logs. - Authenticate every later request with
Authorization: Bearer <token>.GET /__9pm/auth/me, the app's own routes andPOST /__9pm/email/inviteall accept it. The header is verified and removed before it reaches the app's code — read the user fromctx.user/X-9pm-User, never by parsing the header yourself. Values inAuthorizationthat are not a 9pm session credential are left untouched, so an app can keep its own API-key scheme alongside. - Browser apps must not opt in. Keep using the cookie path (do not send the transport header): a credential that page scripts can hold loses the protection the
HttpOnlycookie gives it. The header is for clients that cannot use cookies. - Sign-out means deleting the stored token. Sessions are stateless and run to
expiresAt(7 days);POST /__9pm/auth/logoutis harmless from a native client but revokes nothing. Say so honestly in the app's UI, and re-run the code flow when the token expires. - If both a cookie and a bearer are present (an embedded web view can do this), the bearer decides — live or expired — and the cookie is ignored for that request.
- Private access modes are not supported for native clients: on any
private_*app the access gate identifies the visitor through a browser session, and this transport is not consulted for identity (a 9pm session token inAuthorizationis still removed before it reaches the app). A native companion needs apublicapp with managed accounts. - Apps deployed before this transport existed must be redeployed once to gain it; the bearer path lives in the code 9pm wraps around each app at deploy time.
User files
When signed-in users need to keep their own files — photos, documents, avatars — managed accounts include per-user file storage, so the app never provisions a bucket, signs an upload URL, or holds storage credentials. It comes with --with-auth; there is no separate flag. Never store binaries in the managed database, and never bring a bucket — use this. Files belong to the app, and the app decides which user may see which file.
Worker-runtime apps get a ctx.files client. It is null when the app has no managed accounts or is a private/access-gated app (there, files ride the access session), so feature-test it. Every call throws on a signed-out request before doing any work, so gate on ctx.user first.
Uploading is a three-step grant, so the bytes go straight from the client to storage, never through the app:
- Ask for a grant.
const { grant } = await ctx.files.createUploadGrant({ path, contentType, sizeBytes }).pathis the app's choice of key — scope it per user, e.g.`${ctx.user.id}/avatar.jpg`.sizeBytesis the file's exact size;contentTypemust be in the allowlist below.grantis{ fileId, url, method, headers, expiresAt }and is good for 5 minutes. Completing a second upload at apaththe same user already has replaces the old file — the previous one is deleted. That is what you want for a stable key likeavatar.jpg; use a uniquepath(a random id, a timestamp) when every file must be kept. - Upload the bytes. The client
PUTs the file togrant.urlwithgrant.methodandgrant.headersunchanged — the headers carry the exactContent-Length, and a body of any other size is refused. From a browser this is a cross-originPUT; nothing else about it is special. - Finalize.
await ctx.files.complete(grant.fileId). A file stays pending and unreadable until this runs.
Then:
- Read:
const { url } = await ctx.files.createReadGrant(fileId)— a short-lived link on the files domain that serves the bytes withnosniffand a sandbox policy (images inline, everything else as a download). Put it in an<img src>or hand it to the client. - List:
const { files, nextCursor } = await ctx.files.list({ prefix })— the raw list is app-wide, so scope it withprefix(e.g. the user's id). It returns at most 200 files at a time; ifnextCursoris non-null there are more, so page withlist({ prefix, cursor: nextCursor })until it comes back null, or a user with many files sees only their newest. - Delete:
await ctx.files.remove(fileId).
createReadGrantandremovetake a rawfileIdscoped only to the app, not to the current user — the platform does not check that the file belongs to whoever is signed in. (completeis the exception: only the uploader can finalize their own pending upload.) So before reading or deleting a client-supplied id, verify server-side that the file is this user's — e.g. confirm itspathis under the user's own prefix, or that alist({ prefix })for that user contains it. Skipping the check lets a signed-in user read or permanently delete another user's file by guessing an id. "Signed in" is not authorization — the ownership check is the app's job, exactly as with the managed database.
Failures throw a NinepmFilesError carrying a code, a user-ready message, and an HTTP status — not_signed_in, invalid_content_type, invalid_size, file_too_large, file_storage_quota_exceeded (the owner's account is full), app_file_ceiling_exceeded (this app hit its own limit). Surface the message.
- Allowed content types:
image/jpeg,image/png,image/webp,image/gif,image/heic,application/pdf,text/plain,application/octet-stream. Size cap: 25 MiB per file (about 26 MB). - Container apps don't get
ctx.files; they call the same operations over the bridge atNINEPM_FILES_URL—POST $NINEPM_FILES_URL/<op>where<op>isgrants/upload,complete,grants/read,list, ordelete, with the same JSON bodies. 9pm stamps every request it forwards into the container with anX-9pm-Files-Ticketheader for the signed-in user, and the container must copy that header from the incoming request onto its bridgePOST— without it the bridge answers401 files_ticket_invalid. The ticket is per-request and short-lived, so read it off the request you are handling and never cache it; the container never holds a long-lived credential. - Apps deployed before this existed must be redeployed once to gain it — the same one-time redeploy the native transport needs, because the file client lives in the code 9pm wraps around each app.
Multi-user patterns
Every multi-user shape below is the same move: tables in the app's own managed database (see Managed SQL), keyed by the stable ctx.user.id, on top of the identity 9pm injects. Two rules apply to all of them: every authorization check runs server-side in the app, and identity comes only from ctx.user / X-9pm-User — never from a client-supplied id, email, or role value. On public apps the 9pm dashboard already shows the owner how many people have signed up, so don't build a user-count page unless asked; private apps have no dashboard count, so there it's a legitimate feature to build.
Owner admin area — "an admin page only I can see":
- Gate owner-only routes on
ctx.user.isOwner, server-side. No configuration: the owner signs in with their email like anyone else and is recognized. isOwnermarks exactly one person — the account that deployed the app. For additional admins, keep an app-side roles table and let anisOwnersession grant the role.- Removing a user means deleting or hiding their rows in the app's own tables. It does not revoke their sign-in — an existing session stays valid until it expires (up to 7 days). If the user asks for banning or instant lockout, say this plainly, and gate the app's routes on a
bannedflag in the app's tables instead: a banned person may still be signed in, but sees nothing.
Groups that users create and manage — travel groups, shared projects, clubs:
- Two tables in the app's database: groups (id, name, owner user id) and memberships (group id, email, nullable user id, role).
- Every group-scoped read or write verifies membership of that specific group: resolve the group id from the request, then check that
ctx.user.idis the group's owner or an active member before touching its rows. "Signed in" alone is not authorization — skipping this check exposes every group to every account. - Members can be added by email before they have ever signed in: store the membership against the trimmed, lowercased email, normalize the same way when looking up pending memberships by
ctx.user.email, then link the stable user id on first sign-in. (On private apps the email arrives with its original casing; only the id is normalized for you — the same person always resolves to the same id.) - Invites reach people two ways. By default the invited person signs in and finds the group waiting (add-by-email, or a shareable link). If the app is deployed with
--with-email, a signed-in user can also send a managed invitation email —POST /__9pm/email/invitewith{ to, mode: "invited" | "added", inviterName, thingName }from the app's own UI. 9pm sends it, rate-limits it, and handles opt-outs; the app never touches an email provider. Requires managed accounts, works on public apps only, and is budgeted (roughly a couple dozen invites per person per day). Names can't contain links. Do not build your own email sending for invites.202means accepted, not delivered. By design the endpoint returns the same202whether the email was sent, silently dropped because the recipient opted out, or failed at the provider — this is deliberate (opt-outs and delivery must not be probeable). A429 rate_limitedmeans the budget/cooldown was hit. So: never tell the user "email sent" — say "invite sent if that address accepts email", and always keep a shareable link available (both after a429and after a202), since a202is not proof anything arrived.
Two kinds of users — teachers/students, hosts/guests:
- A role column on the app's own profile table, plus relationship tables (classes, enrollment). Gate each role's routes server-side by reading the role from the app's tables.
- How someone becomes the higher role is the app's policy, not the platform's — owner approval from the admin area, an invite code, an open toggle. Ask the user which they want; don't invent one.
- Scope what each role sees by the relationship tables: the lower role only what they're enrolled in, the higher role only their own roster.
Public app with a staff area — guests use the app without signing in, staff sign in to a back office (a restaurant's orders screen, a shop's stock page). It is the two recipes above combined on a public app:
- Deploy
--with-authand keep the apppublic. Access modes (see Access) protect the app's whole address, never one part of it, so the staff area is enforced by the app's own code. - A staff table in the app's managed database, keyed by the trimmed, lowercased email so the owner can add someone before they have ever signed in (link
ctx.user.idon first sign-in, as in Groups). The owner manages it through routes gated onctx.user.isOwner, and counts as staff without a row. - Every staff route checks that table server-side, on every request:
401whenctx.useris null,403when the signed-in person is neither the owner nor on the list. Never cache the verdict in a cookie, client state or local storage. Because the check reads the table each time, removing someone takes effect on their next request: their sign-in stays valid until it expires (see Owner admin area), but it no longer opens anything. - Guests never receive staff data. Staff data is served only by those checked routes, never placed in the deploy folder's static files, which are public (see How a
--runtime workerapp shares requests with its static files). A staff page's HTML may be public; what it loads must not be. - Send signed-out staff to
`/__9pm/auth?return=${encodeURIComponent(location.pathname + location.search)}`so sign-in brings them back to the staff page rather than to/(see Send people back to where they were under End-User Accounts).
Share-by-link Apps
When one URL equals one private workspace (a shared board, doc, or the todo demo below), the URL itself is the access secret:
- Mint a
workspace_idper workspace withcrypto.randomUUID(). Reject malformed:workspacepath values. - Scope every database read/write by
workspace_idso workspaces stay isolated. - Set
Referrer-Policy: no-referrerandCache-Control: private, no-storeon every workspace response. - Do not load third-party assets from the SPA — they can leak the workspace path through the
Refererheader. - Tell the user explicitly that the URL is the access secret before deploying.
- When a workspace needs real identities — named members, an owner, roles — that's the Groups pattern under End-User Accounts: the same isolation, with accounts instead of a secret URL.
Deploy Workflow
- Confirm auth (
9pm whoami) and inventory (9pm apps) so you don't collide with an existing slug. - Inspect the app: stack, package manager, build command, output directory, runtime shape, and persistence needs.
- If changes are required, explain the smallest compatible change and wait for explicit approval.
- Build and test with the app's existing commands when available.
- Run a local preflight:
9pm deploy ./dist --check
- Deploy static build output:
9pm deploy ./dist --name "App Name" --slug app-name
Pass an optional --description "<short note>" to label what this deploy changed (e.g. --description "Fix login redirect"). It appears next to the version in the dashboard and helps the user tell deploys apart later. Keep it short (a sentence; 200 characters max) and prefer including one on every deploy. It is optional — omit it and the deploy just shows its version number. Descriptions can be edited from the dashboard afterward. Works with any runtime:
9pm deploy ./dist --name "App Name" --slug app-name --description "Initial deploy"
- For app-owned server code, deploy a runtime entry, and name the directory of public files with
--assets(or pass--no-assetsif there are none) so server files stay private:
9pm deploy ./app --check --runtime worker --entry src/index.js --assets public
9pm deploy ./app --name "App Name" --slug app-name --runtime worker --entry src/index.js --assets public
- For Docker/container apps, confirm Docker is installed and the app reads the
PORTenvironment variable for its listen port (never a hardcoded one — see the container-runtime notes above), then:
9pm deploy ./app --check --runtime container --port 8080
9pm deploy ./app --name "App Name" --slug app-name --runtime container --port 8080 --health-path /health --region auto
For persistence, see the Persistence section: prefer --volume <gb> (mounts at /data) to keep an app's existing local database where it is enabled, and fall back to --with-db (managed SQL — the app reads NINEPM_SQL_URL and NINEPM_DB_BINDINGS for the bridge) when a volume is unavailable or the app has no disk of its own. If deployment is blocked by image size, report whether it hit the per-app image limit, account retained-image quota, or platform capacity. Report queued, publishing, active, or failed exactly as printed by the CLI.
Container readiness checklist: Dockerfile present, HTTP server binds to 0.0.0.0 and reads its listen port from the PORT environment variable (--port selects the public port; the platform may hand the app a different one through PORT), optional health endpoint returns 2xx, persistent state uses an attached --volume (writing to /data) or managed SQL, and secrets are not baked into the image — set them with 9pm env instead. A failing health endpoint should answer 500 (or any error other than 502/503): the platform reserves 502/503 for its own "machine not up" signal, so on placements that stop idle apps a health check answering those reads as the machine waking, not as a verdict about the app.
If a container app fails to come up or you need to investigate it after the fact, retrieve its most recent recorded logs:
9pm logs <app> # recent container logs + the failure reason, if any
9pm logs <app> --lines 50 # last N lines
9pm logs <app> --json # machine-readable
9pm logs reports the latest container deployment's status, health, the platform-neutral failure reason, and the captured build/startup output when a deploy failed to come up. For an app with a persistent disk it also reports how full that disk is — check it before assuming a stateful app's startup failure is a full disk, since that guess is otherwise unverifiable from inside the app. It is read-only and does not affect deploys. A deployment reported active that only errors on live requests may have no captured logs yet; in that case the command says so rather than implying the app is healthy.
- Use managed files for generated artifacts or handoff files. Never request storage credentials:
9pm files put ./report.pdf --project app-name
9pm files share <file-id> --expires 24h
9pm files share <file-id> --project app-name --public
Shares are owner-only by default: the link opens only for the signed-in owner. Add
--public when the recipient has no account and the contents are safe to publish -
that link needs no sign-in, so anyone who obtains the URL can download the file until
it expires. Never pass --public for anything containing personal data, credentials or
a database export.
- Report both the live URL and the dashboard URL printed by the CLI.
For per-user accounts, prefer 9pm's managed end-user auth (--with-auth) — see End-User Accounts — so the user doesn't have to run their own identity stack. Only when the user specifically wants their own external provider does the following apply.
If the app uses an external auth/identity provider (Supabase, Auth0, Clerk, Firebase, ...), its login, password-reset, and magic-link redirects are configured in that provider's dashboard, not in the app's env. After deploying to the 9pm URL, tell the user to update the provider's Site URL and allowed redirect URLs to the exact live URL the deploy printed (step 10) — copy it, do not reconstruct it from the slug. The hostname carries a per-app suffix (<slug>-<suffix>.<domain>), so a URL built from the slug alone is a hostname no app is served on: it resolves and then fails, which looks like the platform being down rather than a wrong redirect, and every login and magic link breaks that way. Getting this wrong is also invisible until a real user tries to sign in. The agent cannot change the provider's dashboard, so flag this as a required manual step alongside setting any *_SITE_URL env the app reads.
Will a printed link or QR code keep working? Tell the user before they print or hand out an app's URL. The suffix in <slug>-<suffix>.<domain> comes from the app's internal id, not from the slug, so:
- Redeploying under the same slug keeps the address.
- Deleting the app and creating it again under the same slug gives it a new address, because the new app gets a new id. Everything printed with the old one stops working, so do not delete and re-create an app whose address is already out in the world.
- There is no rename: a different address means a different app.
- A custom domain the user owns gives the app an address of its own that does not depend on the app's id. See Custom Domains for how to add one; not every plan includes them.
Making a QR code. Run 9pm qr <app> rather than generating one yourself: it saves a printable SVG (--format png for an image, --out <file> to name it) for the address the app actually serves on, its custom domain when one is live and its 9pm address otherwise. Relay the minimum print width it prints, and any note it adds: a code for a custom domain follows that domain, and scanning a private app's code does not let anyone in. --terminal also draws the code in the terminal. Pass --owner <organisation> for an organisation's app. If it answers that the server can't make QR codes yet, the platform has not caught up with the CLI; don't fall back to building a URL from the slug.
Pages that do not exist. A static site or --entry worker app answers a path with no file in one of two ways, and every deploy prints which as a Missing pages: line:
- 404 (
--no-spa, the default for new apps): the path returns status 404, with the deploy's own404.htmlwhen it has one./still servesindex.html, and links without the extension work:/menuservesmenu.html, or redirects to/menu/when there is amenu/index.htmlinstead, and/menu/servesmenu/index.html. Right for a multi-page site: a link to a page that was removed, or a mistyped one from a printed menu, says "not found" instead of quietly showing the homepage. Add a404.htmlthat links back home. - Homepage (
--spa): every path with no file servesindex.htmlwith status 200, so a single-page app that routes in the browser loads on a deep link or a refresh. Every unmatched path gets it, including dotted ones like/users/jane.smithand missing files like/missing.json- the app's own router has to show its not-found view.
Pass --spa for a single-page app with client-side routing (React Router, Vue Router, and similar); a site whose pages are separate .html files does not need it, even if it has only one page. The app remembers the choice: a deploy without either flag keeps it, and a deploy that fails to publish does not change it. Apps created before this choice existed keep serving the homepage (the deploy line says "kept from before") until a deploy passes one of the flags. Does not apply to --bundle or container apps, which answer every path themselves.
Going Back to an Earlier Version
If a deploy broke a static or worker app, the owner can put an earlier version back from the dashboard without an agent: the app's page → Version history → Restore this version on the version they want, then confirm. Offer this when the user asks to undo or roll back a deploy, and point them there rather than redeploying old code by hand.
- A restore publishes a new version made from the chosen version's stored code and files, with nothing uploaded again. It becomes the serving version and the history shows it as "Restore of vN"; the newer versions stay in the history, so undoing a restore is another restore.
- It restores code and files only. The app's database and its data, environment variables and secrets, access mode, sign-in, custom domains, user files, address, which files are public, and the missing-pages choice all stay as they are now. A file made private since that version stays private.
- If a newer version changed the app's database tables, the older code may not work with them. A restore does not undo database changes or recover data.
- Not offered for the version already serving, for one that never went live, for container apps (not supported yet), or for a version of a different kind than the app runs as now (a static site onto a worker app, or the other way round, or a bundled worker onto an entry-file worker). A version whose files are no longer stored is refused and the app keeps serving what it serves now.
Previews and Branches
9pm deploys the files at the path you give it under the slug you pass. It has no notion of git branches or worktrees — deploying does not "pick up the current branch," it uploads whatever is in the deploy directory. Two consequences to handle so a deploy ships the code the user means:
- Deploy the checkout the user is editing. When the user is iterating on a branch or in a git worktree and asks to deploy "this", resolve the deploy path to that working copy — not a path carried over from an earlier deploy, and not the repo's default checkout. Re-running a previous
9pm deploycommand verbatim is the common way an agent redeploys the old code while the user expected the worktree changes; confirm the path (and your working directory) before running. - Same slug replaces the live app; a new slug is a separate preview. Deploying with an existing slug overwrites that app's live deployment. To preview work-in-progress without disturbing the live app, deploy the branch/worktree under a distinct slug so it gets its own URL:
9pm deploy ./dist --name "App Name (preview)" --slug app-name-preview
When the intent is to preview unfinished work, default to a separate preview slug and confirm with the user whether they want that or to replace the live app at its existing slug. Delete previews you no longer need with 9pm delete <slug> --confirm <slug> (the --confirm flag is required and must match the slug).
- On the Free plan there is no separate preview. On Free, the account's one app slot can't also hold a separate preview app. Deploying to the existing app updates its live version. Before deploying a risky change on Free, tell the user that the deploy updates the live app. Don't offer rehearsal inside the app as a way to try the change: a practice mode rehearses the current app's workflow, and testing a changed version separately requires another app slot. If they want that, suggest a paid plan with more apps (Hobby allows 3) for a separate preview app.
Custom Domains
An app can also answer on a domain the user owns, such as app.example.com. When the user asks for "my own domain", connect it with 9pm domains (CLI 0.72.0 or later; if the command is unknown, update the CLI first):
9pm domains add <app> <hostname> # prints the DNS records to publish, and who hosts the domain's DNS
9pm domains status <app> <hostname> # what DNS shows now for each record; --json for {state, records[...]}
9pm domains wait <app> <hostname> # polls until live (--timeout 30m, --interval 20s)
Add --owner <organisation> for an organisation's app. Show the user the records from add verbatim; they publish them at their registrar or DNS host, not on 9pm. Then run wait and act on its exit code:
0: live. The domain serves the app.10: timed out with something for the user to do: a record is missing or could not be checked, a CAA record blocks the certificate (CLI 0.74.0 or later;statusprints what to change and where), or the domain is ready to switch (see below). Have the user compare what they published with9pm domains status, thenwaitagain. From CLI 0.89.0waitalso stops at once with10when the domain points elsewhere (see below).11: timed out with every record found and nothing for the user to do. The platform is still finishing;waitagain, nothing needs changing.20: failed or expired. The message says why.9pm domains check <app> <hostname>re-reads it; if the certificate failed,9pm domains restart <app> <hostname>starts validation over (the record values may change, so re-publish what it prints; never restart a domain that is only pending, it does not speed anything up); an expired domain is added again withadd.1: a usage, sign-in or permission error.add,check,restart,removeandmoveneed permission to configure the app;statusandwaitonly read.
Rules for the records, whichever way they are added:
- Plan: not on the Free plan. If
addor the dashboard says custom domains aren't included, that is the plan, not a fault. Service-token apps (private_service) cannot have one. - A subdomain (
app.example.com,www.example.com) gets a CNAME to the value shown. - A root domain (
example.com) cannot take a CNAME;9pm domainsshows it asALIAS / ANAME(or, for a DNS host it recognises, that host's own name for it): it needs the provider's ALIAS, ANAME or CNAME-flattening record. Some providers (for example GoDaddy, or Route 53 for a target outside AWS) have no record that works, so usewww.or another subdomain there. A correct ALIAS answers with addresses rather than the target name, so its row follows the platform's routing check. - Enter the host part exactly as shown (
app,_acme-challenge.app). Most providers append the domain themselves, so typing the full name doubles it (app.example.com.example.com). - The two
_acme-challengeTXT values are two separate TXT records with the same name. Add each as its own record and leave any existing TXT records alone. - Never delete MX records, or other TXT records at the root, to make room: they carry the domain's email and verification.
- Cached answers:
statusreads two public resolvers, and both are caches. When they disagree (one shows the value, the other does not), neither proves which is current: a recent change is usually still spreading. Have the user confirm the record at their DNS host, then check again; don't tell them it is done, and don't have them change it. A just-changed record can also show its old value for a few minutes (the row says how long), so check again before changing it twice. From CLI 0.92.0, for DNS hosted at GoDaddy, Namecheap, Google Cloud DNS or NS1, a row can also say what the DNS host itself publishes ("your DNS host publishes …",authoritativein--json). If the host already publishes the value shown, the record is done at the host and the public caches have not caught up yet: tell the user to wait and check again, not to change it. "Unable to verify directly" means only that this extra read failed; it never means the record is missing. The row's state is still the public resolvers' verdict, so don't call the domain ready on the host's answer alone. When a provider's wildcard record (such as*.example.com) answers for a name, the row says to add a record at that name; the wildcard can stay, and a TXT value is always added beside the existing ones. - Timing: once the records are in, the certificate has followed within a few minutes in practice, but DNS can take longer to propagate, so don't promise a time. With every record found,
statusnames the platform's next automatic check; wait for it rather than restarting. Userestartonly when the domain stays stuck after the records are confirmed correct. 9pm domains remove <app> <hostname> --confirm <hostname>disconnects a domain; it stops serving within seconds. Relay the records it prints as deletable only with their condition: each may be deleted only if its value is still the one shown; a name that now holds another value belongs to something else. If it prints none, don't suggest deleting any. If it says the removal is still finishing, tell the user it finishes on its own and that the domain has stopped serving from 9pm once9pm domains list <app>no longer shows it. Until then, tell the user to leave its records in place and don't name any. Once it has finished,9pm domains removed(CLI 0.84.0 or later; add--owner <organisation>for an organisation's domain) lists the records it freed, under the same condition; relay only what it prints, and if it lists none for that domain, says it couldn't check the domain (run the--hostnamecommand it prints a little later), or says the server can't list them, don't suggest deleting any.9pm deleteprints the same per-domain list for an app's domains, and points at9pm domains removedfor one still finishing.- Removing releases the domain: 9pm keeps nothing for it, and after removal, visitors may see a TLS or routing error while its DNS still points at 9pm. Say "may", never one exact error, and don't promise a switch without errors or downtime. For what to delete, relay only the cleanup list
removeordeleteprints, each record only if its value still matches; never tell the user to delete "the records they added". From CLI 0.86.0,removeanddeleteprint this warning. - Moving a domain to 9pm from another host: have the user publish the TXT records first and leave the routing record (CNAME, or ALIAS at the root) on the old host until
statussays the certificate is ready; then change that one record to the value shown. Until it changes, visitors still reach the old host. From CLI 0.89.0, against a server that reports it, the state then isready_to_switch("ready to switch"), naming the record and the value to change it to; it becomesliveonly once public DNS shows the record leading to 9pm. Don't tell the user the domain is live before that. A root domain (ALIAS) and some setups (a resolver that disagrees, a record that answers with addresses) cannot be told apart from outside and read as live; for those, have the user confirm the record at their DNS host. - Moving a domain off 9pm to another host: have the user set the domain up at the new host, point its DNS there, wait for the DNS TTL to pass, and only then remove the domain here. While caches update, some visitors may still reach 9pm or briefly see an error from the new host. Once DNS points away,
statussays the domain points elsewhere (points_elsewhere, CLI 0.89.0 or later, against a server that reports it), withremoveas the next step: that is expected, so remove it rather than escalating. If the user didn't mean to move it, the same text says which record to point back; pointing it back is not enough, because the provider keeps a moved domain moved until validation restarts, so then run9pm domains restart <app> <hostname>(dashboard: Start over), publish any new TXT values it prints, andwait. Once the record points back,statusreads failed with reasonmoved_needs_restartand next stepstart_over("points back at the platform, but the provider still has it disconnected"): that is the cue to restart, not to contact support. Re-check alone does not recover it, and the provider deletes a domain left moved for 7 days. This recovery has not been rehearsed live; if it doesn't go live, contact support. An older CLI or server may instead call it failed and suggest contacting support; treat that the same way after a deliberate move. Don't promise a switch without errors or downtime. For any other kind of move, have the user contact support first instead of improvising an order. From CLI 0.87.0,removeanddeleteprint this order with a link to the custom-domain guide's "Moving a domain off 9pm" section. - Once a domain is live,
9pm domains cleanup <app> <hostname>(CLI 0.83.0 or later) lists the setup records 9pm no longer needs (an earlier_acme-challengevalue, or the ownership TXT), checked against the certificate provider when it runs. Relay them with the same condition: each may be deleted only while it still has the value shown, and a different value at that name belongs to something else. Every other record for the domain stays. Only suggest deleting what it prints: never work out leftovers from what DNS shows or fromstatus, and if it lists none, fails, or says the domain isn't live, don't suggest deleting any.removeincludes these values in its own list. After arestart, the values it says no longer count follow the same rule. 9pm domains move <hostname> --to <other-app>moves a domain to another app of the same account, keeping its certificate and the records already published. If it is refused, follow the reason it prints (another account, the destination's cap, a removal still finishing, or a domain with nothing to keep, which is removed and added again).
Without the CLI (or if the user prefers), the same flow is in the dashboard: the app's own page → Custom domains → Add a domain, with Re-check, and Start over shown only when the certificate failed. The account-wide Domains page only lists the domains already added; it has no add form.
First Todo App
The todo demo is optional. Skip it entirely for established accounts (existing credentials, 9pm login, or prior apps in 9pm apps) and whenever you are working inside an existing repository or project — inspect and deploy that app instead. For a fresh signup in an empty workspace, ask the user whether they want the demo and build it only on an explicit yes. When building it, apply the share-by-link pattern above, ask for the target directory, and confirm the file list before writing.
Build spec for the demo:
- Runtime shape:
--runtime worker --with-db; the entry receivesenv.DB. Initialize schema lazily on the first request. - Routing: on
GET /with no workspace, mint aworkspace_idand redirect to/w/<id>; scope every query byworkspace_id; validate:workspaceand reject malformed values (per the share-by-link pattern above). - Schema:
CREATE TABLE IF NOT EXISTS todos (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
text TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS todos_workspace_created ON todos(workspace_id, created_at DESC);
- API:
GET/POST/w/:workspace/api/todos,PATCH/DELETE/w/:workspace/api/todos/:id { text?, done? }. - Stack: Vite + React + TypeScript with vite-plugin-singlefile so the SPA collapses to one HTML file; bundle the worker entry as ESM exporting a default
fetch-compatible handler (export { app as default }is supported). - Layout: the built SPA at
deploy/public/index.html, the bundled entry atdeploy/src/index.js. - Deploy:
9pm deploy ./deploy --check --runtime worker --entry src/index.js --assets public
9pm deploy ./deploy --name "Todo" --slug todo --runtime worker --entry src/index.js --assets public --with-db
- Polish bar: intentional typography, sensible spacing, dark mode via
prefers-color-scheme, keyboard support, aria-labels on icon buttons, inline SVG icons only, no third-party assets. Report the live URL and dashboard URL the CLI prints.
Access
Access controls who may reach the app at all (the perimeter) — distinct from end-user accounts, which identify who the visitor is once inside (see End-User Accounts). The two compose: an app can be private and also have per-user accounts.
Apps are public by default. Manage access only when requested:
9pm access show app-name
9pm access otp app-name --switch-mode
9pm access idp app-name --provider google --switch-mode
9pm access allow email app-name [email protected]
9pm access allow domain app-name example.com
9pm access revoke email app-name [email protected]
9pm access service create app-name --name backend --switch-mode
9pm access public app-name --switch-mode
Changing the access mode decides who can reach the app, so the platform refuses it unless you pass --switch-mode. Without the flag nothing changes, and the refusal says what the change would do: who loses access and what happens to the app's custom domains. Moving into service-token mode (service create on an app that is not already in it) locks every person out and removes its custom domains; moving back out sets those domains up again with new DNS records the user must publish. Show the user that consequence and get their go-ahead before re-running with --switch-mode. Creating a further service token on an app already in service-token mode needs no flag.
Environment Variables and Secrets
Per-app configuration is encrypted at rest and applies on the next deploy. Values are secret (write-only) by default; pass --var for a readable config value:
9pm env set <app> KEY=value [KEY2=value2 ...] # secret by default (write-only)
9pm env set <app> KEY=value --var # readable config value
9pm env set <app> --from-file ./.env # KEY=value lines; # comments and quotes ok
9pm env set <app> SECRET_NAME --stdin # value read verbatim from stdin (never the chat)
9pm env request <app> SECRET_NAME # prints a one-time browser link to enter the value
9pm env ls <app>
9pm env rm <app> <NAME>
Names are UPPER_SNAKE_CASE; PORT and NINEPM_* are platform-reserved. After changing env, redeploy the app to apply.
When an app needs a real secret you don't already hold — a third-party API key, a token — do not ask the user to paste it into this chat (see Authentication: anything pasted here is logged). Instead run 9pm env request <app> SECRET_NAME and give the user the printed link. They open it, sign in as the app owner, and enter the value in the browser; it is sealed and stored, and never enters this conversation. The link works once and expires quickly. Use 9pm env ls <app> to confirm the secret now exists (names only — secret values are never readable back), then redeploy. Pass --var only for a non-secret readable config value.
Files
Store generated artifacts or share files without handling storage credentials:
9pm files list [--project app-name]
9pm files put <local-path> [--path remote/path] [--project app-name]
9pm files get <file-id> [--output ./local] [--project app-name]
9pm files share <file-id> [--expires 24h] [--public] [--project app-name]
9pm files delete <file-id> [--project app-name]
Delete
9pm delete destroys the app, managed database, managed container app, retained image, and project files. Rows are not recoverable. The app's address goes too: an app created again under the same slug gets a new address, so printed links and QR codes stop working. If 9pm delete prints Its address stops serving the app within about a minute., relay that. If it does not (for example an app with a custom domain, or some container apps), do not promise any timing: open the address yourself before telling the user the content is gone. Before running, state this to the user verbatim and get explicit confirmation, even if they already asked for deletion.
9pm delete app-name --confirm app-name
When something fails
Most failures are self-fixable — read the error the CLI prints, then work down this list before escalating:
- Auth / "not signed in" / 401: run
9pm doctor(checks the stored credential and reachability), then9pm whoamito confirm who you are. If the credential is missing or expired,9pm loginagain. - "Unknown command" or behavior that doesn't match this guide: suspect an out-of-date CLI. Run
9pm latest, and upgrade if it's behind (see Versioning). - Deploy refused before any upload (calls to
api.9pm.aiare blocked): you're in a sandbox with restricted egress. Either trustapi.9pm.aior add the 9pm connector (see Sandboxed Environments) — both applied from outside the sandbox, before the session. (npx ninepmitself needs no allowlisting.) - Deploy/build failed: the CLI prints the reason. Common fixes: ensure the build output directory exists and is non-empty; re-run with
9pm deploy <dir> --checkto validate the directory before deploying; for--runtime container, confirm the image builds locally and the app listens on0.0.0.0:$PORT; if a requested capability (always-on, volume, custom sizing) is rejected, it may not be enabled for this account — drop it or escalate. asset_file_too_large: a single static file exceeds the 25 MiB per-file limit (see Supported App Shapes). This is deterministic — do not retry. Split or externally host the oversized asset (usually a large WASM binary), or deploy the app as a container.
If you're still stuck after that, escalate with context. From the terminal:
9pm support --site app-name -m "<what you tried and what happened>"
9pm support -m "<what happened>" # when it isn't about a specific app
This files a tracked request with your account and (with --site) the app's last-deploy status and error attached automatically — you don't need to copy any of that in. A human replies to the operator's account email, not to this conversation.