mcpField
9
9pm

9pm

github.com/tmrw-realityosCloud & DevOps042 installsUpdated 1d ago
Repository

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

  1. 01CLI login and key management guidance
  2. 02List apps and inspect deployment status
  3. 03Pre-deployment checks and deployment guidance
  4. 04Scoped keys and permission boundaries
  5. 05MCP connector guidance for sandbox environments

Use cases

  1. 01Inspect account apps and choose the intended project
  2. 02Set up restricted keys for unattended workers
  3. 03Run project checks before deployment
  4. 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 9pm

After installing, type/9pmin Claude Code to confirm it loaded.

Details

From the project README

9pm.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> --check before 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 with 9pm apps --owner <organisation-slug>; without the flag, everything is the account's own. Organisations are created and their members invited from the dashboard.
  • 9pm deploy runs the project's own build locally — for framework apps that means npm install (and its lifecycle scripts), the framework build, or docker build execute 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:

  1. 9pm login for 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. Running 9pm login again keeps a stored credential that still works instead of creating another; 9pm login --fresh replaces it and revokes the old one.
  2. A brand-new account is created in the browser, never from the terminal. 9pm signup cannot 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, use 9pm login. An invite link cannot be redeemed through 9pm signup; the one-time link in the email is the only way to use it.
  3. User-set environment in their own terminal: export NINEPM_API_KEY=9pm_.... Warn that the value can land in shell history.
  4. NINEPM_API_KEY overrides stored login for CI.
  5. 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 ls and 9pm 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:

  • monitor reads app status and deployment history, on the apps named with --project <app> (repeat the flag).
  • deploy adds deploying, setting environment variables and adding a database, on the --project apps.
  • provision adds 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, and rm <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:

  1. deploy_begin with the app slug and a manifest of the built files (path, size, lowercase-hex sha256, content type). It returns a deployId and presigned upload URLs for the files whose content the platform does not already have - at most 50 per response. When it also returns a nextCursor, call deploy_upload_targets with 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.
  2. PUT each file's bytes to its own URL from inside the sandbox.
  3. deploy_finalize with the deployId. 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; poll get_deployment_status until the app is up.

