# Hosted projects for agents (/docs/reference/hosted)



People's guides cover the same ground step by step: [Hosted apps](/docs/concepts/hosting) explains how the repository, previews, staging, and the live app fit together; [Configure a hosted app](/docs/build/hosted-app), [Store app data](/docs/build/storage), [Stage and share](/docs/build/staging), and [Build in Playground](/docs/build/playground) hold the details. A project's live app can also serve at a custom domain: see [Custom domains](#custom-domains).

## Source and release workflow [#source-and-release-workflow]

For Domino-hosted repositories, use `main` for the team's accepted source and separate work branches for local agents and Playground. Staging records the successfully deployed commit; a branch name does not select an environment or prove what is running. Existing apps with their own hosting keep their established repository and release workflow.

Follow this procedure before editing or resuming work, and again before shared staging:

1. **Inspect the starting point.** Read the repository's instructions, inspect the working tree and current branch, and fetch the configured remote. Compare the checkout with remote `main` and the current staging commit reported by `domino builds --json`. A Playground checkout or restored backup can be stale. If staged work is missing from `main`, reconcile that divergence before treating `main` as the team baseline; preserve both histories and involve the owner when the intended result is unclear.
2. **Work on a branch.** Start new work from fetched remote `main`. For existing work, preserve uncommitted edits and branch commits before incorporating newer `main` changes. Resolve conflicts explicitly. Never reset away work or force-push a shared branch to make it match another checkout.
3. **Save and preview.** Commit and push your work branch to save source. Use `domino dev` for a running preview. A Playground checkpoint saves its personal branch; it does not integrate that work into `main`. Development currently synchronizes behavior into the shared test environment, so branches do not isolate quest definitions or test data. Coordinate changes that affect other developers' previews.
4. **Integrate for shared staging.** Incorporate the latest `main`, run the app's checks, and verify the requested participant flow. Merge through the repository's review process and push the integrated result to `main`. Respect branch protections. If another developer advances `main` before submission, reconcile and validate the newer result. Stage the exact pushed, integrated SHA with `domino stage --commit <full-sha> --json`; do not replace shared staging directly from an unmerged work branch.
5. **Verify the deployment.** Wait for a successful build, report its commit and returned staging URL, and verify the requested flow there. A failed build leaves the previous staging version active even though `main` may have advanced. Use `domino builds` to distinguish accepted source from the version actually running.

These are agent workflow rules. The current CLI and backend accept arbitrary pushed commits; `domino stage` does not commit, push, merge, synchronize Playground, or enforce this branch policy. Perform the Git steps explicitly. A push to `main` or a successful stage does not publish live.

## Move to a newer starter [#move-to-a-newer-starter]

To rebuild an existing hosted project on the latest version of a starter, run `domino starters export <template> --directory <dir> --json` from the checkout, so it uses the checkout's project scope, with `<dir>` outside it, such as `../next-starter`. It writes the current starter, as `domino create` would seed a new repository today, into a new or empty directory (a directory with files in it fails with `LOCAL_CONFLICT`), and reports `template`, `directory`, `sourceCommit`, and the number of `files`. The files include `.domino/starter.json` but no project name or brief, and nothing is installed. Only the current version of each starter can be exported; it needs `read` in the project scope.

Integrate the export on a work branch from fetched remote `main`: bring in the starter's files, carry over the project's own quests, CAMPAIGN.md brief and design, and keep the exported `.domino/starter.json` so `domino doctor` reports the new `starter-origin`. Install with the version `package.json` pins, run `domino doctor`, `domino check` and the app's tests, then integrate and stage following the [source and release workflow](#source-and-release-workflow).

## Take the app live [#take-the-app-live]

The live app is the project's public app at `https://<project-slug>.domino.page`, on live data. Only an approved promotion changes it.

1. **Set live's environment bindings.** App secrets, integrations and participant sign-in are configured per environment and never copied. Set live's secrets with `printf %s "$VALUE" | domino secrets set NAME --environment live --json` (a CLI session may do this in live), or ask the owner to use Console **Settings → App secrets** with **Live** selected.
2. **Promote.** Stage the integrated commit, then run `domino promote --environment test --json`. The promotion carries the build serving staging (`build`) with test's definitions; `binding-missing` warnings name what live still lacks, and `group-created` warnings the empty groups approval creates in live.
3. **Hand approval to a person.** Someone signed in to Console with publish permission approves it in **Live → Deployments**. An agent cannot approve.
4. **Verify.** After approval, Domino deploys the same build again with live's own key, secrets, stores and schedule signing key, then switches the live app, typically within a couple of minutes, and the address follows within about a minute. Until then the previous live build keeps serving. Before the first such promotion, the address answers 404 "Nothing is live here yet". To check from code, `management.liveApp.get()` (`read` in either environment) returns `{ url, state, build, requested, failure }`, where `state` is `none`, `deploying`, `live` or `failed`; Console shows the same as the **Live app** card in **Deployments**.

A failed live deployment leaves the previous build serving; the next promotion shows the build as changed again, and approving it retries. A rollback promotion (`management.promotions.rollback(id)`) carries the earlier build and deploys it the same way. Only a promotion changes the live app; live refuses `domino deploy --environment live` with `promotion-required`.

The live app is public: no staging sign-in, banner or `noindex`. Participant sign-in from its origin selects the project's live environment (live members and live sign-in settings); staging and previews stay on test. Routing works as on staging, minus the staging access path. A slug change moves the live address within about a minute, with no redirect from the old one; deleting the project takes it offline, and restoring brings it back.

To tell test from live in app code, call `getHostEnvironment()` from `@domino-sdk/relay/browser`: it resolves to `{ environment: "test" | "live", kind: "staging" | "preview" | "live" }` from the host's `/__domino/environment`, or to `null` where the host does not answer (a local dev server, server rendering). A Worker's server code reads `DOMINO_ENVIRONMENT`. Never decide from hostname patterns, and never from a build-time variable: live serves the same build as staging, and builds see only `CI=true`. For printed or published links, such as QR codes, use the live address or a custom domain, never staging's.

## Custom domains [#custom-domains]

A custom domain such as `quests.example.com` serves the project's live app; staging keeps `<project-slug>--staging.domino.page`. It is a live environment binding, never promoted, and `domino domains` works the same whichever environment is selected.

1. **Add.** `domino domains add quests.example.com --json` (or `management.domains.add(hostname)`, `POST /management/v1/domains`). Writes need an organization admin signed in with `domino login`; API keys get 403, and a person without admin gets 403 `permission-required`. Reading needs `read`. Root domains (`example.com`), wildcards, IPs and Domino's own domains are refused with `invalid-hostname`: attach a subdomain and tell the owner to redirect the root at their DNS provider. One domain per project, 10 per organization (`limit-reached`). Where billing enforcement is on, the project needs Growth or above, unless support raised the organization's domain limits: otherwise 403 `plan-required` with `feature: "custom-domain"` and the project's `plan`. Report that the project needs a bigger plan; don't retry. A smaller plan later keeps the domain.
2. **DNS.** The result's `records` are a `CNAME` from the hostname to `cname.domino.page` and a `TXT` at `_domino.<hostname>` with `domino-verify=<token>`, unique to this attachment. Only the domain's owner can add them; give them both records verbatim. CAA records, if any, must allow `letsencrypt.org` and `pki.goog`.
3. **Check.** `domino domains get quests.example.com --json` (or `recheck` to check now, at most every 10 seconds; 429 `rate-limited` sooner). Adding, re-checking and removing share a budget of 30 per organization per hour, also `rate-limited`; poll with `get`, never `recheck`, because Domino checks on its own. `state` is `awaiting-dns` (TXT not found), `provisioning` (proven; Cloudflare validates and issues the certificate, and HTTPS fails until it is done), `active` (serving), `attention` (`issue.message` says what to fix, then re-check) or `suspended` (contact Domino support). Each record's `found` is what public DNS returned (`null` before Domino looked, `[]` for none) and `matches` compares it; `cloudflare` holds Cloudflare's statuses and errors verbatim. A proxied or flattened CNAME can show `matches: false` while the domain works. Times are ISO strings (`createdAt`, `checkedAt`, `nextCheckAt`, `cloudflare.certificateExpiresAt`), each with an epoch-milliseconds sibling named `…Ms`; a certificate expiry Cloudflare sent in a form that doesn't parse has a null `certificateExpiresAtMs`. Setup that does not reach `active` in 7 days goes to `attention`.

While a domain is `active`, `GET` and `HEAD` on `<project-slug>.domino.page` answer 302 to it, and participant sign-in from its origin selects the live environment; members signed in at the old address sign in again. Before the first live build it answers "Nothing is live here yet". Another project that adds the same hostname and publishes its own TXT record takes it over; the first project's domain goes to `attention`. `domino domains remove quests.example.com --yes` stops serving within about a minute; re-attaching needs a new TXT record. A suspended domain cannot be removed (`domain-suspended`). A deployment without custom domains answers 503 `custom-domains-unavailable`: report that custom domains aren't enabled yet rather than retrying.

## Repository access [#repository-access]

Each hosted project has one private repository behind Domino's Git gateway, `https://git.domino.run/git/<repository-id>.git`. It serves only Git fetch and push over HTTPS and accepts a Domino bearer token, or the same token as the Basic-auth password. Fetching and pushing both need `publish` in the project's test environment; a personal API key or an organization automation key must itself carry `publish`, and an automation key must reach the project. `domino checkout` configures the remote and installs `domino credential-helper`, which answers only for that remote and uses `RELAY_MANAGEMENT_TOKEN` or the saved login. Do not paste tokens into remote URLs. A 409 "Repository is not ready" means provisioning has not finished.

## Set up and develop the app [#set-up-and-develop-the-app]

Before the first implementation, follow the Domino skill's intake, discovery, and confirmation procedure. Ask for missing brand and campaign basics and wait for answers before research or design. Then prepare a concrete proposal and obtain the owner's confirmation. Reuse an already confirmed brief in later sessions.

If Console already created the project and repository, run `domino checkout <project>` with its ID or the `slug` `domino whoami --json` lists; a slug two organizations share needs `--organization`. Without `--json`, checkout prints Git's fetch progress on stderr. A fetch that moves under 1 KB/s for 60 seconds fails with `CHECKOUT_GIT_FAILED` (`details.phase` `fetch`); retry checkout once. Use `domino create <name>` only when the requested hosted project does not exist. Both require authentication. Use `domino whoami --json` to inspect accessible projects when the target is unclear. Work in the checkout these commands make. A starter copied any other way keeps the placeholder organization `local` in relay.json, and commands that reach the project refuse it with `SCOPE_PLACEHOLDER` until you run `domino checkout` or pass `--organization` and `--project`.

For a failure after remote project creation, inspect the existing project's state before repeating creation. Keep partially created local directories intact while recovering. Checkout accepts `--directory` for a fresh destination.

Enter the checked-out directory before installing guidance or dependencies. Use checkout's `packageManager.installCommand`, which selects the exact version declared in package.json and preserves the lockfile. For `pnpm@10.34.5`, this is `npx --yes pnpm@10.34.5 install --frozen-lockfile`. Wait for installation to succeed before running doctor; a missing SDK during installation is not a new fault. If guidance is already installed, do not reinstall it as a remedy for checkout or authentication failures. The starter pins the SDK and CLI to one published npm release in package.json; upgrade them with the package manager. Read CAMPAIGN.md for the owner's brief.

Every starter uses Vite, React, TanStack Router with routes declared in `src/router.tsx`, and Tailwind CSS v4, with an app-owned Hono Worker in `server/`: `server/index.ts` answers Domino's participant proxy, then the app's `/api` routes in `server/api.ts`, then browser assets. A `/api` route that acts for the participant gets their member ID from `signedInMember(c.req.raw, c.env, { apiUrl: config.apiUrl })` from `@domino-sdk/relay/portal-proxy`, which asks Domino with the request's cookies; never trust a member ID the browser sends. The browser calls `/api` through the typed client in `src/api.ts`; the build fails if browser code imports `server/`, `@domino-sdk/relay/server` or `@domino-sdk/relay-cli`. `.domino/starter.json` records which starter the project began from (`template`); its AGENTS.md maps the source and describes what that starter is for. The loyalty starter keeps its campaign, members' group and reward items in `program.ts` and its offers in `offers/`: a quest's `groupMembership` reward adds members to the group that the members-only offer's `inGroup` requirement names, and each offer card explains a lock from the offer's `missing`. The campaign starter's look is neutral on purpose (greyscale color tokens in `src/styles.css`, no logo or accent) and it ships sample quests and copy that introduce Domino; replace them with the owner's campaign. Deleting a sample quest's file does not unpublish it, and `domino stage` and `domino deploy` warn `quests-not-in-bundle` naming each one still published: archive each retired quest with `domino quests archive <key> --reason "…" --json` (for example `show-your-setup`), then deploy. An active sample quest keeps its interaction, such as photo or staff, in `supportedInteractions`, so a deployment that drops that interaction fails until the quest is archived. `domino deploy` reports each release's `lifecycle`. Build the interface on the `@domino-sdk/relay/react` hooks (`RelayProvider`, `useSession`, `useExperience`, `useQuest` with `questActionState` for whether to show the input, `useSignIn`, `useAccounts`, `useLeaderboard`, `useReferralSummary`, `useInvitation`, `usePointLedgers`, `useTieredRewards`, `useBalances`) and keep their recovery behavior; for a read no hook covers, use `useRelayQuery` so it shares the cache and invalidation (see [React hooks](participant-api.md#react-hooks)). A participant's own points need no server route: the classic starter's header reads them with `useBalances()`, which reads again after a quest completes. Read hooks keep themselves current, so add no timers or refresh effects of your own: `RelayProvider` follows the signed-in member's activity feed and reads again only what each event changes, and the referral starter's summary comes from `useReferralSummary()` alone, which reads again every minute while the page is visible and whenever the member completes something. The starters' buttons disable on `isPending`; sign-out uses `useSignOut().mutate()`, which never rejects and reports a failure in `error`. Browser code imports response types such as `QuestView` from `@domino-sdk/relay` and avatar helpers from `@domino-sdk/relay/avatar`; keep Zod schemas (`@domino-sdk/relay/schema`) and builders (`@domino-sdk/relay/authoring`) in quests and `server/`, out of the browser bundle. Starters name Domino resources by authoring key, but reads return stored IDs: the referral starter resolves its ledger keys to IDs once with `usePointLedgers` and compares IDs everywhere (see [resource references](participant-api.md#resource-references)), and pages its activity feed with `referrals.history({ cursor })` until `nextCursor` is null. Starter tests use IDs that differ from keys so a key comparison fails.

To test the app's screens without hosted data, route its `/relay/v1/` requests in Playwright, or pass `fetch` to the client in a component test, to `createFakeRelay` from `@domino-sdk/relay/testing`, seeded with the member, quests and points the test needs (see [test the UI](participant-api.md#test-the-ui)).

The starter's relay.json declares discovery patterns for `quests/**/*.quest.ts` and `quests/**/*.quest-type.ts`. Add a matching default-exported file to include it in builds and development sync. Installed snippets use these paths. Use explicit entries only for named exports or per-resource overrides.

Run `domino dev` to start the app with hosted test data and real sign-in. Share its HTTPS preview URL. The CLI prepares the tunnel connector automatically. The URL goes offline when development stops; use `domino stage` for a preview that stays online. Development uses the hosted test environment and real providers.

Real sign-in offers the OAuth providers and email code Domino configures for the platform. Wallet sign-in is the project's own setting, per environment and off by default: enable the wallet families in Console **Settings → Sign-in**, or with `domino api PUT /participant-sign-in body.json` (publish permission). Enable it in the test environment before building a wallet-first app, and in live before taking it live. When a source file of the app signs participants in with a wallet (`relay.auth.wallet.signIn`, `useSignIn().withWallet`, `useSignInWallets`, Cosmos chains passed to `useSignInOptions`, a wallet option picked with `find`, or wallet options filtered without OAuth, email or Telegram ones) and test has no wallet family enabled, `domino stage` warns on stderr, adds a `wallet-sign-in-off` entry, with the families the app names and the enabling command, to its JSON `warnings`, and still stages; `domino doctor --remote` reports the same as its `wallet-sign-in` check. Run the command it names, then verify the wallet sign-in in the staged app. See [wallet sign-in](participant-api.md#wallet-sign-in).

Participant profiles (names and avatars on leaderboards and profile pages) are private by default too. Read the settings with `domino api GET /profile-settings`, and change them with `domino api POST /profile-settings body.json` holding `visibility`, `defaults` (the ordered accounts that fill in names and pictures, such as `["x", "discord"]`; `[]` for generated), the read `revision` as `expectedRevision`, a `reason` and an `actionId` (publish permission; live refuses it from every credential, Console included, with `promotion-required`). `domino profiles settings` and `domino profiles configure FILE` do the same. With the defaults every leaderboard row shows a generated name such as `Participant 1a2b3c4d`: when the project has leaderboards (declared in `relay.json` or published in test) and test's profiles are private or have no default account, `domino stage` warns on stderr, adds a `generated-leaderboard-names` entry with the configuring command to its JSON `warnings`, and still stages; `domino doctor --remote` reports the same as its `leaderboard-names` check. Ask the owner before showing names, since it is their call; [show member profiles](/docs/build/profiles#turn-profiles-on) has their Console steps. See [profiles](participant-api.md#profiles).

Use `domino check` and the app's build script to verify source. `domino check` reports source problems Relay would refuse at deployment, such as two quests declaring one [budget](quest-rules.md#automatic-and-repeatable-quests) key differently, an author budget on a quest without a tracker trigger, or an offer, badge or other data definition that fails its schema (`DEFINITION_INVALID`, naming the file and field). It also warns, without failing, about a leaderboard whose live `ties` differs from its `settle.ties` (`LEADERBOARD_TIES_DIFFER`, severity `warning`): its standings show one order and its prizes pay another; and about a quest on a YouTube or TikTok tracker that runs on every observation (`TRIGGER_REFIRES_ON_REFRESH`), which each refresh evaluates again for every video. [Authoring](authoring.md#upgrade-existing-authoring-code) lists every warning `domino check` reports. Exercise the requested participant flow in the running app before staging: a preview link signs the `domino dev` preview in as a test member ([check participant flows](campaign-testing.md#check-participant-flows-on-staging), "Before staging").

For leaderboards, register authored modules in the manifest's `leaderboards` list. `domino check` bundles them even when the project has no quests. `domino dev` synchronizes their definitions with the other project resources. Verify signed-in rank reads through the participant proxy; a successful build alone does not establish access or scoring behavior.

When the task includes sharing the team's staging preview, follow the [source and release workflow](#source-and-release-workflow) to integrate and push the intended changes to `main` first. Stage the exact integrated commit with `domino stage --commit <full-sha> --json`. Git pushes alone save source; they do not activate a preview or publish live. To take the staged app live, follow [Take the app live](#take-the-app-live).

If stage submission loses its response, or answers 502 or 504 "Could not start cloud build", repeat the printed commit and request ID: the build is reserved under that ID and may already have started. Use `domino builds --json` and `domino logs <build-id> --json` to recover progress after disconnecting. A failed build leaves the previous preview active. Report the URL returned for the successful build rather than constructing one. Staging requires sign-in: Console members with project access in Test sign in through Console, and anyone with `publish` can set a staging password for other reviewers, in Console **Settings → Staging access** or with `domino api PUT /staging/access access.json --environment test`, where `access.json` holds `{"mode":"password","password":"<8-128 characters>"}` (a file keeps the password out of shell history; `{"mode":"console"}` returns to Console sign-in only, and `domino api GET /staging/access --environment test` reads the current mode). Ask the owner before setting or changing the password: it replaces the current access mode and signs out everyone who entered the old one. There is no public staging link; share the staging URL with the password, and take the app live to show it to members. To fetch staging from a script, `domino api POST /staging/link body.json` with `{ "path": "/" }` returns a one-minute URL; opening it answers 303 with the session cookie to send on later requests. To use the app as a test member there, see [check participant flows on staging](campaign-testing.md#check-participant-flows-on-staging).

Cloud staging supports static apps and compiled Cloudflare Worker apps, including SSR and server routes. Configure `app.kind="worker"`, `directory` for private modules, `main` relative to that directory, `compatibilityDate`, optional `compatibilityFlags`, and optional `assetsDirectory` for browser assets. Keep `devScript`, `checkScript`, and `buildScript`. TanStack Start with Cloudflare Vite typically uses `dist/server`, `index.js`, `dist/client`, and `nodejs_compat`. The dev server must honor `DOMINO_APP_PORT` and `DOMINO_PREVIEW_ORIGIN`; with Vite and the Cloudflare Vite plugin, `const domino = dominoHosting({ command })` from `@domino-sdk/relay/vite` supplies both: pass `domino.cloudflare` to the app's own `cloudflare()` call (the Worker in Vite's `server` environment, and `domino dev`'s variables forwarded to the local Worker only when serving) and use `domino.server` as Vite's `server` (port from `DOMINO_APP_PORT`, default 5173; the preview host and HTTPS hot reload when `DOMINO_PREVIEW_ORIGIN` is set). The build must also emit the private `.domino-build` quest/catalog artifacts: end the build script with `domino build --hosting-artifacts`, which writes `quests.json` and `catalog.json` to `.domino-build/` beside `relay.json`. The catalog artifact lists `relay.json` reference `images` by hash and path; the builder stages those files from the repository, so write `domino build --json` output through unchanged and commit the images. Quests and quest types import the installed SDK's runtime instead of bundling the SDK; the catalog artifact carries that runtime once in `runtimes`, so keep it in the output. `domino build --hosting-artifacts` covers up to 1,000 quests. The builder compresses the quest/catalog artifacts separately from the modules and assets: they may reach 64 MB of JSON and 10 MB compressed, while modules and assets share a 20 MB limit once base64-encoded ([limits](/docs/build/hosted-app#what-the-server-output-may-contain)). Set `app.routing="transparent"` to preserve methods, cookies, and app authorization. Staging reserves the sign-in path `/__domino/staging-access`, the test toolbar's `/__domino/toolbar.js` and `/__domino/toolbar/` paths, and `/__domino/environment`; it adds the toolbar script to every HTML response and removes its own staging session cookie, and otherwise forwards requests and responses unchanged. Every `domino.page` address also answers `/.well-known/security.txt` itself with Domino's abuse contact, so the app never receives that path there; a custom domain forwards it. The starter explicitly proxies `/relay/*` in its own server, used in both development and staging; apps can override or remove that handler. Use `proxyParticipant(request, { baseUrl, fetch })` to integrate Domino-managed sign-in explicitly. The API validates the registered app origin, session, and CSRF headers. `DOMINO_PARTICIPANT_API` supplies the selected upstream during connected development. `runWorkerFirst: true` lets your Worker handle even requests matching browser assets through its ASSETS binding. Non-secret string `vars` are supported, and [app secrets](#app-secrets) supply credentials; [KV and SQL stores](#app-storage) declared in `app.storage` receive native bindings. Arbitrary Wrangler bindings are not imported. Domino binds each Worker build's own management key and scope: `DOMINO_MANAGEMENT_KEY` (secret, `dhk_…`), `DOMINO_API_URL`, `DOMINO_ORGANIZATION`, `DOMINO_PROJECT`, and `DOMINO_ENVIRONMENT` (`test` on previews and staging, `live` on the live app); these names are reserved. In server code, call `hostedManagementClient(env)` from `@domino-sdk/relay/server` (Worker-safe) for point writes (`points.record`), custom tracker ingestion (`trackers.ingest`), and reads such as leaderboards, referrals, balances, and members. The key has only `read`, `points`, and `ingest` in the environment the app serves (test for staging and development, live for the live app, which writes real members' points); `ingest` reaches custom tracker records only, and the key cannot enroll members. It cannot publish or qualify referrals. An external score, such as a wallet balance, gets its own ledger, updated with absolute values: see [Rank an external score](/docs/build/leaderboards#rank-an-external-score). Never expose it to browser code. `domino dev` exports the same five variables with a 12-hour key to the dev server process; the app's `vite.config.ts` must forward them to the local Worker in the `cloudflare()` plugin's development `vars`, which `dominoHosting()` does. Activating a newer build revokes older builds' keys in that environment; staging and live keys rotate separately. New staging builds use the same project test data as development and real participant sign-in. Omitted routing or `app.routing="legacy"` preserves existing gateway behavior, including implicit `/relay/` routes. To migrate, add the app-owned proxy, explicitly select transparent routing, restart and test the preview with the updated CLI, then commit and stage. Routing is recorded per build and preview session; old records and old CLI requests remain legacy. Test data does not imply that external provider operations are simulated.

For referral programs, register authored modules in `referrals`. `domino check` and `domino dev` include them even without quests. Preserve invitation codes across the app's sign-in redirect and verify acceptance through the configured participant proxy before any quest completes. See [referrals](/docs/reference/referrals).

Quests declare `integrations: [discord]`; Relay derives the participant identities required by each quest. The campaign starter renders account controls from the quest view’s `connections` field. The Discord membership snippet needs a project Discord integration configured in Console. Sign in with Discord (or link its identity) and check membership before staging.. See [connected accounts](participant-api.md#connected-accounts).

Keep the starter's explicit **Start a new submission** action for a server-retired saved submission. It discards the local record only after participant review; it does not send a replacement claim. Ordinary interrupted submissions still resume their original operation. See [submission recovery](/docs/reference/participant-api).

For streak quests, upgrade the starter's SDK before publication. The starter shows `quest.streak.current` against its goal and formats `nextBoundary` in the project timezone (`quest.streak.timeZone`). It distinguishes `progressed` attempts from completed milestones, keeps automatic quests free of claim/retry buttons, and reports budget-exhausted milestones without pretending a reward was delivered. Preserve these behaviors when adapting the participant interface. See [calendar-day streaks](quest-rules.md#calendar-day-streaks).

## App secrets [#app-secrets]

Store credentials your server code reads, such as `APP_API_KEY`, as app secrets. Each environment has its own: test's reach staged builds, live's the live app, and a promotion never copies them. Never put them in `relay.json` `vars`, source, or browser code. Every `domino secrets` command uses test unless you pass `--environment live`; it ignores `RELAY_ENVIRONMENT` and relay.json's environment.

* **Declare.** List every secret the server code reads in `relay.json` `app.secrets`, keyed by name: `"APP_API_KEY": { "description": "What it opens, format, and how callers send it" }`. `required` defaults to true; set `false` for optional ones. A `description` has 1 to 500 characters, and a build declares at most 50 secrets. A declaration carries no value, a name cannot be both a `vars` entry and a declared secret, and static apps cannot declare any. When you add a secret to server code, declare it in the same change.
* **Set.** `printf %s "$VALUE" | domino secrets set NAME --json` creates or replaces one. The CLI reads the value from stdin or a hidden terminal prompt, never from arguments, and never prints it. An invalid name or value exits 2 with `INVALID_ARGUMENT`, and a missing value with no terminal to prompt in exits 2 with `INPUT_REQUIRED`, before anything is sent. A value starting with `di_` is checked as an app login key first: one that is unknown, expired or revoked, or belongs to another project or environment, exits 2 with `INVALID_ARGUMENT` and nothing is stored; a stored key is named in the result's `identityIntegration`. Generate a value in the pipe (`openssl rand -hex 32 | domino secrets set APP_API_KEY --json`) when the owner does not need to know it; otherwise ask the owner to run the command, and keep the value out of chat and logs.
* **Check what is missing.** `domino stage` warns on stderr about each required declared secret that is not set, with the `domino secrets set` command to fix it, and adds `missingSecrets` to its JSON result. It never blocks: the build stages, and routes that need the secret fail until it is set and staged again. Report missing secrets to the owner rather than treating the stage as complete. Optional missing secrets get one stderr line.
* **Inspect and remove.** `domino secrets list --json` returns `secrets` (names with `updatedAt` and `updatedBy`), `declared` (the active build's declarations merged with this checkout's `relay.json`, whose descriptions win), and `missing` (declared but not set); no command or route returns a value. `domino secrets delete NAME --json` removes one. A value a quest's code reads, such as a shared scan code, is a [secret setting](authoring.md#secret-settings) instead.
* **Apply.** Only Worker builds (`app.kind="worker"`) deployed after a change receive it, as a secret binding that replaces an app `vars` entry of the same name. In test, run `domino stage` again; earlier builds keep their values. In live, the next build promoted to live receives it; the live app keeps its previous value until then. Static apps receive none.
* **Develop locally.** `domino dev` does not receive hosted secrets. Supply local values through the framework, such as a git-ignored `.dev.vars` for the Cloudflare Vite plugin.
* **Rules.** Names match `^[A-Z_][A-Z0-9_]{0,63}$`, excluding `DOMINO_` names and `ASSETS`. Values are 1 byte to 5 KiB; each project environment holds at most 50. Setting and deleting need `publish` and listing needs `read` in the environment (the CLI has the signed-in person's permissions; editors and organization admins have both). A CLI session may set and delete secrets in live; see [errors](/docs/reference/errors) for the other live writes it may make. Operators can also manage them in Console **Settings → App secrets**. The management client exposes `secrets.list()`, `secrets.set(name, value)`, and `secrets.delete(name)`.

## App storage [#app-storage]

Worker apps declare logical stores in `relay.json` under `app.storage`: `"kv": { "CACHE": { "description": "Rendered page cache" } }` and `"sql": { "DB": { "description": "Campaign data", "migrations": "migrations" } }`. Domino provisions resources per project and environment: test's on staging builds, live's when a build that declares them is promoted to live (migrations run against live's databases before the live app switches). Data never copies between environments. Never put Cloudflare IDs in this declaration. `env.CACHE` is a native `KVNamespace` and `env.DB` a native `D1Database`. There is no SDK wrapper or static-app storage. Requires SDK and CLI 1.2.0 or later.

* **Types.** Add `@cloudflare/workers-types` as a dev dependency and `import type { D1Database, KVNamespace } from "@cloudflare/workers-types/index.ts"` in `server/env.ts`. Do not add the package to tsconfig `types`: its globals conflict with the DOM library, and the check script fails staging.
* **Names and limits.** Use the app-secret name rules, unique across vars, declared secrets, KV and SQL. A build declares at most 5 KV stores and 5 SQL stores, and a project holds at most 10 of each, counting stores kept after their declaration was removed; the secret limit is under [App secrets](#app-secrets). A store `description` has 1 to 500 characters. Reuse a name rather than inventing new ones. Stored secrets cannot collide with a store. SQL alone accepts an optional repository-relative migrations directory, without hidden segments, parent traversal, or `node_modules`.
* **Local development.** Mirror the names in root `wrangler.json`/`wrangler.jsonc` `kv_namespaces` and `d1_databases`, with the same `migrations_dir`; local resource IDs are placeholders and local data stays separate. `domino dev` and `domino stage` warn about mismatched names or migration directories without failing. `domino dev` does not apply migrations: run `wrangler d1 migrations apply DB --local` after adding a file.
* **Migrations.** Commit `.sql` files directly in the directory; nested layouts such as `0000_x/migration.sql` fail the build. Every build ships every file, so the limits (100 files per store, 90,000 UTF-8 bytes each, 1,000,000 bytes per build) cover the whole history; squash files that every database has applied when approaching them. The trusted builder applies missing filenames in Wrangler's numeric-prefix order before Worker upload and activation. Migrations are forward-only and must remain compatible with the currently serving build. Each file and its tracking insert run as one request, so a failing file rolls back alone; earlier files stay applied. A failure fails the build without retrying, keeps the previous build active, and includes D1's error message. Correct the file and stage again. See [storage migration recovery](/docs/build/storage#write-migrations).
* **Inspect.** `domino storage list --json` or `GET /management/v1/storage` lists kind, name, declaration status and creation time (null until provisioned), never provider IDs. Requires `read`; declarations come from the environment's serving build. `domino storage` uses test unless you pass `--environment live`.
* **Browse.** Read-only, with `publish`: `domino storage tables DB`, `domino storage rows DB TABLE [--cursor C]` (50 rows; long text `{ text, truncated: true }`, BLOBs `{ blob: bytes }`), `domino storage cell DB TABLE ROW COLUMN [--cursor C | --all]` (one value in full, 100,000 characters per page; ROW is the rows page's `offset` plus the row's index on it; `type`, `length`, `text`, `nextCursor`; a BLOB has `text: null`), `domino storage keys CACHE [--prefix P] [--cursor C]` (100 keys), `domino storage get CACHE KEY` (size and a 2,000-character text preview; `text` is null for binary). Routes: `GET /storage/sql/DB/tables`, `/rows?table=&cursor=`, `/cell?table=&row=&column=&cursor=`, `/storage/kv/CACHE/keys?prefix=&cursor=`, `/value?key=`. Rows, cell and keys pages carry `nextCursor`; pass it back as `cursor` until it is null. Reads count toward D1 usage. Apart from clearing test data (below), nothing writes: seed with migrations; there is no export. KV values stay cut at 2,000 characters. Console shows the same under Settings → Storage, where people with `publish` can also delete undeclared stores.
* **Clear test data.** Test only, with `publish`, on declared stores too: `domino storage clear sql DB --table T [--table T2]` or `--all-tables [--keep T]` deletes the tables' rows in one D1 transaction (foreign keys deferred to its end), keeping columns, indexes, triggers, views, `d1_migrations`, `sqlite_*` and `_cf_*` (virtual tables such as FTS5 clear through themselves, never their shadow tables); `domino storage clear kv NAME --prefix P` or `--all-keys` deletes keys, 10,000 per call, and the CLI repeats until the result's `complete` is true. `--dry-run` only counts (KV counts up to 100,000, `complete: false` beyond). Without `--yes` it asks in a terminal and refuses unattended or with `--json`. Routes: `POST /storage/sql/DB/clear` with `{ tables }` or `{ allTables: true, keepTables? }`, `POST /storage/kv/NAME/clear` with `{ prefix }` or `{ all: true }`, each with optional `dryRun`; SDK `storage.clear(kind, name, input)`. Answers `{ environment, kind, store, dryRun, tables: [{ name, rows }] }` or `{ …, prefix, keys, complete }`. Live: 422 `test-environment-required`; reserved or empty table lists and a missing prefix: 400; an unknown store or table, including a `--keep` one: 404, nothing cleared. Each clear writes one `app-storage-cleared` audit event; dry runs none. Test member reset, purge and erase never touch app storage, so use this to reuse app-side records such as ticket mappings.
* **Delete.** `domino storage delete sql OLD_DB --yes` (or `DELETE /storage/sql/OLD_DB`, `publish`) deletes a store and its data once the serving build no longer declares it; otherwise 409. A CLI session cannot delete in live: the owner uses Console or an API key there ([what each credential may do in live](/docs/concepts/access#what-each-credential-may-do-in-live)). Use it to get back under the 10-per-kind project limit.
* **Retention.** Removing a declaration unbinds the next build but keeps data. Re-adding the same kind and name reuses it; a rename creates another store. Transfers retain resources. Project purge deletes all retained stores.

## Recover interrupted creation [#recover-interrupted-creation]

`domino create` waits for a slow repository setup: when a step times out, loses its connection or gets a 502, 503 or 504, it resumes the same saved operation for up to 5 minutes and then prints the normal result. Without `--json` it says once on stderr that it is still setting up the project. If it still fails, the repository setup is refused, or checkout is interrupted, keep the operation ID from its error. Run `domino operations resume ID --json` with the same `RELAY_CONFIG_DIR` to finish the saved project and destination. `domino operations list --json` finds the ID after a process restart. Do not start another creation to recover the first. The journal keeps the original API scope and actor; sign in to that API as the same actor. See [CLI automation](automation.md#recover-after-restarting-the-cli) for the error and recovery contract.

## Scheduled jobs [#scheduled-jobs]

Declare `app.schedules: [{ name, path, every: "15m", description }]` in a Worker app, or replace `every` with a five-field numeric UTC `cron`. At most ten jobs, at least five minutes between minute slots; interval units are m/h/d, up to 365d. Static apps and development previews do not run schedules. Stage to activate declarations in test; promote that build to run them in live, where they sign with a different `DOMINO_SCHEDULE_KEY` and keep their own runs and pause state.

Verify every job route with `verifyScheduledRequest(request, env.DOMINO_SCHEDULE_KEY)` from `@domino-sdk/relay/server` before doing work. The per-build signing key is separate from the hosted management key. Use the verified `id` for idempotent external writes or `hostedManagementClient(env).trackers.ingest(REFERENCE, { actionId: run.id, records })`. Await all work; do not use waitUntil. See [scheduled custom tracker ingestion](/docs/build/schedules#feed-a-custom-tracker).

Answer with `return scheduleResult({ outcome, summary?, metrics?, diagnostics? })` so the run records what happened: `outcome` is `succeeded`, `partial` or `failed` (the last two fail the run even on 2xx); `metrics` holds up to 50 named numbers; `diagnostics` up to 50 `{ level: "error" | "warning" | "info", message, subject?, code? }` lines. Relay reads any `application/json` answer up to 16,384 bytes at any status; an invalid or oversized one leaves the status alone and sets `error` to `Result ignored: …`. Anyone with read in that environment sees results: IDs only, no secrets or personal data. Without a result a 204 is `succeeded`. [Result fields](/docs/build/schedules#report-what-a-run-did).

Budget each run within 30 seconds: ingests into one tracker run one at a time on the server, so send one `trackers.ingest` per tracker per run with up to 100 records rather than one per member; pass `{ timeout }` from the time left; retry a timed-out ingest with the same `actionId`; stop early with a `partial` result. [Budgeting](/docs/build/schedules#fit-a-run-into-30-seconds).

`domino schedules list --json` shows active-build declarations, last run time/status/duration/error/result and next run. `domino schedules runs NAME --json` returns `{ runs }`, the latest 20 runs newest first with each `result`; Relay keeps no older runs. `domino schedules run NAME` queues one manual run. `pause`, `archive` and `resume` control the project's schedules across builds. Add `--environment live` for the live app's jobs; without it the commands use test. Reading needs read and controls need publish in the environment; a CLI session cannot run or control live jobs, so live controls need Console or an API key. The SDK has `management.schedules.list`, `.runs`, `.run`, `.setState` and `.recover`. Domino does not expose a Worker's runtime logs; put what an operator needs in the result.

Each tick gets one attempt, a 30-second timeout and no automatic retries. Missed ticks are skipped. Calls are serial within a project, and activation waits for an in-flight dispatch. An uncertain run blocks that name even after replacement, and its `error` says what Relay saw; confirm the remote job stopped, then `domino schedules recover NAME --yes`. A build with no Worker fails the run instead of blocking. Recovery skips that tick. Deletion stops jobs and purge deletes receipts. See [schedule dispatch guarantees](management.md#schedule-dispatch-guarantees) for what counts as failed or uncertain, and the [full guide](/docs/build/schedules).