What it does not cover:

  • Worker-runtime apps. The connector refuses them rather than mis-publishing. Trust api.9pm.ai and 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.io are on the Trusted default; the layer-blob CDN may need adding). The built image is then uploaded to api.9pm.ai (the /v1/container-uploads/<id>/image PUT - 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.json is 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.allowedDomains in .claude/settings.json (wildcards like *.9pm.ai work). 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.html and 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 at 9pm deploy --check and at create time with an asset_file_too_large error 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 deploy in 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-build skips detection; --bundle <dir> deploys a pre-assembled bundle (entry worker.js plus an assets/ directory). Astro/SvelteKit are not auto-built yet — 9pm deploy says so and tells you what to do: run the framework's Worker-runtime adapter build yourself (9pm deploy names the adapter to run and the exact deploy command), then deploy the output with 9pm deploy . --bundle <adapter output dir> (Worker bundle) or 9pm deploy <static output dir> --no-build (static output). Known Next.js limitation: routes that use next/og (ImageResponse) pull in yoga.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 Dockerfile when the app cannot fit the static or server runtime. The CLI builds locally for linux/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 on 0.0.0.0:$PORT and expose an HTTP server — read the PORT environment variable, never hardcode the selected --port: on some placements the platform itself holds the selected port and hands the app a different one through PORT, 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> like 4g) are available only on placements that support them and may need platform enablement — if they are not enabled, the deploy fails fast with a container_capability_unavailable error 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 /data and 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 --volume than the current disk grows it in place with its data preserved; sizes never shrink — a smaller --volume is 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 --volume returns container_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-db receive a DB binding. Use prepared statements: env.DB.prepare("SELECT ...").bind(...).all().
  • Container apps deployed with --with-db receive NINEPM_SQL_URL and NINEPM_DB_BINDINGS, plus NINEPM_SQL_TOKEN where the bridge requires one. Send POST requests to NINEPM_SQL_URL with JSON shaped like { "binding": "DB", "sql": "SELECT 1", "params": [] }. When NINEPM_SQL_TOKEN is set, also send Authorization: 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 EXISTS unless 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. Only 9pm delete removes 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/auth on 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), and POST /__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 return parameter: `/__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 add location.hash to the value). Without return the 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.user on the request context — the third argument of the handler, fetch(request, env, ctx) — { id, email, isOwner } when signed in, null when signed out. Container apps read the X-9pm-User request 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/me returns the same shape.
  • The user id is 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.
  • isOwner identifies the app's owner — true when 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 on ctx.user.isOwner server-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 verify return 400 with code invalid_code and 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-code returns 429 (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; all 429).
  • Five wrong attempts invalidate the code — the error stays the same generic invalid_code by design.
  • request-code always returns 202 {"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 successful verify.
  • Surface the error.message from 400/429 responses 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, then POST /__9pm/auth/verify — but send the header X-9pm-Session-Transport: bearer on the verify call. The response is then { user, token, expiresAt } with no Set-Cookie. Store token in 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 and POST /__9pm/email/invite all accept it. The header is verified and removed before it reaches the app's code — read the user from ctx.user / X-9pm-User, never by parsing the header yourself. Values in Authorization that 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 HttpOnly cookie 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/logout is 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 in Authorization is still removed before it reaches the app). A native companion needs a public app 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:

  1. Ask for a grant. const { grant } = await ctx.files.createUploadGrant({ path, contentType, sizeBytes }). path is the app's choice of key — scope it per user, e.g. `${ctx.user.id}/avatar.jpg`. sizeBytes is the file's exact size; contentType must be in the allowlist below. grant is { fileId, url, method, headers, expiresAt } and is good for 5 minutes. Completing a second upload at a path the same user already has replaces the old file — the previous one is deleted. That is what you want for a stable key like avatar.jpg; use a unique path (a random id, a timestamp) when every file must be kept.
  2. Upload the bytes. The client PUTs the file to grant.url with grant.method and grant.headers unchanged — the headers carry the exact Content-Length, and a body of any other size is refused. From a browser this is a cross-origin PUT; nothing else about it is special.
  3. 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 with nosniff and 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 with prefix (e.g. the user's id). It returns at most 200 files at a time; if nextCursor is non-null there are more, so page with list({ prefix, cursor: nextCursor }) until it comes back null, or a user with many files sees only their newest.
  • Delete: await ctx.files.remove(fileId).

createReadGrant and remove take a raw fileId scoped only to the app, not to the current user — the platform does not check that the file belongs to whoever is signed in. (complete is 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 its path is under the user's own prefix, or that a list({ 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 at NINEPM_FILES_URL — POST $NINEPM_FILES_URL/<op> where <op> is grants/upload, complete, grants/read, list, or delete, with the same JSON bodies. 9pm stamps every request it forwards into the container with an X-9pm-Files-Ticket header for the signed-in user, and the container must copy that header from the incoming request onto its bridge POST — without it the bridge answers 401 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.
  • isOwner marks exactly one person — the account that deployed the app. For additional admins, keep an app-side roles table and let an isOwner session 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 banned flag 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.id is 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/invite with { 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.
    • 202 means accepted, not delivered. By design the endpoint returns the same 202 whether 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). A 429 rate_limited means 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 a 429 and after a 202), since a 202 is 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-auth and keep the app public. 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.id on first sign-in, as in Groups). The owner manages it through routes gated on ctx.user.isOwner, and counts as staff without a row.
  • Every staff route checks that table server-side, on every request: 401 when ctx.user is null, 403 when 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 worker app 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_id per workspace with crypto.randomUUID(). Reject malformed :workspace path values.
  • Scope every database read/write by workspace_id so workspaces stay isolated.
  • Set Referrer-Policy: no-referrer and Cache-Control: private, no-store on every workspace response.
  • Do not load third-party assets from the SPA — they can leak the workspace path through the Referer header.
  • 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

  1. Confirm auth (9pm whoami) and inventory (9pm apps) so you don't collide with an existing slug.
  2. Inspect the app: stack, package manager, build command, output directory, runtime shape, and persistence needs.
  3. If changes are required, explain the smallest compatible change and wait for explicit approval.
  4. Build and test with the app's existing commands when available.
  5. Run a local preflight:
9pm deploy ./dist --check
  1. 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"
  1. For app-owned server code, deploy a runtime entry, and name the directory of public files with --assets (or pass --no-assets if 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
  1. For Docker/container apps, confirm Docker is installed and the app reads the PORT environment 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.

  1. 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.

  1. 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 own 404.html when it has one. / still serves index.html, and links without the extension work: /menu serves menu.html, or redirects to /menu/ when there is a menu/index.html instead, and /menu/ serves menu/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 a 404.html that links back home.
  • Homepage (--spa): every path with no file serves index.html with 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.smith and 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 deploy command 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; status prints what to change and where), or the domain is ready to switch (see below). Have the user compare what they published with 9pm domains status, then wait again. From CLI 0.89.0 wait also stops at once with 10 when 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; wait again, 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 with add.
  • 1: a usage, sign-in or permission error. add, check, restart, remove and move need permission to configure the app; status and wait only read.

Rules for the records, whichever way they are added:

  • Plan: not on the Free plan. If add or 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 domains shows it as ALIAS / 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 use www. 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-challenge TXT 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: status reads 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 …", authoritative in --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, status names the platform's next automatic check; wait for it rather than restarting. Use restart only 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 once 9pm 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 --hostname command it prints a little later), or says the server can't list them, don't suggest deleting any. 9pm delete prints the same per-domain list for an app's domains, and points at 9pm domains removed for 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 remove or delete prints, each record only if its value still matches; never tell the user to delete "the records they added". From CLI 0.86.0, remove and delete print 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 status says 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 is ready_to_switch ("ready to switch"), naming the record and the value to change it to; it becomes live only 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, status says the domain points elsewhere (points_elsewhere, CLI 0.89.0 or later, against a server that reports it), with remove as 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 run 9pm domains restart <app> <hostname> (dashboard: Start over), publish any new TXT values it prints, and wait. Once the record points back, status reads failed with reason moved_needs_restart and next step start_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, remove and delete print 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-challenge value, 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 from status, and if it lists none, fails, or says the domain isn't live, don't suggest deleting any. remove includes these values in its own list. After a restart, 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 receives env.DB. Initialize schema lazily on the first request.
  • Routing: on GET / with no workspace, mint a workspace_id and redirect to /w/<id>; scope every query by workspace_id; validate :workspace and 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 at deploy/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), then 9pm whoami to confirm who you are. If the credential is missing or expired, 9pm login again.
  • "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.ai are blocked): you're in a sandbox with restricted egress. Either trust api.9pm.ai or add the 9pm connector (see Sandboxed Environments) — both applied from outside the sandbox, before the session. (npx ninepm itself 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> --check to validate the directory before deploying; for --runtime container, confirm the image builds locally and the app listens on 0.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.

Comments

Markdown supported