# Build with Domino (/docs/overview) Domino runs the rules of your community program. It checks that members did what you asked, counts it once, and hands out the points and rewards they earned. You decide what the app looks like and what people do in it. For example: * A referral race with a weekly leaderboard * A launch quiz with prizes * A selfie at your booth * Posts about you on X * Discord roles for your regulars You can build it in Console, hand it to your developer, or work with a coding agent in your editor. ## Choose your starting point [#choose-your-starting-point] - [Try it with your agent](/docs/start/agent) — Paste one prompt into Claude, ChatGPT, or Gemini and get a campaign on staging. - [Create a new app](/docs/start/new-app) — Start with a private repository, a working app, and instructions for your agent. - [Connect an existing app](/docs/start/existing-app) — Keep your code, login, and hosting. Add Domino where you need it. - [Start from Scaffold-ETH 2](/docs/start/scaffold-eth) — Create an Ethereum dApp with quests, points and a leaderboard, and a quest that checks your contract. - [Manage a project](/docs/use/projects) — Learn what you can change in Console. - [Build as an agent](/docs/agents) — Discover the tools, supported features, and verification steps. ## Understand the core concepts [#understand-the-core-concepts] Start with quests and members, then add the pieces your campaign needs. | Concept | What it's for | | -------------------------------------------- | -------------------------------------------------------------------------------------- | | [Quests](/docs/concepts/quests) | The things members do: answer a quiz, post on X, upload a photo. | | [Members](/docs/concepts/participants) | The people taking part, the accounts they proved are theirs, and their progress. | | [Marketing consent](/docs/concepts/consent) | Whether each member agreed to marketing email, when, and the words they saw. | | [Points](/docs/concepts/points) | What members earn and spend. A project can run more than one kind. | | [Rewards](/docs/concepts/rewards) | What members receive, from badges to Discord roles and merch. | | [Leaderboards](/docs/concepts/leaderboards) | Rank members by their points, and hand out prizes. | | [Referrals](/docs/concepts/referrals) | Reward members for inviting friends. | | [Campaigns](/docs/concepts/campaigns) | Set dates, sign-up and who can take part for a group of quests. | | [Groups](/docs/concepts/groups) | Name a set of members, such as your VIPs, for quests, rankings and referrals. | | [Collections](/docs/concepts/collections) | Put quests in order on a page of your app. | | [Trackers](/docs/concepts/trackers) | Collect activity from outside your app, such as X posts and Discord messages. | | [UGC programs](/docs/concepts/ugc) | Reward creators for their content and how well it performs. | | [Analytics](/docs/concepts/analytics) | Count your program's activity per day or week. | | [Project calendar](/docs/concepts/calendar) | Set where days, weeks and months begin. | | [Test and live](/docs/concepts/environments) | The two copies of a project: one to try things, one for your members. | | [Promotion](/docs/concepts/promotion) | How what you built in test reaches live, reviewed by a person. | | [Access](/docs/concepts/access) | Who can do what: organization admins, project roles, and the keys apps and agents use. | | [Hosted apps](/docs/concepts/hosting) | Where Domino runs your app: previews, staging, and your live address. | [Compose a campaign](/docs/composition) connects these pieces in a working welcome, referral, and weekly-ranking example. To start with one activity, [write your first quest](/docs/build/first-quest). ## What belongs where [#what-belongs-where] | Owner | Owns | | -------- | ------------------------------------------------------------------------------------------------ | | Your app | Its pages and how people interact with them. | | Domino | The recorded result: whether a quest is complete, what a member earned, and which rules applied. | A project has separate **test** and **live** environments. Quests, their settings and other definitions change in test, where you build and try them. Live accepts operations, such as pausing a quest or awarding points, and [promotions](/docs/concepts/promotion) of test that a person approves. See [test and live](/docs/concepts/environments). ## Check the fit before you build [#check-the-fit-before-you-build] [The capability map](/docs/reference/capabilities) lists what Domino supports, what you can compose, and what needs your own backend. For example, Domino can reward the person completing a quest and add a selective bonus for their inviter through a [referral program](/docs/build/referrals). > [!NOTE] Looking for the automation builder? > These guides cover the current Relay-based Domino product. The [legacy documentation](https://docs.domino.run/docs) describes the earlier automation builder. [Outbound webhooks](/docs/concepts/webhooks) tell your own systems about member activity, such as a completed quest, through signed requests with retries and replay. # Run an agent without signing in (/docs/agents/automation) A coding agent in CI or a sandbox has nobody to approve `domino login`. Give it an **automation key** instead: an organization key that no person owns, so it keeps working when people leave. It can build, stage and test in the test environment of the projects you choose. Going live still needs a person in Console. ## Create a key [#create-a-key] ### 1. Open the API keys settings [#1-open-the-api-keys-settings] In Console, open **Settings** → **API keys**. Only organization administrators see **Create an automation key**. ### 2. Choose what the agent may do [#2-choose-what-the-agent-may-do] * **Projects:** **All projects**, which includes projects created later, or **Chosen projects**. * **Permissions:** any of Read, Publish, Inventory, Points, Ingest and Enroll. Pushing code, staging and test members need Publish. * **Create projects:** lets the agent start new projects on your plan. A key limited to chosen projects can also work on the projects it creates. * **Expiry:** the key never expires unless you choose one. ### 3. Save the secret [#3-save-the-secret] Select **Create automation key**. Console shows the secret once. Store it in your CI's or agent's secret settings as `RELAY_MANAGEMENT_TOKEN`, and keep it out of chat and source control. > [!DEV] > Automation keys start with `dak_`. Administrators manage them with `management.automationKeys` in the SDK (`list`, `create`, `addSecret`, `revokeSecret`, `revoke`), from a Console session only. The request body of `create` is `{ name, permissions, projects?, createProjects?, expiresInDays? }`; omit `projects` for every project. ## Use it [#use-it] The CLI reads `RELAY_MANAGEMENT_TOKEN`, so the agent runs the usual commands: ```sh export RELAY_MANAGEMENT_TOKEN=dak_... domino whoami --json domino create launch-week --template campaign --json cd launch-week npx --yes pnpm@10.34.5 install --frozen-lockfile # make the changes and commit them git push domino stage ``` A project name uses letters, numbers, underscores and hyphens. Use the install command `domino create` prints in `packageManager.installCommand` if its pnpm version differs from this example. `domino login --token-stdin` saves the key instead, for a machine that keeps a home directory. The Git credential helper `domino checkout` installs uses the same key, so `git push` to `git.domino.run` works without a password prompt. > [!DEV] > `domino whoami --json` prints `credential` for an automation key: `{ kind: "automation", id, name, projects, createProjects, permissions }`, where `projects` is `null` for every project. Audit history names the key as `automation:`. ## What it can't do [#what-it-cant-do] | Refused | Answer | | ------------------------------------------------------------------------------- | --------------------------------- | | Any change in live, including drafts, previews and secrets | `403` `console-approval-required` | | Managing people, roles, personal keys, projects' settings, deletion or transfer | `403` `credential-not-allowed` | | Creating any key or approving a CLI sign-in | `403` | | Creating a project without **Create projects** | `403` `permission-required` | | A project it was not given | `403` `scope-mismatch` | It can read live data when it has Read. [What each credential may do in live](/docs/concepts/access#what-each-credential-may-do-in-live) compares it with the other credentials. Staging links it creates also open the [test toolbar](/docs/build/staging#try-quests-with-the-test-toolbar) when the key holds Publish and reaches the project, acting as the key: toolbar actions are audited as the key, and revoking the key, or the secret that created the link, ends the toolbar on its next request. ## Rate limits [#rate-limits] Domino sets no general limit on how many management calls a key, a project or an organization makes. Test-member calls and attempt reads have no limit of their own, except linking a test member's X account by handle, which counts as an X handle lookup. This is today's behavior, not a promised throughput. A few calls have their own limits, because each spends a shared outside quota or starts something costly. "Per caller" counts, within one organization, each person once, whichever session or personal key they use, and each automation, service or hosted app key on its own. | Call | Limit | | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Looking up an X handle, including linking a test member's X account by handle | 30 a minute per caller, 300 an hour per organization | | [Previewing an X search](/docs/build/trackers#preview-an-x-search) | 6 a minute per caller, 60 an hour per organization | | [Adding, re-checking or removing a domain](/docs/build/hosted-app#use-your-own-domain) | 30 an hour per organization, together | | Starting `domino dev` | 10 a minute per caller | | Sending feedback | 10 an hour per caller | | Signing in with a personal key or an AI assistant connection that doesn't exist, such as a mistyped key (expired and revoked ones don't count) | 30 every 10 minutes per network address, counting a whole IPv6 /64 as one, on each Domino server; past it, personal keys and AI assistant connections from that address are refused until the 10 minutes end | Any call can still be refused when Domino is busy. Wait as long as the answer asks before trying again. The CLI and the SDK already do this within each call's deadline. For a long run, such as a rehearsal with hundreds of test members, keep a fixed number of calls in flight and slow down when calls are refused. > [!DEV] > Past a limit, a call answers HTTP 429 with `code: "rate-limited"`. Any call can answer 429 or 503 with a `Retry-After` header when the wait is known; the CLI reports it as `details.retryAfter` in milliseconds. Give each write that takes an `actionId` a deterministic one, so a retry replays the first answer instead of applying the change twice. An action ID is 1–100 letters, digits, `_` or `-`; derive one as a hex SHA-256 of what makes the write itself, such as each record's ID and observation time (`sync-` followed by the hash), rather than joining IDs with `:`, which is refused with an error that names `actionId`. Responses carry no `RateLimit-*` headers. `domino reference automation` describes the CLI's retries. ## Rotate or revoke [#rotate-or-revoke] To rotate, select **Add secret** on the key, move your CI to the new secret, then revoke the old one once its last use stops advancing. Both work until then. **Revoke** ends the key and every secret on the next request. Creating, rotating and revoking keys appear in each project's access history. # Connect an AI assistant (/docs/agents/connect) Connect Domino to Claude, ChatGPT, Claude Code, VS Code or Cursor to ask about your projects in plain language: how a campaign is doing, who is waiting for review, where a member stands. The assistant can also try a quest end to end in your test environment with a test member. It never changes your live project. ## Connect [#connect] ### 1. Add Domino's server [#1-add-dominos-server] Domino's server URL is: ```text https://mcp.domino.run ``` **Claude or ChatGPT:** add a custom connector (in ChatGPT, an app) with this URL. **Claude Code:** run this in a terminal, then run `/mcp` in Claude Code and choose `domino` to sign in: ```sh claude mcp add --transport http domino https://mcp.domino.run ``` **VS Code:** run **MCP: Open User Configuration** from the Command Palette, add Domino under `servers`, and start it. VS Code opens the sign-in the first time. ```json { "servers": { "domino": { "type": "http", "url": "https://mcp.domino.run" } } } ``` **Cursor:** add Domino to `~/.cursor/mcp.json`, or to `.cursor/mcp.json` in a project, with Domino's client ID for Cursor. Cursor then lists `domino` on its **Customize** page, where you sign in. ```json { "mcpServers": { "domino": { "url": "https://mcp.domino.run", "auth": { "CLIENT_ID": "https://mcp.domino.run/oauth/clients/cursor.json", "scopes": ["openid", "profile", "email", "offline_access"] } } } } ``` ### 2. Sign in [#2-sign-in] The assistant opens Domino's sign-in. Sign in with the account you use for Console, and allow the connection when asked. The assistant then acts as you, with the access you already have. ### 3. Ask a question [#3-ask-a-question] Start with "List my Domino projects", then ask about one of them, such as "Which submissions in the Launch project are waiting for review?" The assistant reads the test environment unless you ask about live. > [!DEV] > The server speaks MCP over Streamable HTTP at `POST https://mcp.domino.run/`, without sessions, and answers JSON. A request without a token gets `401` with `WWW-Authenticate: Bearer resource_metadata="https://mcp.domino.run/.well-known/oauth-protected-resource"`, which names the authorization server. That request does not count against the client, and neither does a token Clerk recognizes but has expired. Once an API server has refused one address 60 times within 10 minutes, for tokens Clerk does not recognize, other refused credentials or unknown paths, it answers that address `429` `rate-limited` with `Retry-After` until the 10 minutes end. Tokens are OAuth access tokens issued for that resource. The tools that list quests, campaigns, offers, submissions, audit events and leaderboard standings answer `items` and `nextCursor`; pass `nextCursor` back as `cursor` for the next page. Submissions, audit events and standings also carry `total`, and standings the board's own fields. `find_member` answers `{ items, total }` with no cursor, `list_projects` `{ projects }`, and `list_analytics_metrics` `{ metrics }`. A submission's `code` comes with `codeSource`: `relay` when Domino wrote it, `evaluator` when the quest's own rejection did. > > Directories that list servers can read the tools, with their descriptions and input schemas, without signing in, from the server card at `https://mcp.domino.run/.well-known/mcp/server-card.json`. Calling a tool still needs a token. Clients sign in with an OAuth Client ID Metadata Document; the authorization server offers no dynamic client registration. Cursor registers clients only dynamically or by a fixed client ID, so Domino publishes a metadata document for it at `https://mcp.domino.run/oauth/clients/cursor.json` and that URL is Cursor's client ID. It lists Cursor's web callback and its desktop app's, `http://localhost:8787/callback`. Without `scopes`, Cursor asks for every scope the authorization server lists. ## What the assistant can do [#what-the-assistant-can-do] **Read, in test and live:** | Ask about | What it reads | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Projects | Your projects, their live app, and counts of quests, members and pending reviews. | | Quests | Status, current release, whether someone paused it, rewards, prerequisites and budgets, including [per-giver caps](/docs/build/trackers#cap-what-each-giver-gives). | | Campaigns | Each campaign's window, whether members must enroll, and its quests. | | Submissions | Status, why one is waiting, and what the member sent. | | Members | Find by ID, name or email; accounts, point balances, groups and progress. | | Leaderboards | Standings. | | Offers | Cost in points, stock and redemptions. | | Usage | Active members this month against your plan's limit. | | Analytics | One metric from Domino's analytics catalog over a range, by day or week. | | Audit history | Reviews, reward changes, configuration, and actions as test members. | **Change, in test only:** * **Create test members** to try your quests. * **Act as a test member**, submitting a quest through the same checks and rewards a real member gets. * **Reset a test member**, deleting them and everything they did. This can't be undone. * **Review a submission** waiting for review: accept it, which awards its rewards, or reject it, with a reason the audit history keeps. * **Delete a submission's evidence**, the photo, text, link or quiz answers a member sent, once the submission is decided, with a reason the audit history keeps. The submission keeps its outcome and rewards. This can't be undone. ## What it can't do [#what-it-cant-do] * **No live changes.** Every change in live is refused: publishing, reviews and member changes in live happen in Console. * **No more than you can.** Your roles apply. Test members need permission to publish, and reviewing needs the review permission. * **No administration.** It can't publish quests, award points, deploy, or change who has access. * **No billing.** Usage shows numbers only. Plans and billing are in Console. ## Revoke access [#revoke-access] Remove the Domino connector in Claude or ChatGPT, or the `domino` server in Claude Code (`claude mcp remove domino`), VS Code or Cursor, to disconnect it. Domino verifies the connection's token on every request, so a revoked token stops working at once. Your access follows your organization membership. When an organization admin removes you, or changes your project roles in Console, the assistant's next request uses your new access. > [!DEV] > Each tool call reaches the management API as you, with the same membership checks and permission grants as Console and the administration limits of a CLI session. Every change in live is refused with `403` `console-approval-required`, including the few a `domino login` session may make there; see [what each credential may do in live](/docs/concepts/access#what-each-credential-may-do-in-live). # Build with an agent (/docs/agents) Read the owner's brief and existing project instructions first. Inspect the package manager, `relay.json`, login, and hosting before selecting a workflow. ```mermaid flowchart LR A["Read the brief"] --> B["Propose a direction"] B --> C{"Owner confirms?"} C -- "Not yet" --> B C -- "Yes" --> D["Connect and discover"] D --> E["Build from snippets"] E --> F["Verify in the app"] F --> G["Report evidence"] ``` > [!IMPORTANT] Keep authorization with the person > Have the user approve browser login and complete participant email codes or OAuth consent. Integration keys are created by an administrator in Console and saved directly into server secrets. Keep keys and codes out of chat, source, and browser bundles. ## Work through the integration [#work-through-the-integration] ### 1. Confirm the direction before implementation [#1-confirm-the-direction-before-implementation] For a new project or first Domino integration, use the Domino skill to research the brand and campaign context before building. Inspect the supplied website, assets, existing app, and brief. Extract the visual style and voice, then propose: * **A brand direction** with a small visual sample. * **A campaign brief** covering the goal, success measure, participants, actions, motivation, and journey. Ask only about consequential gaps and offer recommendations. Separate facts from assumptions, and keep unconfirmed rewards, budgets, dates, and eligibility out of participant promises. Present the proposal and wait for the owner's confirmation before implementation. Save the confirmed direction, sources, confirmation evidence, and open decisions in `CAMPAIGN.md` or the app's established brief document. Later sessions reuse those decisions; material changes reopen only the affected parts. This is an agent instruction, not a CLI enforcement mechanism. For a project that already has work, such as published quests or staging builds, skip this discovery. Read the project's agent instructions, README, and `CAMPAIGN.md`, review its quests, releases, and builds, summarize what exists, and ask the owner what to change next. Treat the saved brief as confirmed unless the owner says otherwise. ### 2. Establish the connection [#2-establish-the-connection] Use the project's installed CLI. Run `domino doctor --json`, then `domino doctor --remote --json` when remote access is needed. Outside a project, doctor reports setup health only. Its `agents` field lists the Domino skill loaders installed in the project, not which agent is running. Confirm the effective organization, project, and environment before publication. | Starting point | Rule | | -------------------------------------------- | ------------------------------------------------------------- | | [New hosted app](/docs/reference/hosted) | Check out the existing project rather than creating it again. | | [Existing app](/docs/reference/existing-app) | Preserve its repository, framework, login, and hosting. | > [!TIP] Upgrade before you work around > When SDK, docs, and server behavior disagree, read the [changelog](/docs/reference/changelog) and follow the [upgrade guidance](/docs/reference/troubleshooting#check-for-an-sdk-upgrade) before adding a workaround. Check for a newer release that addresses the failure, update the packages together, and retry the original action. ### 3. Discover supported behavior [#3-discover-supported-behavior] ```sh domino capabilities --json domino capabilities referrals --json domino reference participant-api domino reference troubleshooting ``` The [capability map](/docs/reference/capabilities) is generated from the same catalog bundled with the CLI. A capability's `reference` names a topic for `domino reference`; run `domino reference` without a topic to list every reference the installed CLI ships. * **It is release guidance, not a live probe.** Command output includes `provenance` with the CLI version and `serverVerified: false`: this is release guidance, not a live server or project capability probe. Scope flags do not change that. * **Unlisted means unknown.** Treat unlisted behavior and disagreements with the deployed API as unknown, and inspect the applicable contract before changing the architecture. * **Check some features separately.** Check daily recurrence, social verification, and prize fulfillment separately from points and leaderboards. * **It names the commands.** A capability's `cli` lists the `domino` commands that reach it, such as `points award` or `trackers ingest`. Operator reads and changes have typed `domino list|get|…` commands; use them instead of `domino api`. See [resource commands](/docs/reference/automation#resource-commands). * **Quote project analytics.** To answer how a project is doing, run `domino analytics get --range 7d|30d|90d|all` and quote its numbers instead of computing your own. See [project analytics](/docs/reference/automation#project-analytics). To split or narrow a number, read one metric from the closed catalog with `domino analytics query --metric ID`; `domino analytics metrics` lists them. See [metric catalog](/docs/reference/automation#metric-catalog). * **Measure the project's goals.** To choose metrics for what a project is for, or to build, check and explain a dashboard, read [measuring a project](/docs/reference/analytics) (`domino reference analytics`). * **Read the referral reference first.** For invitations, qualification, and inviter bonuses, read it before choosing the authoring and participant APIs. * **Check on-chain actions.** To reward what a member's linked wallet did on chain, an EVM contract event or a Solana transaction that called a program, read [on-chain actions](/docs/reference/onchain) (`domino reference onchain`). * **Reward X posts only for approved creators.** X does not allow apps that reward users for posting. When asked to reward people for posting on X, gate each quest that pays for X posts with an `inGroup(group)` prerequisite on a group the operator manages. See [rewarding X posts](/docs/reference/trackers#rewarding-x-posts). ### 4. Find a quest snippet [#4-find-a-quest-snippet] Before authoring a new quest, follow [the snippet workflow](/docs/reference/authoring#find-a-snippet). Fetch the [live catalog](https://snippets.domino.run/r/registry.json), inspect a matching recipe, then install and adapt its source. New catalog entries are available without updating the agent instructions. Run the project-local `domino agents install` to register the Domino skill for Codex and Claude in an existing project. Distributed starters already include the loaders and project instruction pointers. The full skill and its references live in `node_modules/@domino-sdk/relay-cli/cli/skills/domino`, so guidance stays matched to the CLI version installed in that checkout. `domino reference authoring` prints the release-specific instructions in the terminal. Install dependencies before reading the bundled skill. Keep project-specific rules in AGENTS.md. After a CLI upgrade, ask the agent to re-read the bundled skill; routine skill reinstallation is unnecessary. Existing conversations may still contain instructions read before the upgrade. For older checkouts, run `domino agents install` once after upgrading the CLI. It migrates known copied guidance, preserves project instructions, and adds marked pointers in AGENTS.md and CLAUDE.md. Customized files are reported before any changes; follow the [migration procedure](/docs/reference/existing-app). Commit the loader and instruction changes for the rest of the team. ### 5. Finish with evidence [#5-finish-with-evidence] Run the app's checks and exercise the requested action through the running app. Verify persisted progress, not only a build or doctor result. If participant sign-in is waiting for the user, report that verification as unfinished. For a campaign, simulate it in the test environment with test members over several simulated days, then fix what breaks: follow [campaign testing](/docs/reference/campaign-testing), or run `domino reference campaign-testing`. Return: * The working preview URL. * The action verified. * Any remaining launch dependency. Label simulated provider results explicitly. Live publication requires human review in Console. ### 6. Report tooling issues [#6-report-tooling-issues] Report each distinct issue you encounter with Domino tooling, including issues you worked around. Use `domino feedback --reporter agent --title "Short summary" --body-file report.md --json` and follow the [feedback instructions](/docs/reference/troubleshooting#report-a-tooling-issue). Include the returned Linear issue link in your handoff. Humans can use the same command without `--reporter agent`. Before every completed task's final handoff, also review difficulties that better SDK or CLI support could address, even if no command failed. Report unreported friction such as missing helpers, repetitive manual steps, confusing APIs, or workarounds. Describe the extra work and the concrete tooling improvement that would have helped. Submit only observed difficulties, without repeating earlier reports or sending a general task summary. If there were no such difficulties, skip the completion report. ## Unattended runs [#unattended-runs] To run an agent with nobody present, such as in CI, a scheduled job, or a sandbox, set `DOMINO_AGENT_MODE=unattended` in its environment. Setting it is your consent for the agent to build from your brief without asking you anything; the agent can't turn it on itself. `domino doctor --json` and `domino whoami --json` report the mode as `agentMode`. | The Domino skill normally | In an unattended run | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | Interviews you and waits for you to confirm | Treats your brief as confirmed and never waits. | | Asks about consequential gaps | Picks the most conservative reasonable option and lists it in `CAMPAIGN.md` under "Assumed in an unattended run". | | Sends you a sign-in code | Uses `RELAY_MANAGEMENT_TOKEN`. Browser login refuses with `UNATTENDED_LOGIN_UNAVAILABLE`. | | Submits tooling feedback to Domino | Saves it to `./domino-feedback.jsonl`, or `DOMINO_FEEDBACK_FILE`. `--submit` or `DOMINO_FEEDBACK=submit` sends it. | | Hands over and asks about open decisions | Ends with a written report and asks no questions. | Live publication still needs your review in Console. A personal API key can't create projects, so create the project in Console first; an organization [automation key](/docs/agents/automation) with **Create projects** can. Then, in the project's Test environment, create an API key with the **Read** and **Publish** permissions under **Settings → API keys** and give it to the agent as `RELAY_MANAGEMENT_TOKEN`. Start the agent with a prompt like this: ```text Build a Domino campaign from the brief below on a staging site. Nobody is available to answer questions. Run npx -y @domino-sdk/relay-cli@latest checkout PROJECT_ID, work in the directory it creates, and install dependencies with the command it prints. Then read and follow the Domino skill at node_modules/@domino-sdk/relay-cli/cli/skills/domino/SKILL.md, including its unattended runs section. Stage the result, and end with a report of what you built, the staging link, what you tested, and what you assumed. Brief: ``` ## Read only what the task needs [#read-only-what-the-task-needs] - [Authoring](/docs/reference/authoring) — Definitions, settings, and publication. - [Participant API](/docs/reference/participant-api) — Sign-in, controllers, and recovery. - [Referrals](/docs/reference/referrals) — Supported data ownership and limits. - [Campaign testing](/docs/reference/campaign-testing) — Simulate a campaign with test members, and what to watch for. - [Analytics](/docs/reference/analytics) — Choose metrics from a project's goals, and build and check dashboards. - [Troubleshooting](/docs/reference/troubleshooting) — Startup, staging, and access failures. - [CLI](/docs/reference/cli) — Commands and flags generated from executable help. - [Connect an AI assistant](/docs/agents/connect) — What an owner's assistant, such as Claude, ChatGPT, Claude Code, VS Code or Cursor, can read and try in test through Domino's MCP server. - [Run an agent without signing in](/docs/agents/automation) — An organization automation key for CI and headless agents, test environment only. | Source | Contains | | ------------------------------------------ | -------------------------------------------------- | | Every docs page | A Markdown copy. | | [/docs/llms.txt](/docs/llms.txt) | An index of the site. | | [/docs/llms-full.txt](/docs/llms-full.txt) | The complete public reference. | | The CLI | Release-specific guidance bundled for offline use. | ## Build achievements [#build-achievements] Ask your agent to define badges with `defineBadge`, include their definitions in `relay.json`, and attach `badge(...)` to quest rewards. The CLI publishes the definitions and quest rewards together. Participant clients read the catalog and member awards; your app composes any tiers or levels. See [badge authoring](/docs/reference/authoring#badges). # Use your app's login (/docs/build/app-login) If your community site already has its own login, members don't need a second account for Domino. Your server tells Domino who is signed in, and their quests and progress follow the login they already use. * **What members see.** They sign in to your site as usual. There is no Domino sign-in screen. They can still link wallets and connect X or Discord accounts. * **What you do in Console.** An organization admin creates the server key under **Settings → App login keys** and keeps it working there. Reviewers can unlink a member's app identity on the member's page. * **What your developer does.** Adds a small handler to your site's server and points the browser at it. Signing users in with Privy, Dynamic or Para? Their recipes give the whole server route: [Privy](/docs/build/privy), [Dynamic](/docs/build/dynamic), [Para](/docs/build/para). For Reown AppKit, which has no login token, see [Use Domino with Reown AppKit](/docs/build/reown). Fresh Domino-hosted apps can use the [member sign-in](/docs/build/hosted-app#add-domino-participant-sign-in) supplied with their setup instead, including [wallet sign-in](/docs/build/wallets#sign-in-with-a-wallet) where the project enables it. Domino's own wallet sign-in works only in apps Domino hosts; an app you host signs wallets in with its own login, as [wallet sign-in in your own Next.js app](/docs/build/self-hosted-wallet-sign-in) shows. > [!DEV] Connecting accounts > Members of an app with its own login link wallets and connect X or Discord accounts with `relay.auth.start({ provider, mode: "connect" })`. The integration needs your app to have a server route. ```mermaid sequenceDiagram actor User participant Browser as Browser client participant Server as Your server participant Domino User->>Browser: Sign in with your app's login Browser->>Server: Participant request with the app session Server->>Server: authenticate(request) verifies the session Server->>Domino: Stable user ID and registered project scope Domino-->>Server: Quests and progress Server-->>Browser: Participant response ``` ## Connect your app's login [#connect-your-apps-login] ### 1. Create an integration key [#1-create-an-integration-key] An organization administrator opens **Settings → App login keys** in Console, selects the project and environment, and registers the app's exact HTTPS origin. A **Test** key can also name a loopback origin, such as `http://localhost:3000`, for [local development](#develop-against-domino-on-localhost). To let tracker records name your app's user IDs, also fill in **Identity kind**; see [link your app's user IDs](#link-your-apps-user-ids). **Expiry** is **Never expires** unless you choose 30 days, 90 days or 1 year. For a Domino-hosted app, leave **Also save it as an app secret** checked. Console stores the new key as the app secret `APP_IDENTITY_KEY` in the key's environment, and nobody copies it. Change **App secret name** if your server reads another name. The app reads the key from its next build: the next staged build in Test, or the next build promoted to live. The box needs publish permission and appears only for a project whose code Domino hosts. For any other app, or with the box cleared, Console shows the key once. Save it directly in the application's server secrets as `APP_IDENTITY_KEY`. A key belongs to one project and environment and signs members in only there. A Domino-hosted app's staging build runs in **Test**, so it needs a **Test** key registered for its staging address, `https://--staging.domino.page`. The live app needs a **Live** key for its live address or custom domain. Console refuses a key for a Domino address in the environment that address doesn't serve, and names the right one; see [pick the environment](#pick-the-environment-for-the-address). > [!WARNING] A key that isn't saved as an app secret is shown once > Copy it straight into your server secrets. CLI login credentials are for management operations and cannot replace this key, and this key signs members in only. [Access](/docs/concepts/access#kinds-of-credentials) lists every kind of credential. > [!DEV] Handing the key to your developer or agent > Creating, rotating and revoking keys stays in Console. An organization admin's CLI session can list a project's keys for one environment, without their secrets. > > 1. The admin creates the key in Console. For a Domino-hosted app, Console saves it as the app secret in the same step, so step 2 is done. > 2. Otherwise the admin stores it without pasting it into chat: `printf %s "$KEY" | domino secrets set APP_IDENTITY_KEY` for a Domino-hosted app (add `--environment live` for the live app's secret), or the app's own server secret settings. > 3. `domino secrets set` checks a key before storing it and names the key it belongs to (`identityIntegration` in its `--json` result). It stores nothing and exits with `INVALID_ARGUMENT` when the key is unknown, expired or revoked, or belongs to another project or environment, such as a Live key set as Test's secret. When the key's origin is not the app's own address for that environment, it stores the key and warns with both origins. When it cannot reach Domino to check, it warns and stores the key unchecked. > 4. The developer or agent confirms it with `domino identity-integrations list --json`, which shows each key's origin, identity kind and the expiry and last use of its secrets, and `domino secrets list`. ### 2. Connect your server [#2-connect-your-server] Use `createParticipantBackend` from `@domino-sdk/relay/portal-proxy`. Its `authenticate(request)` function must verify the app's existing session and return a stable user ID. Mount the handler at `/relay/*`. ### 3. Connect the browser [#3-connect-the-browser] Use `createBrowserRelayClient({ baseUrl: "/relay" })` in the browser. The adapter supplies the registered project scope. You do not need to forward browser project headers or write your own identity exchange. The [complete server example](/docs/reference/participant-api#existing-application-login) explains routing, cookies, and error handling. If your app signs members in or out without loading a new page, tell the browser client each time: * **After your app's sign-in succeeds**, read the Domino session again before showing member pages. The page read it when it loaded, while nobody was signed in, and keeps that answer until it is asked again. Otherwise the member looks signed out for up to 30 seconds, or until the window regains focus. * **After your app's sign-out succeeds**, read the session again too. With your app's session gone, Domino finds nobody signed in and the member's data disappears as soon as that read answers. Sign out through your app only: Domino's own sign-out passes through your server's check of the app session, so while that session lasts, the page's next read signs the member straight back in. > [!DEV] Refresh the session in React > Under `RelayProvider`, call `useSession().refresh()` after your sign-in route answers and again after your logout route answers. `refresh()` never rejects. After sign-in it switches the member hooks to the new member and reads their data; after sign-out they report `signed-out` once a read answers `null`. When Domino refused the sign-in, `useSession()` reports `status: "error"` with the reason in `error`: show it instead of the member pages. Don't call `useSignOut()` with app login. > > ```tsx > import { useSession } from "@domino-sdk/relay/react" > > // `navigate` is your router's. > export function useAppLogin(navigate: (to: string) => void) { > const { refresh } = useSession() > > return { > async signIn(form: FormData) { > const response = await fetch("/api/login", { method: "POST", body: form }) > if (!response.ok) throw new Error("Sign-in failed") > // RelayProvider still holds the signed-out session it read on load. > await refresh() > navigate("/members") > }, > async signOut() { > await fetch("/api/logout", { method: "POST" }) > // With the app session gone, the read finds nobody and drops the member's data. > await refresh() > navigate("/") > }, > } > } > ``` ### 4. Verify through the app [#4-verify-through-the-app] * Sign in normally and check that Domino recognizes the member. * Sign in without reloading the page and check that member pages show the member at once. * Complete one quest and refresh. * Sign out of your app and confirm that member access is removed. * Sign out without reloading the page and check that the member's data is gone without waiting for a poll. * If the app supports account switching, check that too. > [!NOTE] The remote doctor cannot check your session code > A successful `doctor --remote` result checks management configuration. It cannot verify the app's session implementation or integration secret. ## Troubleshoot sign-in [#troubleshoot-sign-in] ### Pick the environment for the address [#pick-the-environment-for-the-address] Create the key in the environment whose members the app signs in: | The app's address | Create the key in | | ----------------------------------------------------------------------------- | --------------------------------------------------- | | Staging build, `https://--staging.domino.page` | **Test** | | Development preview, `https://--.domino.page` | **Test** | | Live app, `https://.domino.page` | **Live** | | Your custom domain, which serves the live app | **Live** | | Your own server's address | Either. Pick the environment its members belong to. | | `http://localhost:`, `http://127.0.0.1:` or `http://[::1]:` | **Test** | For a staging, preview or live `domino.page` address in the other environment, **Create server key** refuses with a message that names the right environment, such as "…is a staging app, which signs members in to Test. Create this app login key in Test." Keys created before this check are left as they are; **Disconnect** one and create it again in the right environment. The address must be an exact origin: `https://` and the host, with an optional port, and nothing after it. When it has a path, a query or a trailing slash, Console names the origin it accepts, such as "Use the exact origin [https://app.example.com](https://app.example.com), without a path, query or trailing slash." Plain `http://` works only on a loopback host and only in **Test**: elsewhere Console answers "Use an HTTPS origin, or a loopback origin such as [http://localhost:3000](http://localhost:3000) for a Test key.", and on a **Live** key "Live app login keys need an HTTPS origin. Loopback origins such as [http://localhost:3000](http://localhost:3000) work only on Test keys." ### Why Domino refused a sign-in [#why-domino-refused-a-sign-in] When Domino refuses the sign-in exchange, `createParticipantBackend` answers the browser with Domino's status and code. Only a 409 and `ACCOUNT_VOUCHING_OFF` carry Domino's own message; every other refusal shows a general one, so your users never read instructions meant for your developer. | Status | `code` | Meaning | Who fixes it | | ------ | ------------------------------------ | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | 401 | `IDENTITY_INTEGRATION_ACCESS_DENIED` | The key is unknown, expired or revoked, or isn't an app login key. | Your developer sets a working key from Console as `APP_IDENTITY_KEY`. | | 400 | `INVALID_IDENTITY_EXCHANGE` | The server sent something besides a user ID, `previousToken` and up to 20 `accounts`. | Your developer. | | 403 | `ACCOUNT_VOUCHING_OFF` | The server sent `accounts`, but the key doesn't vouch for accounts. | An admin [turns on vouching](#turn-it-on), or your developer stops sending `accounts`. | | 403 | `live-offline` | The project's live app is offline because it has no plan. | The organization [chooses a plan](/docs/concepts/promotion#going-live-needs-a-plan). | | 409 | `APP_IDENTITY_UNLINKED` | An organizer unlinked this user ID. | An organizer [relinks it](#unlink-an-app-identity). | | 409 | `APP_IDENTITY_CONFLICT` | The key already signs this user ID in as another member. | An organizer. | | 429 | `IDENTITY_INTEGRATION_ACCESS_DENIED` | Too many sign-ins for this key. The answer's `Retry-After` says how many seconds to wait. | Nobody: retry after the wait. | After sign-in, a participant request answers 403 `IDENTITY_INTEGRATION_ACCESS_DENIED`, "Application origin does not match the identity integration", when the `origin` your server passes to `createParticipantBackend` is not the key's origin. Create a key for the address the app runs at. The key alone picks the project and environment for these requests. On the private staging address, they don't need the staging pass that a hosted app's own `/relay/*` proxy forwards, so app login works there as it does on the live address. A 403 `staging-access-required` comes from a request without the key. > [!DEV] Log refusals on your server > Pass `onExchangeError(error, request)` to `createParticipantBackend` to see each refused exchange as Domino answered it. `error` is a `ParticipantExchangeError` with `status`, `code`, Domino's `message` and `retryAfter` in milliseconds. The browser's answer stays the same, and an error thrown in the callback doesn't change it. > > ```ts > import { createParticipantBackend } from "@domino-sdk/relay/portal-proxy" > import { verifyAppSession } from "./your-existing-server-auth" > > export const participant = createParticipantBackend({ > baseUrl: "https://relay.domino.run", > origin: "https://app.example.com", > integrationKey: process.env.APP_IDENTITY_KEY!, > authenticate: async (request) => { > const user = await verifyAppSession(request) > return user ? { subject: user.id } : null > }, > onExchangeError: (error) => { > console.error("Domino sign-in refused", error.status, error.code, error.message) > }, > }) > ``` ## Test the login in a browser [#test-the-login-in-a-browser] A browser test can run your real server handler, identity exchange included, against [the fake participant API](/docs/build/testing#test-your-apps-ui) instead of Domino. Your app can then run at a local address such as `http://127.0.0.1:4173` or `vite` dev's `http://localhost:5173`. > [!DEV] Run the real adapter against the fake > In test mode, give `createParticipantBackend` the local origin, any HTTPS `baseUrl`, and the fake's `fetch`. Here an `E2E` environment variable, set by the command your Playwright `webServer` runs, selects test mode: > > ```ts > import { createParticipantBackend } from "@domino-sdk/relay/portal-proxy" > import { createFakeRelay } from "@domino-sdk/relay/testing" > import { verifyAppSession } from "./your-existing-server-auth" > > const authenticate = async (request: Request) => { > const user = await verifyAppSession(request) > return user ? { subject: user.id } : null > } > > export const participant = process.env.E2E > ? createParticipantBackend({ > baseUrl: "https://relay.test", > origin: "http://127.0.0.1:4173", > integrationKey: "di_test", > authenticate, > fetch: createFakeRelay({ member: "member_ada" }).fetch, > }) > : createParticipantBackend({ > baseUrl: "https://relay.domino.run", > origin: "https://app.example.com", > integrationKey: process.env.APP_IDENTITY_KEY!, > authenticate, > }) > ``` > > The test then signs in through your app's own login and checks the page, with no `page.route` for `/relay`. The fake runs in the server process, so seed it there; the test cannot read its `requests`. It also keeps one state for every test that server serves: after a test signs out, later sign-ins become the member whose ID is your app's user ID, or fail with a `TestSetupError` when that user ID is not a valid member ID, so run sign-out tests last or give them their own server. Set `E2E` only in the `webServer` command. > > `origin` may be `http://localhost`, `http://127.0.0.1` or `http://[::1]`, with any port; any other `http:` origin throws. On these origins the `domino_app_session` cookie is not `Secure`, because WebKit drops `Secure` cookies on `http://localhost`. The origin and `X-Relay-CSRF` checks are the same as in production. `baseUrl` must still be HTTPS. > > A real key works on a local origin only when it was created for that exact origin, port included: against `https://relay.domino.run`, a key for another origin signs the user in through the exchange, then every participant request is refused with 403 `IDENTITY_INTEGRATION_ACCESS_DENIED`. The exchange signs the user in as a real member and, for a key with an identity kind, links the user ID, so keep real keys out of automated tests. ## Develop against Domino on localhost [#develop-against-domino-on-localhost] To run your app on your machine against real Domino, create a **Test** key for its local address, such as `http://localhost:3000`, and give `createParticipantBackend` the same `origin` with `baseUrl: "https://relay.domino.run"`. Members sign in to the project's **Test** environment. A **Live** key needs an HTTPS origin, so create a separate **Live** key for the deployed address. Each port is its own origin: a key for `http://localhost:3000` refuses requests from `http://localhost:3001`. [Wallet sign-in in your own Next.js app](/docs/build/self-hosted-wallet-sign-in#develop-on-localhost) walks through it. ## Link your app's user IDs [#link-your-apps-user-ids] Your servers can report activity for your own users, such as a game reporting a win for user `42`, and a quest can pay the member who signs in as that user. To allow it, give the key an identity kind, such as `app:game`, when you create it. The key then links each user ID your server signs in to its member, as an identity of that kind in the project and environment. Your servers can send [tracker records](/docs/build/trackers#your-apps-user-ids) that name those user IDs, and Console lists them on the member under **Identities**. An identity kind is for your app's own user IDs. To pay a member's Telegram, X or Discord account, [vouch for it](#vouch-for-your-members-accounts) instead: X quests and Telegram and Discord trackers then pay it as an account the member linked. * **A kind belongs to the key that first claimed it.** In a project and environment, a kind names the user IDs of one app. While its key is connected, no other key can be created with it. Another project, or the other environment, has its own. * **The kind is set when the key is created.** It cannot be added to or changed on an existing key. Rotating the key keeps it and every link. * **A user ID is linked when the user signs in.** Records for a user ID nobody has signed in with yet are skipped. A linked user ID always signs in as its member. * **Name the kind** `app:` and up to 40 lowercase letters, digits, `_` or `-`. Leave the kind empty for an integration that only signs users in. > [!DEV] > Tracker records name the kind with `identities: ["app:game"]`. A record for a user ID nobody has signed in with yet is skipped with `unlinked-account`. Evaluators read the member's identities with `ctx.member.identities()`; see [Evaluator readers: null, errors and pinning](/docs/reference/verification#evaluator-readers-null-errors-and-pinning). ### Map members to your user IDs [#map-members-to-your-user-ids] Your server can look up the member a user ID is linked to, and list the user IDs linked to each member, with the management client. Both read only linked user IDs: one an organizer [unlinked](#unlink-an-app-identity) is not found until it is relinked. > [!DEV] > `management.members.lookup({ provider: "app:game", subject: "42" })` answers the member holding user `42`, with `id` and `appIdentities`, or fails with 404 `member-not-found`. The lookup is exact, including letter case, so send a user ID in the form your server signed the user in with or the import used. Going the other way, `members.people(ids)` (up to 100 member IDs) and `members.list()` list each member's `appIdentities` as `{ kind, subject }`, as does `member` in `members.get(id)`, and `members.list({ q })` also matches part of a user ID. All need `read` permission. ### Reuse a kind after disconnecting its key [#reuse-a-kind-after-disconnecting-its-key] **Disconnect** keeps the kind's links: tracker records that name those user IDs still pay their members. A new key can take the kind only by adopting its identities, because each linked user ID then signs in as its member, and another app's user `42` would sign in as this app's user `42`. When you type a kind whose disconnected key left identities, **Create server key** shows how many user IDs it links. Adopt them only if the new key's user IDs mean the same users: check **This app's user IDs mean the same users** and give a reason, which the audit log keeps with your identity. Without adoption the key is refused; use a new kind for a different app. A kind with no identities left is reused freely. > [!DEV] > In code, pass `adoptIdentitiesFrom` with the old key's ID, from `management.identityIntegrations.kinds()`, and a `reason`. Without adoption the key is refused with 409. ### Unlink an app identity [#unlink-an-app-identity] A reviewer can unlink a member's app identity in Console, on the member under **Identities**. Unlinking: * stops tracker records that name the user ID from paying anyone, and removes it from the member's identities that new attempts read; * ends the member's sessions that the kind's key started for that user ID; * refuses that user ID's sign-in until it is relinked, so the user cannot reach the member through your app. **Relink** restores it for the same member and rechecks the tracker records that were skipped while the user ID was unlinked. The quest's [historical activity](/docs/concepts/trackers#historical-activity) policy decides what happens to earlier activity: it is admitted automatically, or kept for the member or staff to release. Attempts already admitted keep their original attribution. Unlink and relink both need a reason and **Review** permission, and the audit log keeps both. See [operator identity changes](/docs/concepts/participants#operator-identity-changes). > [!DEV] Unlink and relink in code > Call `management.members.identityStates.unlink(member, { identity: { kind: "app", appKind, subject }, expectedRevision, reason })`, where `appKind` is the kind, such as `app:game`, and `expectedRevision` is the `revision` that `identityStates.list(member)` returned. Unlinking removes the user ID from `ctx.member.identities()` for new attempts. > > `management.members.identityStates.relink` takes the same input. It schedules re-decisions of records skipped with `unlinked-account`. Under the quest's `historical: "automatic"` policy, eligible earlier activity is admitted; `"claim"` or `"staff"` keeps earlier activity as a historical skip for the member or staff to release. > > Every unlink and relink raises the identity's `revision`, and a relink sets a new `linkedAt`. `management.members.unlinkIdentity` and `relinkIdentity(member, { kind, subject, reason })` are deprecated forms of the same commands, without the revision check. When Domino refuses a sign-in, your site can show the member why. `createParticipantBackend` answers the browser with status 409 and one of these codes: | `code` | Meaning | | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `APP_IDENTITY_UNLINKED` | An organizer unlinked this user ID. Ask them to relink it. | | `APP_IDENTITY_CONFLICT` | This key already signs the user ID in as another member than the one the kind links it to. Only an organizer can resolve it. | ## Vouch for your members' accounts [#vouch-for-your-members-accounts] If your app already knows its users' X, Telegram or Discord accounts or wallets, its server can attach them when it signs each user in. Members then don't have to connect them again. X quests and Telegram and Discord trackers pay them from that moment, as if they had linked the accounts themselves. This is the same trust as a [member import](/docs/use/imports#what-an-import-records), for members who join after it. ### Turn it on [#turn-it-on] Vouching is off for every key. An organization admin who also has **Publish** permission turns it on in **Settings → App login keys**: under **Connected apps**, choose **Allow vouching** on the key, give a reason, and confirm. The audit log keeps your identity and reason. **Stop vouching** turns it off the same way. Accounts already vouched for stay. Allow it only when your app verifies the accounts it sends, because Domino trusts them like accounts the member proved. ### What Domino does with each account [#what-domino-does-with-each-account] * **Attached.** The account is linked to the member. Console marks it **Vouched by** and your key's name, under the member's **Identities** or **Wallets**. * **Already linked.** The member has it already. Nothing changes, so your server can send the same accounts every time it signs a user in. * **Refused.** It belongs to another member, or the member already has a different X, Telegram or Discord account. An account an organizer unlinked from this member, or the member disconnected, is refused too, so your app never undoes an unlink. A wallet is also refused when the member already has 5 wallets of that chain besides the one they sign in with. A refused account never stops the sign-in. Domino tells your server which accounts it refused and why. Vouched accounts follow the rules of imported ones: * A vouched account never replaces an account the member linked, nor one vouched for earlier. * When the member links an account of that kind themselves, their link replaces the vouched one. * Nobody signs in with a vouched account. * When an organizer unlinks a vouched account the member never signed in with or proved, Domino releases it: its owner can link it and sign in with it, and your app's next exchange can't vouch it back for that member. See [operator identity changes](/docs/concepts/participants#operator-identity-changes). * A leaderboard's [payout list](/docs/build/leaderboards#export-the-payout-list) leaves out vouched wallets. When the member links the wallet through Domino, signing Domino's own message in your app with `relay.wallets.link`, it becomes a wallet they proved and the payout list includes it. See [pay prizes to the wallet](/docs/build/self-hosted-wallet-sign-in#pay-prizes-to-the-wallet). > [!DEV] Sending accounts > Return `accounts` from `authenticate` in `createParticipantBackend`, next to `subject`: up to 20 entries, wallets as `{ kind: "wallet", chain, address }` (plus `network` for Cosmos), and at most one each of `{ kind: "x" | "telegram" | "discord", subject }` with the numeric user ID. Pass `onAccounts(results, request)` to read each account's `status`: `attached`, `already-linked` or `refused`, with a `code` such as `account-linked-elsewhere`, `identity-unlinked`, `x-account-selected`, `telegram-account-selected`, `discord-account-selected` or `limit-reached`. > > A server that signs users in without a browser calls `exchangeParticipant({ baseUrl, integrationKey, subject, accounts })` from `@domino-sdk/relay/portal-proxy`. It answers the session `token` and the same `accounts` results, and throws `ParticipantExchangeError` when the exchange is refused: a `RequestError` of kind `http` with the answer's `status`, Domino's `code`, and `retryAfter`, the milliseconds its `Retry-After` asks to wait. `isParticipantExchangeError(error)` recognizes it. Pass the previous `token` as `previousToken` to keep using that session while it lasts. > > `inspectIntegrationKey({ baseUrl, integrationKey })` from the same entry answers which key an app login key is: its `id`, `name`, `organization`, `project`, `environment`, `origin`, `identityKind`, `vouchesAccounts`, `secretExpiresAt` (null when it never expires) and `appOrigins`, the addresses Domino serves the project's app from in that environment. It never returns a secret, and checking a key does not count as using it. A key that is unknown, expired or revoked, or whose project was deleted, throws `ParticipantExchangeError` with status 401 and `invalid-credential`. > > With vouching off, an exchange that sends accounts is refused with status 403 and `ACCOUNT_VOUCHING_OFF`, and nobody is signed in. Domino never drops the accounts silently. Operators read `vouchedBy` (`{ integration, integrationName }`) next to `imported: true` on the member's accounts, identity states and wallets. `management.identityIntegrations.setAccountVouching(id, { enabled, reason })` changes the setting from a Console session; a retry with the same `actionId` answers with the first result. ## Maintain the connection [#maintain-the-connection] A key has one or more secrets, and each works until it is revoked or reaches its own expiry. Each key in **Connected apps** lists its secrets with their last four characters, when they expire and when they were last used. A key created before secrets could be rotated keeps its original expiry until you remove it. | Action | Effect | | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Add secret** | Issues a second secret that never expires. Both secrets work. With **Also save it as an app secret** checked, Console replaces the app secret with the new secret; deploy to use it. Otherwise copy the new one with **Copy key**, update `APP_IDENTITY_KEY` on every server, and deploy. Member sessions stay signed in. | | **Revoke**, then **Revoke secret** | Stops one secret at once. Wait until its **Last used** time stops changing first. A key's only secret has no **Revoke**. | | **Remove expiry** | Makes an expiring secret never expire. | | **Disconnect**, then **Disconnect now** | Stops every secret and ends the integration's member sessions. | > [!WARNING] An expired key signs everyone out > When a key's last secret expires, the integration stops and every member session it started ends. Add a secret before an expiry, or remove the expiry. > [!DEV] > In code, `management.identityIntegrations.addSecret(id)` returns the new secret as `token`; `createIntoAppSecret(input, name)` and `addSecretIntoAppSecret(id, name)` store it as the app secret `name` in the key's environment instead, answer `appSecret` and no `token`, need publish permission, and leave no key or secret when the app secret is refused. Like every key change, they work only from an administrator's Console session. `revokeSecret(id, secret)` revokes one, and `removeSecretExpiry(id, secret)` removes an expiry. The deprecated `rotate(id)` replaces every secret at once, so the old secret stops immediately. > [!CAUTION] A new integration means new identities > Creating a new integration creates a new identity namespace. Rotate the existing integration when you want to keep identity continuity. An integration with an identity kind is the exception: a new integration that [adopts](#reuse-a-kind-after-disconnecting-its-key) a disconnected key's kind signs each user ID it linked in as the same member. # Record consent from a checkbox (/docs/build/consent) > [!NOTE] Experimental > The consent API is experimental: `relay.consent` and `useConsent()` can change in a minor release. Your app shows the checkbox; Domino records what the member chose, when, and the words beside the checkbox. See [marketing consent](/docs/concepts/consent) for what Domino keeps. ## 1. Show the checkbox with its wording [#1-show-the-checkbox-with-its-wording] Put a checkbox where the member decides, such as their account settings or after sign-in. Write the label your members agree to, including a link to your privacy policy. Domino stores that label word for word as the consent wording, so keep it as one string. Leave the box unchecked until the member checks it. ## 2. Send each change [#2-send-each-change] When the member checks the box, record the opt-in with the label they saw. When they uncheck it, record the opt-out. Each takes one call. > [!DEV] > With React, `useConsent()` reads the signed-in member's consent and changes it: > > ```tsx > import { useConsent } from "@domino-sdk/relay/react" > > const label = > "Email me news and offers. See our privacy policy: https://example.com/privacy" > > export function MarketingEmail() { > const consent = useConsent() > const email = consent.data?.find((entry) => entry.channel === "email") > > return ( > > ) > } > ``` > > Without React, call the client directly: > > ```ts > import { createBrowserRelayClient } from "@domino-sdk/relay/browser" > > const relay = createBrowserRelayClient({ baseUrl: "/relay" }) > const label = > "Email me news and offers. See our privacy policy: https://example.com/privacy" > > const current = await relay.consent.get() > await relay.consent.set({ channel: "email", granted: true, wording: label }) > await relay.consent.set({ channel: "email", granted: false }) > ``` > > `get()` lists one entry per channel the member chose on, each with `member`, `channel`, `state` (`granted` or `withdrawn`), `at` (epoch milliseconds, Relay's clock), `wording` and `source` (`member`, or `imported` for consent a [member import](/docs/use/imports) carried over, whose `at` is the old platform's time). A channel missing from the list has no record. `set()` answers the channel's current entry. `grant` and `withdraw` resolve true once saved and put a failure in `actionError`. ## 3. Check what was recorded [#3-check-what-was-recorded] Read the member's consent back and show the checkbox from it, so the box matches what Domino has. Sending the same choice and wording again records nothing new. > [!DEV] > Domino refuses a change, with HTTP 422 and one of these codes, and records nothing: > > | Code | Cause | > | --- | --- | > | `consent-wording-required` | An opt-in without wording. Withdrawals may leave it out. | > | `consent-wording-too-long` | Wording over 2,000 characters. | > | `consent-wording-invalid` | Wording that is not plain text: a NUL character, or half of an emoji cut off. | > | `unsupported-consent-channel` | Any channel but `email`. | > > `set()` takes an optional `actionId`: retrying with the same one records the change once. Domino stamps the time itself; a body that sends one is refused. # Dashboards (/docs/build/dashboards) A dashboard is a page of analytics panels that your project code defines. Each panel reads one metric from Domino's catalog, so its numbers match `domino analytics query`. You deploy a dashboard in test, check it with `domino dashboards get`, and promote it to live like any other definition. For the model behind this guide, read [Analytics](/docs/concepts/analytics). To choose metrics from a project's goals and explain a dashboard's numbers with an agent, see the [analytics reference](/docs/reference/analytics). ```mermaid flowchart LR C["defineDashboard"] --> R["relay.json dashboards"] R --> D["domino deploy (test)"] D --> G["domino dashboards get"] D --> P["Promotion"] P --> L["Live"] ``` ## Define a dashboard [#define-a-dashboard] ### 1. Choose the metrics [#1-choose-the-metrics] Run `domino analytics metrics` to list the catalog. Each metric names the breakdowns, filters and charts it accepts, and whether it needs a point ledger. ### 2. Write the definition [#2-write-the-definition] Default-export `defineDashboard` from a file anywhere in your project: ```ts // dashboards/growth.ts import { defineDashboard } from "@domino-sdk/relay/authoring" export default defineDashboard({ key: "growth", title: "Growth", description: "Sign-ups, quest completions and points.", defaultRange: "30d", position: 1, sections: [ { panels: [ { key: "sign-ups", title: "Sign-ups", metric: "members.signUps", chart: "number" }, { key: "xp", title: "XP awarded", metric: "points.awarded", ledger: "xp", chart: "line" }, { key: "completions", title: "Completions by quest", metric: "quests.completions", breakdown: "quest", chart: "bar", width: 4, }, ], }, { title: "VIPs", panels: [ { key: "vip-rejections", title: "Why VIP attempts are rejected", metric: "quests.rejections", breakdown: "reason", filter: { group: "vip" }, chart: "table", }, ], }, ], }) ``` The types only accept the breakdowns, filters and charts the panel's metric allows. A `ledger`, or a filter key, can be a key or a handle from `defineLedger`, `defineQuest` and the other `define…` functions. ### 3. List it in relay.json [#3-list-it-in-relayjson] ```json "dashboards": [{ "entry": "dashboards/growth.ts" }] ``` Add `exportName` when the file exports several dashboards. ### 4. Deploy and check it [#4-deploy-and-check-it] Run `domino deploy`. The preview lists each dashboard and whether publishing creates it, updates it, or leaves it `unchanged`. Then read it back: ```sh domino dashboards get growth --range 7d ``` It prints every panel's numbers, or the panel's error, as an operator would see them. Add `--json` for the full read. ## What a dashboard holds [#what-a-dashboard-holds] | Part | Fields | | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | Dashboard | `key`, `title`, optional `description`, optional `defaultRange` (`7d`, `30d`, `90d` or `all`; default `30d`), optional `position`, and `sections` | | Section | Optional `title` and its `panels`, in order | | Panel | `key`, `title`, optional `description`, `metric`, optional `breakdown`, optional `filter`, `ledger` for points metrics, `chart`, and optional `width` | Panels sit on a 4-column grid. `width` is 1 to 4, and defaults by chart: | Chart | Breakdown | Default width | | -------- | ----------- | ------------- | | `number` | Not allowed | 1 | | `line` | Optional | 2 | | `bar` | Optional | 2 | | `table` | Required | 4 | A broken-down `line` or `bar` panel reads the top 8 rows, with the rest summed into Other. A `table` reads up to 100 rows. ## What a deployment refuses [#what-a-deployment-refuses] A deployment fails with a coded error, and publishes nothing, when a dashboard has: | Code | Cause | | ---------------------------------------- | -------------------------------------------------------------------------------------------------- | | `unknown-metric` | A metric the catalog doesn't have | | `breakdown-not-accepted` | A breakdown the metric doesn't take, or a `number` panel with a breakdown | | `breakdown-required` | A `table` panel without a breakdown | | `filter-not-accepted` | A filter the metric doesn't take | | `chart-not-accepted` | A chart the metric can't draw, such as a rate as `bar` | | `ledger-required`, `ledger-not-accepted` | A points metric without `ledger`, or `ledger` on another metric | | `unknown-reference` | A quest, tracker, ledger, offer, referral program or leaderboard key this environment doesn't have | | `referral-program-required` | A referral metric in a project with no referral program | | `duplicate-key` | Two dashboards with one key, or two panels with one key in a dashboard | | `too-many-panels` | More than 24 panels in a dashboard | | `too-many-dashboards` | More than 20 dashboards that aren't archived in the project | Archived quests, ledgers and other resources are valid references: their history keeps counting. Groups are checked when a panel is read, not at deploy. ## Read a dashboard [#read-a-dashboard] | Command | Management client | Returns | | --------------------------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `domino dashboards list [--include-archived]` | `dashboards.list({ includeArchived })` | Each dashboard's key, title, description, position, archive time and last update, by position (those without one last), then title | | `domino dashboards get KEY [--range R]` | `dashboards.read({ key, range })` | The definition, the range read, and each panel's result or error | Both need `read` permission. A read uses the dashboard's `defaultRange` unless you pass one. Each panel's result is what `domino analytics query` returns for the same metric, breakdown and filters. A panel that fails carries its own `error` while the other panels still have results: | Error | Cause | | ----------------- | ----------------------------------------------------------------------- | | `group-not-found` | The panel filters on a group this environment doesn't have | | `panel-timeout` | The panel's query took too long; try a shorter range | | `panel-failed` | The panel couldn't be read for another reason; read the dashboard again | ## Change, archive and promote [#change-archive-and-promote] * **Change it in code.** Deploying a changed definition publishes a new version. Deploying an identical one publishes nothing. * **Removing it from relay.json doesn't delete it.** Archive it instead: `domino dashboards archive KEY --reason "…"`, or `dashboards.archive(key, { reason })` with the management client. Restore it with `unarchive`, unless the project already has 20 dashboards that aren't archived (`too-many-dashboards`). Both need `publish` permission, work in test only, and are recorded in the audit log with the reason. * **An archived dashboard stays readable.** It leaves `dashboards list` unless you pass `--include-archived`, and deploying its code again leaves it archived. * **Live gets dashboards by promotion.** A [promotion](/docs/concepts/promotion) carries each dashboard to live by key, archive state included. Groups aren't copied, so a panel filtered on a group live doesn't have reads `group-not-found` there. ## View dashboards in Console [#view-dashboards-in-console] Open **Analytics** and pick a dashboard in the switcher beside the range. The switcher lists **Project analytics** first, then the dashboards that aren't archived, in `dashboards list` order. A dashboard has its own address, `/analytics/KEY`. * **Range.** A dashboard opens at its `defaultRange`. Choose 7 days, 30 days, 90 days or All time to read it over another range. * **Panels.** Sections and panels follow the definition on a 4-column grid at each panel's `width`, in one column on a narrow screen. A `number` panel shows the total and the change against the previous range, a `line` or `bar` panel the series (one line or stacked bar per row, top 8 and Other, when broken down), and a `table` panel each row's total and change. Archived quests, offers and programs are labelled **Archived**. * **States.** A panel with nothing in the range says **No activity in this range**. A rate below its minimum shows **—**; hover it for the minimum. A panel that failed shows its error, with **Retry** for `panel-timeout` and `panel-failed`, and the other panels still show. * **Archive.** In test, people with `publish` permission can choose **Archive dashboard** and give a reason. An archived dashboard's address still opens it, with an **Archived** banner and **Unarchive dashboard**. Live has neither action: dashboards reach live by promotion. Dashboards change in code; Console can't edit them. ## Share dashboards with a sponsor [#share-dashboards-with-a-sponsor] Give a sponsor, client or partner the **Viewer** role to show them the project's dashboards and nothing else. Viewers see no members, evidence, quests or settings, and dashboards show only totals, never individual members. 1. Invite them to your organization: **Settings → Access → Team → Manage members**. 2. Under **Project permissions**, choose them, check **Viewer**, and check the environments whose dashboards they see, usually **Live**. 3. To end their access on its own, such as 30 days after an event, pick the day under **Access ends (UTC, optional)**. 4. Choose **Save project access**. When they sign in, Console shows them only **Analytics**, opened on the project's first dashboard, with the switcher listing the others. **Project analytics** isn't theirs: it can show individual members. A project with no dashboards in that environment shows **No dashboards yet**. After the end date their access stops, and the grant stays listed as **Expired**. > [!NOTE] The viewer role is experimental > It can change in a minor release. > [!DEV] > A viewer holds one permission, `dashboards`, which reads `dashboards.list` and `dashboards.read` and nothing else. Every other role, and any key with `read`, reads dashboards too. See [Give people access to a project](/docs/use/projects#give-people-access-to-a-project). # Preview from your own computer (/docs/build/development) Your developer can run the app on their own computer and share an HTTPS link to it, so you can click through a change before anyone stages it. The preview uses test data, so nothing it does reaches live members. * **The link works only while it runs.** When your developer stops the preview, the link goes offline. For a link that stays up, [stage the pushed commit](/docs/build/staging). * **No computer needed in Console.** [Playground](/docs/build/playground) runs the same kind of preview for you in **Developers → Build**. `domino dev` is the command behind it: it runs the app on the device against hosted test data and shares it through an HTTPS preview URL. ```mermaid flowchart LR Visitor["Visitor"] --> URL["HTTPS preview URL"] URL --> App["Dev server on your device"] CLI["domino dev"] -->|"starts"| App CLI -->|"synchronizes authored behavior"| Test["Hosted test data"] App --> Test ``` ## Start a preview [#start-a-preview] ### 1. Install from the lockfile [#1-install-from-the-lockfile] For a Domino starter, install dependencies from the lockfile. ### 2. Check the remote configuration [#2-check-the-remote-configuration] ```sh pnpm exec domino doctor --remote --json ``` ### 3. Start development [#3-start-development] ```sh pnpm exec domino dev ``` The CLI: * connects the app to hosted test data, * synchronizes authored behavior, * starts the development server, * prints an HTTPS preview URL. Members sign in through the app. | Option | Use it to | | ---------------------- | --------------------------------------------------------------------------------------- | | `--port ` | Choose the port the app's dev server listens on; the CLI passes it as `DOMINO_APP_PORT` | | `--cloudflared ` | Use a Cloudflare Tunnel connector you installed, instead of the one the CLI downloads | * **One preview per checkout.** A checkout keeps the same preview address between runs. Starting a second preview from it fails with "This worktree already has a running preview" until the first stops, or 90 seconds after it stopped responding. * **Sleep does not stop the app.** If your device sleeps or pauses past those 90 seconds, `domino dev` takes the preview back when it resumes and reconnects it without restarting your dev server. It stops instead if another run started this checkout's preview in the meantime, or if Domino refuses the preview, for example because you lost publish permission. * **Experimental.** `domino dev`'s result, output, and preview transport may change in a minor release. In Console's **Developers → Build**, use **Select** to point the agent at an element, **Console** to inspect browser messages and attach them to a prompt, the device menu to check 768px and 390px layouts, and the address bar with **Back** and **Forward** to open and move between pages. The address follows navigation inside the preview. See [preview controls](/docs/build/playground#point-to-a-change-in-the-preview). The development-preview gateway supplies these tools without changing the app repository. Staged apps do not include the bridge. ### 4. Edit resources while it runs [#4-edit-resources-while-it-runs] The CLI checks source files for changes about once a second, skipping `node_modules`, `dist`, `.git`, `.domino`, `.domino-build`, and `.wrangler` directories. This keeps quest synchronization from consuming the sandbox's file watcher limit. Your app's development server handles app hot reload separately. * **New files join automatically.** Resource files matching the `discover` patterns in `relay.json` join the next synchronization. Adding a `.quest.ts` or `.quest-type.ts` file under the starter's `quests/` directory does not need another manifest entry. * **Each update reports itself.** After publishing changed resources, `domino dev` prints `Hosted test quests updated.` An update that fails prints `Quest update failed:` and the error, and keeps the last published version. The next good update after a failure prints `Hosted test quests are up to date.` when it publishes nothing new, for example after you revert the change that broke it. Playground's [Map](/docs/build/playground#see-the-campaign-as-a-map) shows the failure until then. * **Deleted files are not archived.** Deleting a file stops including it in future bundles; it does not archive the remote quest. Run `domino quests archive --reason "…"`, or archive it in Console. See [Archive a quest](/docs/build/publishing#archive-a-quest). ## Share the HTTPS URL [#share-the-https-url] Preview addresses use `https://--.domino.page`. | Part | Details | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `` | Globally unique, derived from the project name: **Phantom Breakpoint 2026** becomes `phantom-breakpoint-2026`. A name whose slug is taken, reserved by Domino, or shorter than 3 characters receives a short suffix. Slugs stay fixed, use lowercase letters, numbers, and hyphens, and are limited to 35 characters. Find yours under **Project settings → General → Project slug**. | | `` | 19 characters. Separates workspaces and makes addresses hard to guess. Restarting the same workspace keeps its address. | > [!WARNING] Share the printed URL, not localhost > Share the URL printed by the CLI, not `localhost` or `127.0.0.1`. Requests to that URL reach the app on your device while development is running. Stopping development takes it offline. Use the preview for work you are ready to show. The app is reachable through that address; member sign-in still controls member access. ## Check before you stage [#check-before-you-stage] ```sh pnpm test pnpm exec domino check pnpm build pnpm exec domino deploy --preview --environment test --json ``` `pnpm test` runs your tests, including the quest unit tests in `tests/quests.test.ts` ([Test quests locally](/docs/build/testing)). `check` runs configured app checks, bundles quest definitions, and validates offers, badges and your other data definitions as Relay will, naming the file and field of any that fail. Remote preview validates the effective settings. > [!IMPORTANT] Try the main action yourself > Neither check proves that a member can finish the main action. Open the app and try it. ### Act as a test member on the preview [#act-as-a-test-member-on-the-preview] The preview has the same [test toolbar](/docs/build/staging#try-quests-with-the-test-toolbar) as staging, for whoever opens it with a preview link: act as a test member, move them to the next day, and pass their quests, before anything is staged. Your coding agent uses it to check the app's signed-in screens. Everyone else who opens the preview sees the app as before, without the toolbar. A preview link works only on the preview it was minted for, and only while that preview runs. > [!DEV] Preview links > `POST /management/v1/staging/link` with `preview` set to the URL `domino dev` printed (`domino api POST /staging/link body.json` with `{ "path": "/", "preview": "https://--.domino.page" }`) returns a one-minute URL on that preview, as a staging link does. Opening it sets a session for that preview host alone, valid 12 hours, which `domino dev`'s app never receives. The link names the same operators a staging link does, and Domino checks their `publish` permission and key on every toolbar request. A URL that is not a running preview of the project answers 404 `preview-not-found`, and the test environment is required. Without the link's session, the preview's `/__domino/toolbar/` paths answer 403 `operator-required`. In every routing mode, the preview reserves `/__domino/staging-access` for the link and the `/__domino/toolbar/` paths for the toolbar. It answers `/__domino/toolbar.js` with the toolbar's script only for the link's session, and adds that script only to that session's pages. The agent steps are under "Check participant flows on staging" in the Domino skill's campaign testing reference (`references/campaign-testing.md`). For a persistent preview, [stage the pushed commit](/docs/build/staging). ## Configure an existing app [#configure-an-existing-app] To develop an app you already have, add an `app` section to its `relay.json` and make its dev script listen on `DOMINO_APP_PORT`. [Configure a hosted app](/docs/build/hosted-app) covers the settings, routing, member sign-in, Worker variables, secrets, storage, and the management key your server code receives. ## When Vite is ready but the preview does not start [#when-vite-is-ready-but-the-preview-does-not-start] In Console's Playground, select **Upgrade with AI** if the project needs a newer CLI or still uses bundled package archives. The Playground checks for CLI 1.0.0 or newer before starting the preview, while keeping the agent available. Older CLIs build quests the API no longer accepts, which shows as `Unsupported evaluator protocol` when the preview starts. It does not change your dependencies until you select the upgrade action. After the agent finishes, select **Start the preview**. If Vite reports that it is ready but the CLI says "The app did not start. Check its dev script.", check the CLI version installed in your project. Older CLIs check the app without requesting HTML. Apps with a browser-only page fallback, including the Classic starter, can return 404 to that check even though the page works in a browser. Upgrade the project's `@domino-sdk/relay-cli` and restart the preview. Current readiness checks request HTML from both the local app and its shared preview URL. If startup still fails, confirm that the dev script listens on `DOMINO_APP_PORT` and that `/` returns a successful response to a request with `Accept: text/html`. ## When Vite blocks the preview host [#when-vite-blocks-the-preview-host] Current starters configure the preview hostname from `DOMINO_PREVIEW_ORIGIN` through [`dominoHosting()`](/docs/build/hosted-app#run-the-dev-script-on-the-preview-port). An older app may show an allowed-host error. To fix it: * On the Cloudflare Vite plugin, use `dominoHosting()`'s `server` settings, or add the exact preview hostname to Vite's `server.allowedHosts`. * Configure HMR for that HTTPS preview. * Preserve your existing Vite settings. Do not allow every host. ## When Vite reaches the file watcher limit [#when-vite-reaches-the-file-watcher-limit] If Vite exits with `ENOSPC` and `syscall: 'watch'`, the environment could not allocate another file watcher. Reduce unnecessary watched directories or enable Vite's `server.watch.usePolling`. Polling uses more CPU. Domino's hosted playground and build sandboxes configure at least 524,288 watches per user at startup. For `domino dev` on your own Linux machine, check `fs.inotify.max_user_watches` on that machine. See [Vite's file watcher troubleshooting](https://vite.dev/guide/troubleshooting#vite-crashes-with-enospc-error). ## Always on test data [#always-on-test-data] `domino dev` always uses the hosted test environment with real sign-in and providers. The local runtime, `--offline`, and `--review-mode` options have been removed. Keep your project in the `test` environment while developing. > [!DEV] Output streams > Development subprocess output goes to stderr so stdout remains available for the CLI result. Use `--json` for a structured result when the development session stops; progress and app logs continue on stderr while it runs. ## Troubleshoot member sign-in [#troubleshoot-member-sign-in] | Symptom | What to do | | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | A hosted app shows "Participant sign-in is unavailable" | Domino has not finished configuring sign-in on the shared API. Contact Domino; changing your app will not fix it. | | Your server route receives 404 "Participant requests must come through a registered app or identity integration" | Check that the app runs as a staged build or a `domino dev` preview. | | You use an SDK release before 0.10.0 | Pass `hosted: true` to `proxyParticipant`. | For configuration or build failures, read [Troubleshooting](/docs/reference/troubleshooting). # Use Domino with Dynamic (/docs/build/dynamic) If your app signs users in with [Dynamic](https://www.dynamic.xyz), they can use Domino's quests, points and leaderboards without a second login. Your server checks the user's Dynamic JWT and tells Domino who they are and which wallets Dynamic verified for them. Their embedded wallet and the wallets they connected join their Domino wallets, so wallet quests count them straight away. This is [app login](/docs/build/app-login) with Dynamic's JWT as the session. It needs a server route, such as a [Domino-hosted app's Worker](/docs/build/hosted-app) or your own backend. ```mermaid sequenceDiagram actor User participant Browser as Your app participant Server as Your server participant Domino User->>Browser: Log in with Dynamic Browser->>Server: Token on /relay Server->>Server: Verify it Server->>Domino: User ID, wallets Domino-->>Browser: Quests ``` ## Before you start [#before-you-start] * **An app login key that vouches for accounts.** An organization admin [creates the key](/docs/build/app-login#1-create-an-integration-key) for your app's address and [turns on **Allow vouching**](/docs/build/app-login#turn-it-on). Your server reads it as `APP_IDENTITY_KEY`. * **Your Dynamic environment ID.** It is in the Dynamic dashboard, next to your API keys. Save it as `DYNAMIC_ENVIRONMENT_ID`. Use the sandbox environment's ID with Domino's Test environment and the live one with Live. ## Connect Dynamic [#connect-dynamic] ### 1. Add the server route [#1-add-the-server-route] Mount this module on `/relay/*` in your server, with your app's address as `origin`. It fetches the environment's public keys from Dynamic and accepts the JWT as an `Authorization: Bearer` header. ```ts import { createParticipantBackend } from "@domino-sdk/relay/portal-proxy" import { createRemoteJWKSet, errors, jwtVerify } from "jose" type Credential = { format?: string address?: string chain?: string wallet_provider?: string } const chains: Record = { eip155: "eip155", evm: "eip155", solana: "solana", } /** The wallets in a Dynamic JWT's `verified_credentials`, embedded wallets first, at most 20. */ export function dynamicWallets(credentials: Credential[]) { const embedded = (credential: Credential) => credential.wallet_provider === "embeddedWallet" return credentials .filter( (credential) => credential.format === "blockchain" && credential.address && chains[credential.chain ?? ""] ) .sort((a, b) => Number(embedded(b)) - Number(embedded(a))) .slice(0, 20) .map((credential) => ({ kind: "wallet" as const, chain: chains[credential.chain ?? ""], address: credential.address ?? "", })) } /** Verifies the Dynamic JWT the browser sends against the environment's public keys. */ export function dynamicAuthenticate(config: { environmentId: string }) { const jwks = createRemoteJWKSet( new URL( `https://app.dynamicauth.com/api/v0/sdk/${config.environmentId}/.well-known/jwks` ) ) return async (request: Request) => { const token = request.headers .get("Authorization") ?.match(/^Bearer (\S+)$/)?.[1] if (!token) { return null } try { const { payload } = await jwtVerify<{ scope?: string verified_credentials?: Credential[] }>(token, jwks, { issuer: `app.dynamicauth.com/${config.environmentId}`, algorithms: ["RS256"], }) // A session still waiting on MFA or a required form has no `user:basic` scope yet. if (!payload.sub || !payload.scope?.split(" ").includes("user:basic")) { return null } return { subject: payload.sub, accounts: dynamicWallets(payload.verified_credentials ?? []), } } catch (error) { // A forged, expired or misaddressed token is a signed-out visitor. Anything else, such as // the provider's keys failing to load, is a fault. if ( [ errors.JWTClaimValidationFailed, errors.JWTExpired, errors.JWSSignatureVerificationFailed, errors.JWSInvalid, errors.JWTInvalid, errors.JWKSNoMatchingKey, errors.JOSEAlgNotAllowed, ].some((type) => error instanceof type) ) { return null } throw error } } } let backend: ReturnType | undefined /** The `/relay/*` handler, built once so the verifier keeps its keys between requests. */ export function participant(env: { APP_IDENTITY_KEY: string DYNAMIC_ENVIRONMENT_ID: string }) { backend ??= createParticipantBackend({ baseUrl: "https://relay.domino.run", origin: "https://app.example.com", integrationKey: env.APP_IDENTITY_KEY, authenticate: dynamicAuthenticate({ environmentId: env.DYNAMIC_ENVIRONMENT_ID, }), }) return backend } ``` The route signs nobody in for a missing, expired or forged token, one from another Dynamic environment, or one whose `scope` lacks `user:basic`: Dynamic issues that while the user still owes a multi-factor check or a required onboarding field. > [!DEV] What the route sends > - **Wallets only.** EVM and Solana wallets from `verified_credentials` entries with `format` `blockchain`. Email and social credentials, and wallets on chains Domino doesn't link, are left out, because Domino refuses the whole exchange when one account is invalid. Dynamic documents `eip155` as an EVM wallet's `chain`, and its SDK objects say `evm`, so the route accepts both. > - **Embedded wallet first, at most 20.** Domino attaches up to 5 wallets per chain family and refuses the rest with `limit-reached`, and an exchange carries at most 20 accounts. Sending the wallet whose `wallet_provider` is `embeddedWallet` first keeps it from being the one refused. > > On a Domino-hosted Worker, call it from your `fetch` handler: `if (new URL(request.url).pathname.startsWith("/relay/")) return participant(env)(request)`. `participant` builds the handler on the first request and reuses it, so the provider's keys are fetched once, not on every request. ### 2. Send the JWT from the browser [#2-send-the-jwt-from-the-browser] Give Domino's browser client a `getToken` that returns Dynamic's JWT, and pass the client to `RelayProvider`. Use `getAuthToken()`, the full JWT: `getMinAuthToken()` returns a minified JWT that stays the same size however many wallets the user adds, so it cannot list them all. ```tsx import { createBrowserRelayClient } from "@domino-sdk/relay/browser" import { RelayProvider } from "@domino-sdk/relay/react" import type { ReactNode } from "react" // From @dynamic-labs/sdk-react-core: the JWT, or undefined when nobody is logged in. declare function getAuthToken(): string | undefined const relay = createBrowserRelayClient({ baseUrl: "/relay", getToken: async () => getAuthToken() ?? null, }) export function Community({ children }: { children: ReactNode }) { return {children} } ``` When the user logs in or out with Dynamic, for example in `DynamicContextProvider`'s `events.onAuthSuccess` and `events.onLogout`, call `useSession().refresh()` so the page reads the Domino session again, as [app login](/docs/build/app-login#3-connect-the-browser) explains. ### 3. Link the embedded wallet for payouts [#3-link-the-embedded-wallet-for-payouts] A leaderboard's [payout list](/docs/build/leaderboards#export-the-payout-list) leaves out vouched wallets, because the member never proved them to Domino. To pay prizes to the embedded wallet, ask the member to sign Domino's message with it once. The signature proves the wallet, and it stays linked as if the member had linked it themselves. Dynamic doesn't hand out its embedded wallet as an EIP-1193 provider, so `evmSigner` doesn't fit. Wrap the wallet's own `signMessage` in a signer instead. Pick the embedded wallet from `useUserWallets()`, not `primaryWallet`, which is whichever wallet the user chose, such as MetaMask: ```tsx import type { WalletSigner } from "@domino-sdk/relay" import { useWallets } from "@domino-sdk/relay/react" // From @dynamic-labs/sdk-react-core: useUserWallets().find((wallet) => // wallet.connector.isEmbeddedWallet && wallet.chain === "EVM"). declare const embeddedWallet: { address: string signMessage(message: string): Promise } const dynamicSigner: WalletSigner = { chain: "eip155", name: "Dynamic", address: async () => embeddedWallet.address, async sign(message) { const signature = await embeddedWallet.signMessage(message) if (!signature) { throw new Error("The wallet returned no signature.") } return { signature } }, } export function useProveEmbeddedWallet() { const { link } = useWallets() return () => link(dynamicSigner) } ``` The message states that it authorizes no transaction or transfer. A Dynamic smart wallet (`wallet_provider` `smartContractWallet`) proves itself the same way: Domino checks a [smart-contract wallet's signature](/docs/build/wallets#limits) on its chain. Give its signer a `chainId()` that returns the chain the wallet is on, such as `8453` for Base; without one, Domino checks the signature on Ethereum. See [Bring your own wallet provider](/docs/build/wallets#bring-your-own-wallet-provider) for the other signers. ### 4. Check it [#4-check-it] * Log in with Dynamic and check that the app shows the member's Domino quests. * In Console, open the member's page: **Wallets** lists the Dynamic wallets, marked **Vouched by** your key's name. * Prove the embedded wallet and check that it is no longer marked **Vouched by**. * Log out of Dynamic and check that the member's data disappears. > [!NOTE] Checked against Dynamic's documentation > The route is tested against Dynamic's documented JWT (RS256, issuer `app.dynamicauth.com/`, the `user:basic` scope, `verified_credentials`) with a throwaway key served as the environment's JWKS. It has not yet been run against a live Dynamic environment. Which `chain` values Solana wallets carry in the JWT, and whether the minified JWT keeps any wallets, are not yet confirmed against one. ## Where next [#where-next] - [Use your app's login](/docs/build/app-login) — keys, refusals and how to test the login in a browser. - [Wallets](/docs/build/wallets) — wallet quests, limits and what operators see. # Write your first quest (/docs/build/first-quest) A quest is one thing you ask members to do, such as "Claim your welcome bonus" or "Post a link to your setup". This guide builds one in code, so it is mostly for your developer or coding agent. Members see the finished quest in your app and complete it there. * **No code needed for existing types.** In Console, open **Quests** and add a quest from a type your project already has. See [Manage quests](/docs/use/quests). * **Code adds new behavior.** A new way to check proof, a new kind of submission, or a reusable quest type starts here. * **Console still edits the wording.** Steps, images and account hints of a quest from code can be changed in Console. See [Change it in Console](#change-it-in-console). For the model behind this guide, read [Quests](/docs/concepts/quests). See [Compose a campaign](/docs/composition) for a complete example. Prefer a working recipe? [Start from a snippet](#start-from-a-snippet). ## Publish your first quest [#publish-your-first-quest] ### 1. Write the quest [#1-write-the-quest] Add a TypeScript module to the package that has `@domino-sdk/relay` installed. This example is the welcome quest from the distributed starter. It accepts a member's claim once and grants 10 points. ```ts import { pointsLedger } from "../ledgers" import { defineQuest, settings, manual, once, points, } from "@domino-sdk/relay/authoring" // Tour stop 1 · claim. No evidence: one tap records the completion. // Keep the "welcome" key. The starter's release checks complete this quest. export default defineQuest({ key: "welcome", title: "Complete your first quest", settings: settings.object({}), presentation: () => ({ instructions: "One tap, no evidence. Domino records your completion and grants the reward. Everything else on this page builds on that loop.", }), trigger: manual(), completion: once(), rewards: [points(pointsLedger, 10)], }) ``` ### 2. Define the points ledger it pays [#2-define-the-points-ledger-it-pays] The quest imports `pointsLedger` from `../ledgers`, so the module does not bundle without it. If your project already defines a ledger, import that handle instead. Otherwise save this as `ledgers.ts` next to `relay.json`: ```ts import { defineLedger } from "@domino-sdk/relay/authoring" export const pointsLedger = defineLedger({ key: "points", name: "Points" }) ``` Publish the ledger by merging this property into `relay.json`: ```json "pointLedgers": [ { "entry": "ledgers.ts", "exportName": "pointsLedger" } ] ``` Publication rejects a quest whose reward names a ledger the environment doesn't have. See [Points and ledgers](/docs/concepts/points). ### 3. Save it where discovery finds it [#3-save-it-where-discovery-finds-it] Save the example as `quests/welcome.quest.ts`. New projects include these patterns in `relay.json`: ```json "discover": { "quests": ["quests/**/*.quest.ts"], "questTypes": ["quests/**/*.quest-type.ts"] } ``` * **Existing projects opt in.** Add these patterns to an existing project's manifest to enable discovery. * **Explicit entries only.** Omit a discovery array or set it to `[]` to disable discovery for that resource kind. Explicit entries still build and pass `domino check`. * **One resource per file.** Every matching file must default-export its resource. Helpers stay in ordinary `.ts` files. * **Paths are relative to `relay.json`.** Change the patterns for another source layout. * **Nothing runs on the build host.** Discovery bundles source without executing it. * **The SDK is not copied into each quest.** When the installed SDK ships a [shared runtime](/docs/concepts/quests#shared-sdk-runtime), the bundle imports it, and the deployment carries it once. > [!WARNING] Deleting a file does not archive the quest > Keep quest keys stable when renaming files. Duplicate keys fail server preview. Removing a source file does not archive the deployed quest; [archive it](/docs/build/publishing#archive-a-quest) with `domino quests archive` or in Console. `domino deploy` and `domino stage` warn with the keys of the quests still published from files you removed. ### 4. Preview and publish to test [#4-preview-and-publish-to-test] ```sh pnpm exec domino deploy --preview --environment test --json pnpm exec domino deploy --environment test --json ``` The first command validates the deployment against test without publishing. The second publishes it to test. Both include every quest in the project; to publish only the quest you changed, add `--only` with its key, as in `domino deploy --only daily-check-in`. See [publish only some quests](/docs/build/publishing#publish-only-some-quests). If either command gets no answer to its preview, it exits 1 without publishing anything, so run it again; a lost answer to the publication itself is resumed instead, as [Publish to test](/docs/build/publishing#2-publish-to-test) explains. ### 5. Try it in the app [#5-try-it-in-the-app] Open the app, sign in, and claim the quest. Confirm that completion survives a refresh. > [!NOTE] A direct claim verifies nothing external > This acceptance rule demonstrates a direct claim; it does not verify an external activity. ## Start from a snippet [#start-from-a-snippet] Snippets are ready-made quests your developer installs and adjusts: a photo upload, a quiz, a quest your staff confirm, a photo nobody else has sent, and checks on X profile pictures and X Community screenshots. Install one into a project that already has the SDK and CLI: ```sh pnpm dlx shadcn@4.21.0 add https://snippets.domino.run/r/photo-quest.json ``` Replace `photo-quest` in the URL to install another recipe: | Recipe | Name in the URL | | ------------------------ | ---------------------- | | Photo quest | `photo-quest` | | Quiz | `quiz` | | Staff-confirmation quest | `staff-confirmation` | | X profile picture | `x-profile-picture` | | Unique photo | `unique-photo` | | X Community screenshot | `community-screenshot` | Each recipe installs editable source under `quests/` and instructions under `docs/snippets/`. Installation does not publish anything. * **Read and customize.** Follow the installed instructions, then customize the key and settings. * **Keep secrets server-side.** Keep server verification code out of your browser bundle. * **Match your layout.** The default discovery patterns include installed quest files automatically. With a different layout, move the source to a matching directory. * **Older CLIs.** If your installed CLI does not yet support `discover`, use an [explicit entry](#override-or-register-an-explicit-entry). * **Pin a version.** Install a pinned registry version at `/v//.json`. ## Override or register an explicit entry [#override-or-register-an-explicit-entry] Add an entry to the `quests` array in your existing `relay.json`. Preserve the connection and other entries: ```json { "entry": "quests/welcome.quest.ts", "provider": "workers-ai", "settings": {} } ``` * **Overrides, never duplicates.** An explicit default-export entry overrides the same discovered resource without adding a second copy. * **Named exports.** Use `exportName` for named exports. * **Settings.** Entry settings and setting renames still control deployment defaults; Console custom values remain in effect. * **Provider.** Discovered files inherit `discover.provider`, which defaults to `workers-ai`. Development uses the hosted test environment with real providers; simulated review providers are no longer accepted. ## Repeatable or automatic activity [#repeatable-or-automatic-activity] A quest can pay each member once, once per thing they do (each order, each announcement they react to), or up to a shared limit. | Use | When | | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `once()` | Each member completes the quest once. | | `keyed(...)` | Each member can complete it more than once. The key identifies the rewarded occurrence, such as an announcement message or an order. | | `keyed(..., { across: "members" })` | Each key pays only once across all members, such as a single-use code or a post that pays once. See [keys shared by members](/docs/concepts/quests#keys-shared-by-members). | | Named budgets | You want to limit successful completions or awarded points across several quests. | See [automatic and repeatable quests](/docs/reference/quest-rules#automatic-and-repeatable-quests). To reward activity Domino or your server collects, such as Discord reactions or paid orders, trigger the quest from a [tracker](/docs/build/trackers#trigger-a-quest-from-tracker-records). Install `discord-announcements` to demonstrate automatic rewards from a project integration. It runs without a claim button and can stay hidden from members. Configure the channel and message publication cutoff, then connect Discord as described in [project integrations](/docs/build/integrations#automatic-announcement-reactions). ## Collect a response or link [#collect-a-response-or-link] A quest can ask members to type a response, such as feedback, or paste a link, such as their X post. Use `evidence.text({ minLength: 20, maxLength: 2000 })` for feedback, or `evidence.url({ allowedHosts: ["x.com", "twitter.com"] })` for a link. Pass it to `manual({ input: ... })`. Both are required by default. Read the captured string through `ctx.input.value` in your evaluator. The campaign starter includes text and URL controls with validation and interrupted-submission recovery. Existing apps must add these controls and declare `text` and `url` in `supportedInteractions` before publishing such quests. > [!IMPORTANT] > A response or URL is a member's claim. Relay does not fetch submitted URLs. Parse X post links with `xPostId`, then verify through `ctx.x.post({ postId: id })`, which returns `null` for a post that does not exist; do not trust the username in the link as proof of ownership. See [text and URL submissions](/docs/reference/verification#text-and-url-submissions) for options and [participant submission controls](/docs/reference/participant-api#submit-text-or-urls) for the SDK flow. ## Codes and other secrets [#codes-and-other-secrets] Don't use a text or URL quest for something members must not see or guess, such as a code hidden at an event station. * **Members read back what they typed.** Domino returns a submitted response to the member who sent it, in their quest list, their attempts and their submissions. See [reads that return submitted input](/docs/reference/participant-api#reads-that-return-submitted-input). * **Any signed-in member can submit.** Your app's participant proxy forwards submissions from the browser, so a member can send guesses to Domino directly, past any limit your app puts on its own form. Check the code in your app's server instead, and tell Domino what it proved: ### 1. Define a tracker and a quest it triggers [#1-define-a-tracker-and-a-quest-it-triggers] Declare a [custom tracker](/docs/build/trackers#feed-another-source-including-a-blockchain-indexer) whose records name a Domino member and the station they found, never the code. A quest triggered by that tracker pays each member once per station. ```ts import { automatic, customSource, decision, defineLedger, defineQuest, defineTracker, points, settings, z, } from "@domino-sdk/relay/authoring"; const pointsLedger = defineLedger({ key: "points", name: "Points" }); export const stationVisits = defineTracker({ key: "station-visits", title: "Station visits", source: customSource("station-visits", { identities: ["member"], data: z.object({ station: z.string().max(64) }), }), window: { start: Date.parse("2026-10-01T00:00:00Z") }, }); export default defineQuest({ key: "station-codes", title: "Find the station codes", settings: settings.object({}), trigger: automatic({ tracker: stationVisits, on: "new-record" }), completion: ({ keyed }) => keyed(({ tracker }) => "station:" + tracker.record.data.station), review: () => ({ enabled: false }), rewards: [points(pointsLedger, 100)], async evaluate() { return decision.accept(); }, }); ``` ### 2. Check the code in your app's server [#2-check-the-code-in-your-apps-server] Keep the codes in an [app secret](/docs/build/hosted-app#set-app-secrets). A server route finds the signed-in member, limits how often they can guess, checks the code, and sends a record for the member and station. ```ts import { signedInMember } from "@domino-sdk/relay/portal-proxy"; import { hostedManagementClient, type HostedManagementEnv } from "@domino-sdk/relay/server"; type Env = HostedManagementEnv & { DOMINO_PARTICIPANT_API?: string; STATION_CODES: string }; // Your own limit, such as a counter per member in your app's KV storage. declare function allowGuess(env: Env, member: string): Promise; export async function checkStationCode(request: Request, env: Env) { const member = await signedInMember(request, env, { apiUrl: "https://relay.domino.run" }); if (!member) { return new Response("Sign in first", { status: 401 }); } if (!(await allowGuess(env, member))) { return new Response("Too many guesses", { status: 429 }); } const { code } = (await request.json().catch(() => ({}))) as { code?: unknown }; if (typeof code !== "string") { return new Response("Send a code", { status: 400 }); } // STATION_CODES holds the codes by station, such as {"north-gate":"7KQ2"}. const codes: Record = JSON.parse(env.STATION_CODES); const station = Object.keys(codes).find((key) => codes[key] === code.trim()); if (!station) { return Response.json({ found: false }); } const at = Date.now(); await hostedManagementClient(env).trackers.ingest("station-visits", { actionId: crypto.randomUUID(), records: [ { id: `${member}:${station}`, author: { id: member }, occurredAt: at, observedAt: at, data: { station }, }, ], }); return Response.json({ found: true, station }); } ``` The code never reaches Domino, and the browser can't write to the tracker: only your server holds the key that can. > [!DEV] > The hosted app's own management key has `ingest` permission. The record ID is the member and station, so entering a station's code again sends the same record, and `on: "new-record"` runs the quest only for its first observation. Ingestion never joins a member to the project: a member who never signed in to your app is skipped with `not-joined`, so report only members `signedInMember` returns. `packages/sdk/examples/station-code-quest.ts` is the complete quest with a record helper and a points setting. ## Review the quality of text [#review-the-quality-of-text] A quest can have AI judge a member's text against your own requirements, such as "mentions the launch date and isn't a copy of our announcement". Clear passes and fails are decided automatically. Unclear text goes to your team when human review is on. Use `ctx.review.text({ text, criteria })` in the evaluator. For example, an evaluator can read a member's verified X tracker record and review its `content`. Handle all three results: | Result | Quest decision | | --------- | -------------------------------------------- | | `pass` | `decision.accept()` | | `fail` | `decision.reject({ reason: result.reason })` | | `unclear` | `decision.review({ reason: result.reason })` | This reviews content quality, not account ownership or AI authorship. Verify ownership through the source capability first. Use `evidence.text()` to collect text directly or `evidence.url()` to collect an X post link. > [!DEV] > The capability accepts up to 16,000 units of text and 2,000 units of criteria, measured as JavaScript string lengths. Provider failures throw a `CapabilityError` with code `provider-unavailable`, never a passing or failing result. Review text is untrusted, including instructions embedded in an X post; keep author criteria separate from member content. Credentials, retries and the remaining limits are in [Review text with AI](/docs/reference/verification#review-text-with-ai). ## Add participant presentation [#add-participant-presentation] Presentation is what members read on the quest: ordered steps with links, an image, and hints about the account or wallet they need. A step only explains what to do. Opening its link does not submit evidence or complete the quest; the evaluator still checks the activity. ### Change it in Console [#change-it-in-console] Open a quest's **Presentation** section in Console to edit its steps, image and account hints. * **A quest made from a type.** Review and publish its draft. **Reset to type** restores the type's presentation. * **A quest synced from code.** **Publish presentation to test**, or **to live** in live, publishes immediately, and **Reset to code** restores the authored values when published. Overrides replace these fields together and survive later deployments. Instructions, videos and quizzes continue to come from the definition and its settings. ### Set defaults in code [#set-defaults-in-code] Return presentation from `defineQuest` or `defineQuestType`: ```ts import { defineQuest, manual, once, settings } from "@domino-sdk/relay/authoring" export default defineQuest({ key: "read-the-guide", title: "Read the participation guide", settings: settings.object({}), presentation: () => ({ image: "/images/workshop.png", imageAlt: "The workshop entrance", steps: [ { title: "Read the guide", description: "Review the participation rules before continuing.", action: { kind: "link", url: "https://example.com/guide", label: "Open guide" }, }, { title: "Return here and continue" }, ], requirements: [{ kind: "wallet" }], }), trigger: manual(), completion: once(), rewards: [], }) ``` | Field | Meaning | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `steps` | Up to 30 steps in display order. Each has a title, optional description, and optional action. | | `steps[].action` | Currently `{ kind: "link", url, label }`. The URL must use HTTPS. | | `image`, `imageAlt` | Optional image URL and alt text. Images accept HTTPS or a same-site path beginning with `/`. | | `requirements` | Optional account or wallet hints for the member. Use `{ kind: "account", provider: "google" }` or `{ kind: "wallet" }`; a wallet hint can include `chain`, using an existing wallet family. | The participant view already exposes `connections` for declared X and Discord integrations. Use that list alongside `presentation.requirements`; the hints do not add eligibility checks. Keep verification in the evaluator. ## Keep going [#keep-going] - [Test quests locally](/docs/build/testing) — Unit-test the evaluator with fake capability answers before you publish. - [Authoring](/docs/reference/authoring) — Configurable settings, reusable types, and publication ownership. - [Participant controllers](/docs/reference/participant-api) — Render the quest in your app. # Set up your hosted app (/docs/build/hosted-app) Domino can host your community site for you. Your team builds and checks it on test data, and members visit the live app at `.domino.page` or at your own domain. A starter project comes with a working setup, so most of this page is for your developer. You can do these in Console without code: * **Set app secrets**, such as an API key for your analytics service, under **Settings → App secrets**. See [Set app secrets](#set-app-secrets). * **Use your own domain**, such as `quests.example.com`, under **Settings → Domains**. See [Use your own domain](#use-your-own-domain). * **Check background jobs** under **Settings → Schedules**, and the data your app stores under **Settings → Storage**. For how previews, staging and the live app fit together, read [Hosted apps](/docs/concepts/hosting). ## What members see [#what-members-see] Members visit the live app, on live data. Your team works on a preview and on the staging app, which use test data. A build reaches members when someone approves a [promotion](/docs/concepts/promotion) in Console. ## Configure the app section [#configure-the-app-section] Every Domino-hosted app is described by the `app` section of `relay.json`: what kind of app it is, how to develop and build it, and what its server code receives. Starters ship with this section filled in. Edit it when you adapt an existing app or add server features. | App | `kind` | Output | | --------------------------------- | ---------- | ---------------------------------- | | Static site | `"static"` | A public output `directory` | | Server-rendered app or API server | `"worker"` | A compiled Cloudflare Worker entry | Merge an app section like this into your existing configuration. This example fits TanStack Start with the Cloudflare Vite plugin: ```json { "app": { "kind": "worker", "routing": "transparent", "directory": "dist/server", "main": "index.js", "assetsDirectory": "dist/client", "compatibilityDate": "2026-09-16", "compatibilityFlags": ["nodejs_compat"], "devScript": "dev", "checkScript": "typecheck", "buildScript": "build" } } ``` | Field | Applies to | Meaning | | ----------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------- | | `kind` | Both | `"static"` or `"worker"` | | `directory` | Both | Build output: the public files of a static site, or a Worker's private modules | | `devScript`, `checkScript`, `buildScript` | Both | `package.json` scripts that `domino dev`, `domino check`, and the cloud build run | | `routing` | Both | `"transparent"` or `"legacy"` (the default); see [routing](#choose-a-routing-mode) | | `main` | Worker | The compiled `.js` or `.mjs` entry, relative to `directory` | | `assetsDirectory` | Worker | Optional public files served beside the Worker; omit it for an API-only app | | `compatibilityDate`, `compatibilityFlags` | Worker | The Workers runtime date (`YYYY-MM-DD`) and up to 50 flags | | `runWorkerFirst` | Worker | Send every request to the Worker, including ones matching public files; see [requests](#how-requests-reach-your-worker) | | `vars` | Worker | Non-secret string [variables](#configure-worker-variables) | | `secrets` | Worker | [Secret](#set-app-secrets) declarations, without values | | `storage` | Worker | [KV and SQL stores](/docs/build/storage) | | `schedules` | Worker | [Scheduled jobs](/docs/build/schedules) | * **Paths.** Paths are relative to `relay.json`. * **Other frameworks.** They work when their adapter emits compiled Worker ES modules. * **Node servers.** A Node server that listens on a port needs a Workers adapter before staging. * **Unknown fields fail.** The CLI rejects `app` fields this table does not name. ## Run the dev script on the preview port [#run-the-dev-script-on-the-preview-port] The dev script must listen on `DOMINO_APP_PORT`. For a Vite app on the Cloudflare Vite plugin, as the starters are, `dominoHosting()` from `@domino-sdk/relay/vite` supplies these settings: ```ts import { cloudflare } from "@cloudflare/vite-plugin"; import { defineConfig } from "vite"; import { dominoHosting, serverBoundary } from "@domino-sdk/relay/vite"; export default defineConfig(({ command }) => { const domino = dominoHosting({ command }); return { plugins: [serverBoundary(), cloudflare(domino.cloudflare)], server: domino.server, }; }); ``` * **`domino.server`** listens on `DOMINO_APP_PORT` (5173 when unset) and refuses another port. With `DOMINO_PREVIEW_ORIGIN` set, it accepts that hostname and runs hot reload over HTTPS through it. * **`domino.cloudflare`** builds the Worker in Vite's `server` environment. When serving, it passes the variables `domino dev` sets to the local Worker, as empty strings when unset; builds get none. Spread it to add plugin options: `cloudflare({ ...domino.cloudflare, persistState: false })`. Other Vite apps set `server.port` to `Number(process.env.DOMINO_APP_PORT ?? 3000)` and use `DOMINO_PREVIEW_ORIGIN` for the allowed host and HTTPS HMR configuration. Then run `pnpm exec domino dev`. * **Requests arrive intact.** Worker previews preserve HTTP methods, bodies, application authorization, and cookies, including response cookies. * **No Cloudflare cache.** Connected previews bypass Cloudflare's cache when fetching from your dev server, including static assets and error responses. The app still controls browser cache headers. ## Choose a routing mode [#choose-a-routing-mode] Routing decides how much of each web request Domino's hosting handles before your app sees it. New starters use transparent routing, which hands almost everything to your app. | `app.routing` | Behavior | Default for | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | | `"transparent"` | Hosting forwards every URL to your app. There is no injected banner and no stripped request credentials. Staging reserves only its sign-in path, `/__domino/staging-access`, and the [test toolbar](/docs/use/quests#try-quests-on-your-staging-site)'s script, `/__domino/toolbar.js`, and endpoints under `/__domino/toolbar/`. It adds that script to the HTML pages it serves and removes its own session cookie. Your app owns methods, cookies, response headers, and routing. Hosting does not require Domino's member sign-in to be configured. | New starters, which select it explicitly. | | `"legacy"` or absent | Keeps the implicit `/relay/` participant proxy, preview path restrictions, and credential filtering. | Existing projects, and existing deployments and preview sessions. | In either mode, every `domino.page` address answers `/.well-known/security.txt` with Domino's abuse contact, so your app never receives that path there. On your [custom domain](#use-your-own-domain) the path reaches your app. The starter includes an explicit `/relay/*` handler in `server/index.ts`, which runs in both development and staging through the Cloudflare Vite plugin. That is starter code you own. Add custom handlers before it, replace it, or remove it. A custom `/relay/v1/me/pass` handler receives its requests normally. ## Add Domino participant sign-in [#add-domino-participant-sign-in] Domino can sign members in for you, with the methods you choose in Console under **Settings → Sign-in**. To use it in a hosted app, mount this handler in your application: ```ts import { proxyParticipant } from "@domino-sdk/relay/portal-proxy"; export function participant(request: Request) { return proxyParticipant(request, { baseUrl: "https://relay.domino.run", fetch, }); } ``` * **Participant routes only.** This helper uses the `/relay/*` convention and deliberately allows only participant API routes. * **Scoped by origin.** It sends the app origin to the API, which checks its registered preview or staging scope. * **Not authentication.** It does not grant authentication: sessions, project isolation, and CSRF checks still apply. * **You mount it.** The hosting gateway does not call this helper for you. * **Members are counted one by one.** On Domino hosting, every request your app's server sends to `https://relay.domino.run` carries the visitor's IP address, attested by Domino, so the sign-in limits per IP address count each member on their own. An app you host yourself reaches the API from its server's address, so its members share that address's limits. On your own Cloudflare Workers that address is Cloudflare's, shared with every Worker, and Domino counts those requests per project and environment instead. Connected development supplies `DOMINO_PARTICIPANT_API` for your app's explicit proxy. An app using Domino sign-in must configure that integration in its own development server as well as its deployed server. A static-only app with transparent routing has no implicit backend; add an app-owned Worker or use your own backend for member sign-in. To use your app's existing login, use the [identity integration adapter](/docs/build/app-login) instead. ### Identify the participant in your server routes [#identify-the-participant-in-your-server-routes] A server route that acts for the member, such as one that hands out a prize code, asks Domino who is signed in: ```ts import { signedInMember } from "@domino-sdk/relay/portal-proxy"; export async function memberOf(request: Request, env: Record) { return signedInMember(request, env, { apiUrl: "https://relay.domino.run" }); } ``` It returns the member ID, or `null` for a visitor who is not signed in or a session that is not a member's. It sends the request's cookies to `/relay/v1/auth/session` through the same routing as `handleHostedApp`, so it works under `domino dev` and on hosting. Never trust a member ID the browser sends. To use your app's existing login, use the [identity integration adapter](/docs/build/app-login) instead. ## Migrate to transparent routing [#migrate-to-transparent-routing] 1. If your app uses Domino sign-in, add the app-owned participant handler. 2. Set `app.routing` to `"transparent"` in `relay.json`. 3. Restart `domino dev` with the updated CLI, and verify sign-in, a member action, and your app routes. 4. When the preview works, commit and stage that version. Staging reads the setting from the selected commit; editing local configuration does not change an existing deployment. Failed builds preserve its routing and application together. ## Configure Worker variables [#configure-worker-variables] Worker `vars` supply non-secret string configuration to staged and live Workers. They are committed in `relay.json`, so anyone with repository access can read them; put credentials in [app secrets](#set-app-secrets) instead. Configure local values through your framework's development environment. `ASSETS` is refused as a variable name. Six `DOMINO_` names are Domino's own: the five in [the management API section](#call-the-management-api-from-your-server) and `DOMINO_SCHEDULE_KEY` for [scheduled jobs](#run-scheduled-jobs); Domino's values replace app `vars` with those names. For KV namespaces and SQL databases, and how to browse their contents page by page, see [Store app data](/docs/build/storage). ## Set app secrets [#set-app-secrets] App secrets are the passwords and API keys your app's server uses, such as a key for your own backend or an analytics service. Your developer declares which ones the app needs. Someone with publish permission then enters the values, in Console or the CLI. Nobody can read a value back once it is saved. Test and live each have their own secrets: test's reach staged builds, and live's reach the live app. A promotion never copies them, and its preview warns about each secret test has and live lacks. ### 1. Declare what the app reads [#1-declare-what-the-app-reads] List each secret in `relay.json` under `app.secrets`, with a description of what it is for. Declarations carry no value, so they are safe to commit. ```json { "app": { "kind": "worker", "secrets": { "APP_API_KEY": { "description": "Opens the protected /api routes. At least 32 characters; callers send Authorization: Bearer ." }, "ANALYTICS_TOKEN": { "description": "Sends usage events to the analytics service.", "required": false } } } } ``` * **Required by default.** Set `"required": false` for a secret the app works without. * **One kind per name.** A name cannot be both a `vars` entry and a declared secret. Static apps cannot declare secrets. * **Up to 50.** Descriptions have 1 to 500 characters. ### 2. Set the values and stage [#2-set-the-values-and-stage] In Console, open **Settings → App secrets**. Or run: ```sh openssl rand -hex 32 | pnpm exec domino secrets set APP_API_KEY pnpm exec domino stage ``` `domino secrets set` reads the value from stdin, or asks for it at a hidden prompt in a terminal. It never takes the value as an argument and never prints it. An app login key needs no copying: when an administrator creates it, or adds a secret to it, in **Settings → App login keys**, Console can save it as the app secret `APP_IDENTITY_KEY` in the same step. See [Use your app's login](/docs/build/app-login#1-create-an-integration-key). ### 3. Set live's values before promoting [#3-set-lives-values-before-promoting] In Console, select **Live** and open **Settings → App secrets**. Or run: ```sh printf %s "$APP_API_KEY" | pnpm exec domino secrets set APP_API_KEY --environment live ``` Without `--environment live`, `domino secrets` uses test; it ignores `RELAY_ENVIRONMENT` and the environment in `relay.json`, and warns when either names live. Its `--json` result says which environment it used in `environment`. A live change reaches the live app with the next build promoted to live; until then the live app keeps its previous value. ### Manage secrets [#manage-secrets] Each command takes `--environment live` for live's secrets. | Command | Does | | ---------------------------- | -------------------------------------------------------------- | | `domino secrets set NAME` | Creates or replaces a secret. One trailing newline is removed. | | `domino secrets list` | Lists set secrets, declared secrets, and declared ones missing | | `domino secrets delete NAME` | Deletes a secret | * **Missing secrets warn, not block.** `domino stage` warns about each required declared secret that is not set, shows the command that sets it, and adds `missingSecrets` to its `--json` result. The build still stages; routes that need the secret fail until you set it and stage again. * **Declarations are merged.** `domino secrets list` combines the active build's declarations with your checkout's `relay.json`, whose descriptions win, so it reports missing secrets before the first stage. In live, the declarations come from the live app's build, or from staging's build while nothing is live. * **Applied by the next build.** Builds are immutable. In test, Worker builds staged after a change receive it as a secret binding; builds staged earlier keep their values. Run `domino stage` again to apply a change. In live, the next build promoted to live receives it. * **Worker apps only.** Apps with server code (`app.kind: "worker"`) receive every secret. Static apps receive none. * **Secrets win over `vars`.** A set secret replaces an app `vars` entry of the same name, declared or not. * **Not in development.** `domino dev` does not receive hosted secrets. Give the local Worker its own value, for example in a git-ignored `.dev.vars` file, which the Cloudflare Vite plugin loads. * **Write-only.** No command, Console page, or API route shows a stored value. Replace a value you have lost. * **Limits.** Names use uppercase letters, digits, and underscores, start with a letter or underscore, and have at most 64 characters. `DOMINO_` names and `ASSETS` are reserved. Values are 1 byte to 5 KiB, and each project environment holds at most 50. * **Permissions.** Setting or deleting needs `publish` in the environment, and listing needs `read`. The CLI acts with the signed-in person's permissions, so a coding agent signed in as a project editor or organization admin can set secrets. A CLI sign-in session can set and delete secrets in live, although it is otherwise read-only there. > [!DEV] Management API > Management API clients use `GET /management/v1/secrets`, which also returns the active build's `declared` secrets, `PUT /management/v1/secrets/NAME` with `{ "value": "…" }`, and `DELETE /management/v1/secrets/NAME`, or `secrets.list()`, `secrets.set(name, value)`, and `secrets.delete(name)` on `createManagementClient` from `@domino-sdk/relay/server`. ## Use your own domain [#use-your-own-domain] A custom domain, such as `quests.example.com`, serves your live app, so members visit your community at an address you own. An organization admin sets it up, signed in to Console or the CLI; API keys cannot. Anyone with `read` can see its status. [Custom domains](/docs/concepts/hosting#custom-domains) explains how it works. ### 1. Add the domain [#1-add-the-domain] In Console, open **Settings → Domains**, enter the hostname, and choose **Add domain**. Or run: ```sh pnpm exec domino domains add quests.example.com ``` Attach a subdomain. A root domain such as `example.com` is refused: attach `www.example.com` and redirect the root to it at your DNS provider. A project has one custom domain. Where plans apply, the project needs the Growth plan or above. ### 2. Add the two DNS records [#2-add-the-two-dns-records] The domain shows both records. Add them at your DNS provider: | Type | Name | Value | | ------- | ---------------------------- | ----------------------------------------- | | `CNAME` | `quests.example.com` | `cname.domino.page` | | `TXT` | `_domino.quests.example.com` | `domino-verify=…`, the value Domino shows | If the domain has CAA records, they must allow `letsencrypt.org` and `pki.goog`. ### 3. Wait for HTTPS [#3-wait-for-https] Domino checks the records on its own. To check now, choose **Re-check** in Console or run: ```sh pnpm exec domino domains recheck quests.example.com ``` Once Domino finds the TXT record, the domain shows **Setting up HTTPS** while Cloudflare validates it and issues its certificate, usually within minutes. HTTPS to the domain fails until it shows **Active**. Then `https://quests.example.com` serves your live app, and once a build is live, page loads on `.domino.page` redirect there. ### Manage the domain [#manage-the-domain] * **Needs attention.** The domain says what to fix: a record that changed, a certificate Cloudflare could not issue, or setup that did not finish within 7 days. Fix it, then re-check. * **A proxied or flattened CNAME** can show as not found in public DNS while the domain works. Cloudflare's statuses are what decide **Active**. * **Removing it.** Attaching it again, to this or another project, needs the new TXT record it shows. Remove the records at your DNS provider when you no longer need them. * **Not enabled.** A Domino deployment without custom domains answers "Custom domains aren't enabled on this deployment yet" (`custom-domains-unavailable`). | Command | Does | | ------------------------------- | ---------------------------------------------------------------------------------------------------------- | | `domino domains list` | Lists the project's domains and their state | | `domino domains get DOMAIN` | Shows each DNS record against what public DNS returned, Cloudflare's statuses, and when Domino checks next | | `domino domains add HOSTNAME` | Attaches a hostname and shows its DNS records | | `domino domains recheck DOMAIN` | Checks now, at most every 10 seconds | | `domino domains remove DOMAIN` | Detaches the domain; it stops serving within about a minute | `DOMAIN` is the hostname or the domain's ID. The commands work the same whichever environment is selected, because a custom domain always serves live. To follow a domain, use `domino domains get`: Domino checks on its own. > [!DEV] Limits and management API > Adding, re-checking and removing share a budget of 30 per organization per hour; past it they answer `rate-limited`. > > Management API clients use `GET /management/v1/domains`, `POST /management/v1/domains` with `{ "hostname": "…" }`, `GET /management/v1/domains/ID`, `POST /management/v1/domains/ID/recheck` and `DELETE /management/v1/domains/ID`, or `domains.list()`, `domains.add(hostname)`, `domains.get(id)`, `domains.recheck(id)` and `domains.remove(id)` on `createManagementClient` from `@domino-sdk/relay/server`. ## Call the management API from your server [#call-the-management-api-from-your-server] Your app's server can award or spend points, read leaderboards, referrals, members and balances, and send records to custom trackers. Domino gives every staged and live Worker app, and the dev server `domino dev` starts, a management key for its own project and environment, so nobody pastes a credential. | Binding | Value | | ----------------------- | -------------------------------------------------------- | | `DOMINO_MANAGEMENT_KEY` | The app's key, a secret beginning `dhk_` | | `DOMINO_API_URL` | The Domino API origin | | `DOMINO_ORGANIZATION` | The project's organization | | `DOMINO_PROJECT` | The project | | `DOMINO_ENVIRONMENT` | `test` for previews and staging, `live` for the live app | Build a client from them with `hostedManagementClient` from `@domino-sdk/relay/server`. It runs in a Worker without `nodejs_compat`, and throws an error naming any binding that is missing. ```ts import { hostedManagementClient } from "@domino-sdk/relay/server"; export async function awardSignupBonus( env: Record, member: string, ) { const management = hostedManagementClient(env); return management.points.record({ kind: "award", actionId: `signup-bonus-${member}`, member, balance: "community", amount: 50, reason: "Completed sign-up", }); } ``` * **Read, points, and ingest only.** The key has exactly the `read`, `points`, and `ingest` permissions in one environment of its project: test for staging and development, live for the live app. `ingest` sends records to custom trackers with `management.trackers.ingest`; it cannot enroll members. `management.access()` lists all three. The key cannot publish, edit quests, review, hand over rewards, or manage access. Qualifying referrals still needs a key with `publish`. * **Server code only.** Keep it in your Worker. Never send it to a browser or put it in member-facing code. * **Rotated for you.** Each staged build gets its own key. When a newer build becomes the active preview, older builds' keys stop working; a failed build's key is revoked immediately. Each live deployment gets its own live key, and the previous live build's key stops working when the new one starts serving. Staging and live keys rotate separately. The key never appears in build artifacts, logs, or build status. * **Short-lived in development.** `domino dev` creates a key that expires after 12 hours and revokes it when development stops. It needs your own `read`, `points`, and `ingest` permissions in the test environment, which editors have. Without them, the dev server starts without the key and the CLI prints why. * **Recorded as the app.** Point entries and audit records name the app (`hosted:`, or `hosted:development:` during development), not a person. The Cloudflare Vite plugin passes only the values your `vite.config.ts` lists to the local Worker. [`dominoHosting()`](#run-the-dev-script-on-the-preview-port) passes all of them. Without it, add these entries to the `vars` that `cloudflare({ config })` receives when `command === "serve"`: ```ts export const developmentVars = { DOMINO_MANAGEMENT_KEY: process.env.DOMINO_MANAGEMENT_KEY ?? "", DOMINO_API_URL: process.env.DOMINO_API_URL ?? "", DOMINO_ORGANIZATION: process.env.DOMINO_ORGANIZATION ?? "", DOMINO_PROJECT: process.env.DOMINO_PROJECT ?? "", DOMINO_ENVIRONMENT: process.env.DOMINO_ENVIRONMENT ?? "", }; ``` Keep any variables your configuration already forwards, such as `DOMINO_PARTICIPANT_API`. Static apps have no server code and receive no key. Code that must behave differently in live can read `DOMINO_ENVIRONMENT`. ## Tell test from live [#tell-test-from-live] Previews and staging run on test data, and the live app on live data, from the same build. If something should only ever appear while you test, such as a "skip to tomorrow" button, your app asks at runtime where it is running. Don't guess from the address: hosts have moved before. | Where the app runs | Ask with | Answer | | ------------------------------------ | ------------------------------------------------------- | ---------------------------------- | | In the browser, static or Worker app | `getHostEnvironment()` from `@domino-sdk/relay/browser` | `{ environment, kind }`, or `null` | | In a Worker's server code | The `DOMINO_ENVIRONMENT` binding | `test` or `live` | `getHostEnvironment()` asks the page's host for `/__domino/environment` once per page load and shares the answer between calls: | Host | `environment` | `kind` | | --------------------------------------------------------------- | ------------- | --------- | | A `domino dev` preview | `test` | `preview` | | Staging | `test` | `staging` | | The live app, at its `domino.page` address or its custom domain | `live` | `live` | It resolves to `null` where the host does not answer, such as a local dev server or a host outside Domino, and during server rendering. Choose what `null` means for your app: ```ts import { getHostEnvironment } from "@domino-sdk/relay/browser"; const host = await getHostEnvironment(); const showTestTools = host?.environment === "test"; ``` Domino answers `/__domino/environment` itself on every hosted address, so your app never receives that path. On staging it answers only visitors with [staging access](/docs/build/staging#control-who-can-open-staging), like any page. On the live app, other `/__domino/` paths still reach your app. ## Run scheduled jobs [#run-scheduled-jobs] Your app can run background jobs on a timer, such as refreshing data every 15 minutes. Worker apps declare them as `app.schedules` beside secrets and storage. Relay sends signed POST requests to the serving build in each environment: the staged build in test and the live app in live. Verify them with `verifyScheduledRequest(request, env.DOMINO_SCHEDULE_KEY)`. Use **Settings → Schedules** or `domino schedules list` to inspect jobs and `domino schedules run NAME` to queue one; add `--environment live` for the live app's. A `domino login` session can't run or control live jobs, so use Console or an API key for those. See [Run scheduled jobs](/docs/build/schedules) for interval and cron declarations, limits, custom tracker ingestion and recovery. ## Build outputs [#build-outputs] These are the rules a build must follow. Worker apps run their configured check and build scripts in the same isolated builder as static apps. * **Package manager.** Use a pinned pnpm 10 version in `packageManager` and commit the lockfile. * **Build output.** The build script must emit the compiled modules and optional public assets configured in [the app section](#configure-the-app-section), plus `.domino-build/quests.json` and `.domino-build/catalog.json`. * **Resource artifacts.** `domino build --hosting-artifacts` writes those private resource artifacts to `.domino-build/` beside `relay.json`. The starters' build script is `vite build && domino build --hosting-artifacts`; add the same step when adapting an existing app. ### What the server output may contain [#what-the-server-output-may-contain] * **Accepted modules.** JavaScript ES modules, Wasm, text modules (including CSS, JSON, and HTML as strings), and binary modules. Bundle package dependencies during your framework build. * **Excluded files.** Generated `wrangler.json`, source maps, and the server's `.vite` metadata directory. Deployment settings come from `relay.json`, not generated Wrangler configuration. * **Public assets.** The public directory honors `.assetsignore`. Server and public directories must be separate; neither may contain the other. Only public assets receive browser URLs. | Limit | Value | | -------------------------------------------------- | ----- | | Files per directory | 500 | | Size per directory | 10 MB | | Modules and public assets, base64-encoded together | 20 MB | | Quest and catalog code (`.domino-build`), as JSON | 64 MB | | Quest and catalog code, compressed | 10 MB | Quest and catalog code does not count toward the 20 MB. The builder compresses it separately. When the installed SDK ships a [shared SDK runtime](/docs/concepts/quests#shared-sdk-runtime), quests and quest types import it, carried once per build, instead of each bundling the SDK. Other dependencies are bundled into each quest, tracker, leaderboard, and referral that imports them, and compress well when they repeat. A build over a limit fails in its pack step and names what to reduce. ### How requests reach your Worker [#how-requests-reach-your-worker] The builder uploads the modules and assets to an isolated Worker in the dispatch namespace. | Setting | Behavior | | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Default | Matching assets are served first; other requests reach your Worker. | | `app.runWorkerFirst: true` | Your Worker handles every request, including requests matching public assets. Your handler can serve those through `env.ASSETS.fetch(request)`. The starter selects this mode and owns its participant proxy and SPA fallback explicitly. | API-only Workers need no `index.html`. Static apps retain their SPA fallback. Each Worker build also receives its own management key and project scope as `DOMINO_` bindings, for server-side point writes and reads. Activating a newer build revokes older builds' keys in that environment. See [Call the management API from your server](#call-the-management-api-from-your-server). ### Activation [#activation] * **All at once.** Activation switches the deployed application as a unit. Failed builds preserve the previous staging version. * **Routing per commit.** With `app.routing: "transparent"`, the gateway returns app responses unchanged, including cache headers, cookies, and HTML. Omitting `app.routing` preserves legacy gateway behavior. The selected commit determines routing for each deployment. * **Live is a separate deployment.** Staging serves test. An approved promotion deploys the same build again for live, with live's key, secrets, stores, and schedule key, and the live app switches to it when that deployment succeeds. See [How test and live hosting relate](/docs/concepts/hosting#how-test-and-live-hosting-relate). ## Move to a newer starter [#move-to-a-newer-starter] A hosted project keeps the starter it was created from. To rebuild it on the latest version of a starter, export that starter's files and integrate them into your checkout on a branch. 1. **Export the starter.** From your checkout, so the command uses its project, run `domino starters export campaign --directory ../next-starter`. It writes the files `domino create` would start a new project with today into a new or empty directory, and prints the commit they were built from. A directory that already has files is refused. 2. **Integrate on a branch.** Start a branch from the latest `main` in your checkout, copy in the exported files, and carry over your own quests, copy, and design. Keep the exported `.domino/starter.json`: `domino doctor` reads it to report which starter the project now comes from. 3. **Install and check.** Install dependencies with the package manager version that `package.json` pins, then run `domino doctor` and the app's checks. 4. **Stage it.** Merge into `main` and stage the result, as the [source and release workflow](/docs/reference/hosted#source-and-release-workflow) describes. The export is the starter as a new project receives it: it has no project name or brief, and nothing is installed. Only the current version of each starter can be exported. Any member who can read the project can export. ## Where next [#where-next] - [Develop and share a preview](/docs/build/development) — run the app against hosted test data with `domino dev`. - [Stage and share](/docs/build/staging) — build a pushed commit into the staging app. - [Store app data](/docs/build/storage) — KV namespaces and SQL databases for your Worker. # Check images (/docs/build/images) Many quests ask members for a picture. Domino can check it for you, so your team only looks at the unclear ones. For example: * **"Use our campaign picture as your X avatar."** Domino compares the member's X profile picture with yours. * **"Upload a selfie at our booth."** Domino spots the same photo submitted by several people, then AI checks it against your criteria. * **"Show that you joined our X Community."** AI reads the screenshot and Domino compares what it shows with the member's linked X account. The member gets an answer: accepted, not accepted with a reason, or sent to your team for review when the quest uses review. Images are never served publicly, and the AI reviewer receives copies without location data. > [!DEV] > Quest evaluators check images through `ctx.images`. The platform fetches and decodes each image and returns only the result. Quest code never receives image bytes or URLs, and it cannot make the platform fetch an arbitrary address. To start from a working quest, install a recipe: | Recipe | Name in the URL | Checks | | ---------------------- | ---------------------- | -------------------------------------------------------------------------------- | | X profile picture | `x-profile-picture` | The linked X avatar is your campaign picture, or a customized version AI accepts | | Unique photo | `unique-photo` | An uploaded photo was not already submitted by someone else, then AI review | | X Community screenshot | `community-screenshot` | A screenshot shows your Community, membership and the linked X username | ```sh pnpm dlx shadcn@4.21.0 add https://snippets.domino.run/r/x-profile-picture.json ``` Each recipe installs `quests/.quest.ts` and instructions under `docs/snippets/`. See [start from a snippet](first-quest.md#start-from-a-snippet). | To | Use | | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | Require your campaign picture as an X avatar | [`compare`](#compare-two-images) against a [reference image](#1-add-reference-images-to-relayjson) | | Accept a customized version, such as a framed selfie | [`review`](#review-images-with-ai) with the reference as a second image | | Read a screenshot, such as a joined X Community | [`extract`](#read-fields-from-a-screenshot) | | Catch the same photo submitted by several people | [`seen`](#catch-reused-photos) | | Get dimensions, format and hashes | [`inspect`](#inspect-an-image) | ## Name an image [#name-an-image] Every method takes image references, not files or URLs: | Reference | Image | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `{ evidence: ctx.input.photo }` | The photo the member uploaded with this attempt. Another attempt's upload is refused. | | `{ x: "profile" }` | The linked X account's current profile picture, at 400×400. The member must link X first. | | `{ x: "post", postId, index }` | The image attached to an X post, where `index` is its position: `0` (default) to `3`. Videos and GIFs use their preview image. | | `{ asset: "key" }` | A reference image you deployed with the project. | X images are downloaded only from X's image hosts. X post reads also list attached media in `post.media`, as `{ type, url }` in post order. ## Add reference images [#add-reference-images] ### 1. Add reference images to relay.json [#1-add-reference-images-to-relayjson] List each image with a key and a path relative to `relay.json`: ```json { "images": [ { "key": "campaign-avatar", "path": "assets/campaign-avatar.png" } ] } ``` Use JPEG, PNG or WebP files of at most 5 MiB and 25 megapixels, up to 10 per deployment. Commit them with your project. ### 2. Deploy [#2-deploy] Run `domino deploy`. The CLI uploads each new file straight to private storage, then previews the deployment with only each file's hash and size; unchanged files are not uploaded again. The platform decodes every image before publication and rejects anything that is not a supported image. The deployment summary lists the published images. Images are never served publicly. Hosted builds publish the same images from your repository: build them with `domino build --hosting-artifacts`, as the starters do, or keep `domino build --json` output unchanged in your build script. Deploying a key again replaces its image for later checks. Removing an entry from `relay.json` does not delete the published image. Results that were already captured for an attempt stay unchanged. ## Compare two images [#compare-two-images] `compare` returns perceptual hash distances between 0 (same picture) and 64: ```ts import { x, defineQuest, settings, manual, once, decision } from "@domino-sdk/relay/authoring"; export default defineQuest({ integrations: [x], key: "campaign-avatar", title: "Use the campaign picture on X", settings: settings.object({}), trigger: manual(), completion: once(), rewards: [], async evaluate(ctx) { if (!(await ctx.x.connection())) { return decision.reject({ reason: "Connect X, then start a new submission." }); } const match = await ctx.images.compare({ x: "profile" }, { asset: "campaign-avatar" }); return match.phashDistance <= 8 ? decision.accept() : decision.reject({ reason: "Set the campaign picture as your X avatar." }); }, }); ``` | Result | Meaning | | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `identical` | The two files have the same bytes. | | `phashDistance` | Differences in overall structure. Up to about 8 means the same picture resized or re-encoded. Unrelated photos usually differ by 25 or more. | | `dhashDistance` | Differences in local brightness changes. It reacts more to small edits. | Your quest decides the threshold. Make it a setting so operators can tune it in Console without a code change: ```ts import { x, defineQuest, settings, manual, once, decision } from "@domino-sdk/relay/authoring"; export default defineQuest({ integrations: [x], key: "campaign-avatar-tunable", title: "Use the campaign picture on X", settings: settings.object({ maxDistance: settings.integer({ label: "Largest hash distance that counts as the same picture (0–64)", default: 8, min: 0, max: 64, }), }), trigger: manual(), completion: once(), rewards: [], async evaluate(ctx) { const match = await ctx.images.compare({ x: "profile" }, { asset: "campaign-avatar" }); return match.phashDistance <= ctx.settings.maxDistance ? decision.accept() : decision.reject({ reason: "Set the campaign picture as your X avatar." }); }, }); ``` Hashes tolerate resizing, compression and small color changes. Crops, rotations, overlays and added frames change them substantially. Use [AI review](#review-images-with-ai) when a member may customize the picture. ## Review images with AI [#review-images-with-ai] `review` judges up to four images against your criteria and returns `pass`, `fail` or `unclear` with a short reason. Images are numbered in the order you pass them, so the criteria can refer to them: ```ts import { decision, type EvaluationContext } from "@domino-sdk/relay/authoring"; // Call from evaluate() after checking that X is connected. export async function framedAvatar(ctx: EvaluationContext) { const result = await ctx.images.review({ images: [{ x: "profile" }, { asset: "campaign-avatar" }], criteria: "Image 1 uses the frame and logo from image 2. The person's own photo may appear inside the frame.", }); if (result.kind === "unclear") { return decision.review({ reason: result.reason }); } return result.kind === "pass" ? decision.accept() : decision.reject({ reason: result.reason }); } ``` The AI reviewer receives re-encoded copies without EXIF or location data, never the image URL. It is told to ignore instructions inside the images, but AI can still misjudge adversarial pictures, so send unclear results to your team. > [!DEV] > Return `decision.review()` only when the quest enables human review. With review off, it becomes a rejection that keeps your reason and marks the attempt `requestedReview`, which Console shows as "Would have gone to review; human review is off". To review an uploaded photo, pass `{ evidence: ctx.input.photo }`. `ctx.review.photo()` is deprecated: it still works, reviews only the uploaded photo and will be removed in 2.0.0. ## Read fields from a screenshot [#read-fields-from-a-screenshot] `extract` reads up to 12 declared fields from one image. Each field is a `string`, `number` or `boolean`, and is `null` when the image does not show it clearly: ```ts import { decision, type EvaluationContext } from "@domino-sdk/relay/authoring"; // For a quest with trigger: manual({ input: evidence.photo() }). export async function joinedCommunity(ctx: EvaluationContext) { const screenshot = await ctx.images.extract({ image: { evidence: ctx.input.photo }, fields: { community: { type: "string", description: "The X Community name shown on the page" }, joined: { type: "boolean", description: "Whether the page shows the viewer as a member" }, }, }); const { community, joined } = screenshot.values; if (community === null || joined === null) { return decision.review({ reason: screenshot.reason }); } return community === "Domino Builders" && joined ? decision.accept() : decision.reject({ reason: "Upload a screenshot after joining the community." }); } ``` A screenshot is a member's claim, not proof from X. Compare extracted values with data you can verify, such as the linked account's username, and send uncertain cases to staff. ## Catch reused photos [#catch-reused-photos] A photo booth or a "share your setup" quest invites copying. `seen` records the image for this attempt and counts other members whose earlier images are within the distance: ```ts import { decision, type EvaluationContext } from "@domino-sdk/relay/authoring"; // Call first in evaluate(); continue with your own checks when it returns null. export async function reusedPhoto(ctx: EvaluationContext) { const seen = await ctx.images.seen({ evidence: ctx.input.photo }); return seen.matches > 0 ? decision.review({ reason: "This photo matches another member's submission." }) : null; } ``` The result contains `matches`, the `closestDistance`, and `firstSeenAt` in milliseconds. The default `maxDistance` is 8; pass `{ maxDistance }` from 0 to 8 to change it, for example from a quest setting as the `unique-photo` recipe does. The same member's other attempts never count. Only images passed to `seen` are recorded, within the same project and environment. `seen` records the image when it runs, even if the attempt is later rejected, so two people submitting the same photo at once see each other. ## Inspect an image [#inspect-an-image] `inspect` returns `width` and `height` after EXIF rotation, `format`, `bytes`, `sha256`, and the 16-character `phash` and `dhash`. Use it to require a minimum size or to store hashes in your own records. > [!DEV] > Image result readers ignore added fields, including nested upload metadata. For explicit parsing of a newer server variant, `capabilityResponses` from `@domino-sdk/relay/schema` preserves an unknown image format as `{ kind: "unsupported", raw }`. Existing evaluator methods retain their declared types and their evaluator protocol; new formats must stay compatible with that protocol. ## Limits and failures [#limits-and-failures] | Limit | Value | | ---------------------------- | --------------------------------------------------------- | | Images per `review` | 4 | | Fields per `extract` | 12 | | Criteria | 2,000 characters | | Downloaded or uploaded image | 5 MiB and 25 megapixels | | Photo uploads by a member | 30 an hour; past it, uploads answer 429 `rate-limited` | | Reference images | JPEG, PNG or WebP; 5 MiB each; 10 per deployment | | Capability calls | Each method call uses one of the evaluation's eight calls | Member avatar uploads are decoded by the same image service with the same 5 MiB and 25 megapixel limits, then stored as 512×512 WebP. They are profile pictures, not quest evidence; see [member profiles](/docs/build/profiles#upload-a-picture). A technical failure, such as an image service outage, never passes or rejects a member; when the quest code lets the error through, Domino retries it. To check a changed picture or account, the member starts a new submission. > [!DEV] > Every result is captured for the attempt and reused on evaluator retries. Failures throw a `CapabilityError` instead of passing or rejecting: `not-linked` for `{ x: "profile" }` without a linked X account; `author-error` for a missing reference image, an image that cannot be decoded and a missing X picture; `provider-unavailable`, which Domino retries if your code lets it through, for provider failures and timeouts. The attempt pins the member's X account at its first X read. See [evaluator readers](/docs/reference/verification#evaluator-readers-null-errors-and-pinning). > [!NOTE] Checks happen at verification time > A matching profile picture proves the picture at that moment. It does not prove that the member kept it. The recipes above are complete quests built from these methods. Each one is also an SDK example in `packages/sdk/examples`. ## Retire an image [#retire-an-image] Archive an image in **Settings → Resources**, or run `domino images archive --reason ` in test. Fresh evaluator reads of that image name fail while it is archived. Stored bytes and image evidence already captured by an attempt remain available for replay. Publishing the image again preserves archive state. Unarchive it explicitly before new checks should capture it again. # Connect Discord and X (/docs/build/integrations) Your community already talks on Discord and X. Connect them, and quests can check what members do there. For example: * **"Join our Discord server"**, optionally with a required role. * **"React 🫡 to our announcements"**, paid automatically. * **"Post in #show-and-tell"**, once a day. * **"Get a ❤ from a moderator"**, which pays the member who wrote the message. * **"Finish onboarding and get the Contributor role"** in Discord. * **"@ana got +10 XP from @sam"**, posted in a channel each time a quest pays. * **"Follow us on X"**, **"Repost our launch"**, or **"Put our tag in your bio"**. Connect Discord once per project. Quests then use that server, so nobody copies server IDs or bot tokens into code. X needs no setup in your project at all. Telegram groups and channels connect from the same **Integrations** page; see [Telegram](/docs/build/telegram). Each service is a card on **Integrations** with its status and what it needs from you next, such as **Connect** or **Manage**. Open a card to see its details and act on it in a panel beside the page. Other services Domino integrates appear on the same page, one card each, such as [Klaviyo](/docs/build/klaviyo), which keeps your members' Klaviyo profiles current for your email flows. Connect one with the settings and secrets it asks for, separately in Test and Live; secrets show only as set or not set, never their values. Anyone who can publish connects, edits or disconnects it; anyone else sees its status. Some services connect through their own sign-in instead of secrets. Their panel has **Connect with** and the service's name: fill in the settings, grant Domino access on the service's page, and it sends you back to **Integrations**, which finishes connecting the service with the panel open. Only the person who started connecting can finish it, signed in to Console: a link to the service's page that you send someone else connects nothing. Domino keeps that access fresh on its own. If the service stops accepting it, for example because someone removed Domino in the service's settings, the card asks you to **Reconnect**, and nothing reaches the service until you do. **Disconnect** removes Domino's access at the service too. A card that says **Not configured** can't be connected yet: Domino hasn't set that service up. > [!DEV] > `domino connections authorize --config-json '{…}'` and `management.connections.authorize(plugin, { config })` answer the `authorizationUrl` to open; it works once, for ten minutes. The service then sends the browser to Console with a one-time `oauthCode`, which `management.connections.completeAuthorization(plugin, { code })` redeems for the same person within five minutes (403 `scope-mismatch` for anyone else). An automation, service or hosted app key can't start one (403 `credential-not-allowed`). A connection that needs reconnecting has a `reconnectReason`. `plugins.list()` shows which services connect this way (`oauth`) and whether they're set up (`oauth.configured`); connecting one that isn't is refused with 409 `plugin-not-configured`. ## What members see [#what-members-see] * **One link, then quests.** A member who signed in with Discord is ready. Anyone else links their Discord or X account once from your app, and every quest can use it. * **Reactions and messages pay on their own.** Reaction and message quests have no claim button. The member reacts or posts in Discord, and the points arrive. * **Roles show up in Discord.** A role reward appears on the member's Discord profile, and your app can show whether it is still being granted. * **An outage is never held against them.** If Discord or X is down, the check waits and tries again instead of saying no. ## What you can do in Console [#what-you-can-do-in-console] | Without code | Where | | --------------------------------------------------------------------------- | ----------------------------------------- | | Connect, check, switch or disconnect your Discord server | **Integrations** | | Connect a server another project of your organization already connected | **Integrations**, **Or reuse** | | See which quests need Discord, whether events are arriving, and recent gaps | **Integrations**, on the Discord card | | Choose the announcement channel and the cutoff date | The quest's **Settings** | | Choose the role a quest hands out | The quest's **Settings**, **Reward role** | | See role grants, retry a failed one, or keep a role your team owns | A member's **Discord roles** | | Choose the channel a quest announces in | The quest's **Settings** | | See a member's announcements, retry one that failed | A member's **Announcements** | | Relink an X or Discord account a member already proved | A member's **Identities** | The quests themselves come from code. Your developer starts from a ready-made recipe, called a snippet: | Snippet | Rewards | | ------------------------------- | ---------------------------------------------------------------- | | `discord-membership` | Members of your server, optionally only those with a role. | | `discord-announcements` | 🫡 reactions on announcements published after a date you choose. | | `discord-contribution` | Messages in a channel you choose, once per member per day. | | `discord-moderator-recognition` | The author of a message, when a moderator reacts with a heart. | | `discord-role-reward` | A Discord role, handed out when the member completes the quest. | > [!DEV] How each snippet runs > `discord-membership` calls `ctx.discord.member()` in the evaluator. `discord-announcements` and `discord-moderator-recognition` trigger from a `discord.reactions()` tracker; the moderator one pays `messageAuthor`. `discord-contribution` triggers from a `discord.messages()` tracker. `discord-role-reward` declares `discord.grantRole(roleId)` as a quest reward. An announcement is `discord.postMessage({ channel, text })` in the quest's `announcements`; see [Announce in a channel](#announce-in-a-channel). X checks use `ctx.x`; see [Verify X activity](#verify-x-activity). ## Connect Discord to a project [#connect-discord-to-a-project] An organization administrator installs the bot, or [sends a link](#send-the-install-to-your-server-admin) to whoever manages the server, and manages the connection. Anyone else with read access can see its status. Once one project connects a server, anyone who can publish can [connect it to another project](#use-a-server-your-organization-already-connected) of the same organization without installing the bot again. ### 1. Start the connection [#1-start-the-connection] Open **Integrations** in Console, select your project and the Test or Live environment, open the **Discord** card, and choose **Connect Discord**. ### 2. Install the bot [#2-install-the-bot] Discord asks you to choose a server you manage and install Domino's bot. You don't enter a server ID or bot token. > [!NOTE] When Domino's bot is already in the server > Every Domino organization shares the same bot. If someone already gave it permissions in the server, such as **Manage Roles** for role rewards, only a person who has all of those permissions too can connect it, or the server's owner or an Administrator. Anyone else sees "Domino's bot already has permissions in this server that you don't have," and nothing is connected. Ask a server administrator to connect it, or send them an install link. ### 3. Return to Console [#3-return-to-console] Come back to the same Console account and project to finish. Console shows the server's name when the connection is ready. ### Send the install to your server admin [#send-the-install-to-your-server-admin] Adding a bot to a Discord server takes **Manage Server** (or Administrator) in that server. If you're a Console administrator without it, send a link to someone who has it. They don't need a Domino account. 1. In **Integrations**, open the **Discord** card and choose **Send to your server admin**. 2. Optionally enter the server's ID. The link then preselects that server and connects only it. Without an ID, it connects any server the person opening it manages. 3. Choose **Create link**, copy it and send it to them. 4. They open it and see which organization and project it connects to: your organization's name, the project's name, its slug and the environment. They choose **Continue to Discord**, add Domino's bot, and see a page naming the connected server and the project. Refresh **Integrations** to see the server. The link connects the project and environment you made it in. * **Once, for seven days.** A link connects one server, then stops working. Making a new link leaves earlier ones working. * **Only for the connection you had.** If the project's Discord connection changes after you make the link (someone connects or disconnects a server), the link connects nothing. Make a new one. * **Anyone with the link can use it.** Send it only to the person who manages the server. The project's audit records you, the link's creator, as who connected the server. * **It names your project.** The project's slug is unique across Domino, so tell the person to expect it. Someone who gets a link naming a project they don't know shouldn't continue. ### Manage the connection [#manage-the-connection] * **One server per environment.** Each project has one Discord server in Test and one in Live, set up separately. * **Reconnect to switch servers.** Reconnecting lets you pick another server. * **Check access.** **Check access** checks the bot again. If the bot was removed or lost access, reconnect from this page. * **Outages are not rejections.** A Discord outage makes the check try again later. It never fails the member's membership. ### Use a server your organization already connected [#use-a-server-your-organization-already-connected] A community with several projects installs the bot once. When another project of your organization already connects a server in the same environment, and the connection is ready, connect it here directly: 1. Open **Integrations** in Console, in the project and environment you want to connect, and open the **Discord** card. 2. Under **Or reuse**, choose **Use** beside the server. Domino checks that its bot can still see the server, then connects it. Anyone who can publish in the project can do this, not just administrators. To add the bot to a server no project connects yet, an administrator uses **Connect Discord**. The project's audit records who connected the server and which project's connection it reused. * **Only a project without a server.** A project that already has a Discord connection keeps it, even when it needs attention. An administrator reconnects, checks or disconnects it. * **Ready connections only.** A server whose only connection needs attention isn't offered. Repair it in its project first. > [!DEV] Connect from the CLI or an automation key > An agent that creates a project with `domino create` can connect it without a person: `domino integrations discord servers` lists the servers it can use, `domino integrations discord connect --server ` connects one, and `domino integrations discord get` reads the connection. The management client has the same calls: `management.discord.servers()`, `management.discord.connect(server)` and `management.discord.connection()`. They need `publish` permission, and an automation key or CLI session connects only in Test. > > A refusal names what a person needs to do. `server-not-connected-in-organization` (409): no other project connects that server ready in this environment, so an administrator installs the bot in Console. `integration-connected` (409): the project already has a Discord connection. `server-not-reachable` (422): the bot lost access to the server, so an administrator reconnects it. > [!NOTE] Console says Discord isn't available > Contact the platform administrator. Domino's Discord bot must be set up before anyone can install it. See [Configure the managed Discord application](#configure-the-managed-discord-application). ## Check that someone joined your server [#check-that-someone-joined-your-server] Install the `discord-membership` snippet. It accepts members of your server who finished Discord's membership screening. Your developer can narrow it to a role, match names, or require a server boost. To be checked, a member needs their Discord account in your app: * **Discord sign-in.** A member who signs in with Discord is ready. * **Other logins.** A member who signs in another way links Discord once with your app's account controls. Custom apps use the [member API](/docs/reference/participant-api#connected-accounts). * **Basic consent only.** Discord asks for basic identity consent. Members don't authorize reading their server membership. * **Existing links.** A Discord account a member linked before works too, as long as it's unambiguous. Console labels these quests **Quest code · Discord integration**. > [!DEV] > Declare `integrations: [discord]` on the quest and call `await ctx.discord.member()` in `evaluate`. Relay picks the project's server and the member's Discord identity; the quest has no server setting. The result is `null` when the member isn't in the server or hasn't linked Discord, or an object with `userId`, `roles`, `joinedAt`, `pending`, `nickname`, `displayName` and `boostingSince`. Quest code owns the role, name and boost decisions and the rewards. > > The photo-check provider setting does not select the Discord verifier. **Staff review** means quest code may request a review; uncertain Discord checks don't go to staff on their own. The membership snippet accepts or rejects membership, and leaves Discord outages as errors that retry. ### Recover a configuration failure [#recover-a-configuration-failure] Connect Discord in **Integrations**, for the quest's project and environment, before members try a quest that needs it. A quest needs Discord when it checks membership, hands out a Discord role, announces in a channel, or lets you choose a Discord channel or role. * **Publishing still works without it.** No way of publishing refuses a quest because Discord is missing or broken. The Discord card in **Integrations** always lists the quests that need Discord, and **Quests** shows a warning on each one until Discord is ready. * **Roles and channels are checked while connected.** Once Discord is connected, a role or channel the server doesn't have still stops publication. * **Automatic quests catch up.** An automatic quest published before Discord is ready, or whose connection breaks later, starts members' attempts at once, and they fail. When Discord becomes ready, by first connecting it or repairing it from needs attention, Domino starts a new attempt in the background for each member whose latest attempt failed this way. Current rules apply: a paused quest, or a member who no longer meets the prerequisites, gets no new attempt. Quests with a tracker trigger are the exception: their members keep the failed attempts, so retry those one by one. When a check can't reach your server, the member's attempt usually stops with one of these reasons: | Reason | What to do | | ------------------------------------------------ | --------------------------------------------------------------- | | The project hasn't connected its Discord server | Connect Discord in the same project's Test or Live environment. | | The project's Discord connection needs attention | Use **Check access**, or reconnect the bot in **Integrations**. | | Discord is not configured | Ask the platform administrator to set up the managed bot. | Read the reason on the attempt in Console. For an attempt Domino doesn't start again, fix the connection first, then retry: a member trying again can't fix your project's setup. A retry checks Discord again. If the member's Discord account or your server changed since the attempt started, start a new submission instead, because an attempt keeps the account and server it first saw. > [!DEV] > A deployment's preview and result list such quests in `warnings` with code `integration-required`, and `domino deploy` prints them. Publishing a single quest, a Console draft, or unarchiving a quest returns no warning. > > In an existing quest, a Discord read that needs the project connection throws a non-retryable `CapabilityError` with code `integration-required` when the connection is missing or broken. Membership still returns `null` when the member hasn't linked Discord. If quest code lets the error escape, the attempt becomes `failed` immediately, with that code and a member-safe reason. It doesn't use up the automatic retry budget or reject the member's membership. Read `code` and `reason` with `domino attempts get `. Temporary network errors and Discord outages throw `provider-unavailable` and retry automatically. See [Evaluator readers: null, errors and pinning](/docs/reference/verification#evaluator-readers-null-errors-and-pinning). ## Automatic announcement reactions [#automatic-announcement-reactions] Install the `discord-announcements` snippet to reward members who react 🫡 to announcements published after a date you choose. Choose the announcement channel and that date in Console, as described in [Choose channels and cutoff dates](#choose-channels-and-cutoff-dates). The server comes from the project's connection, and the bot must be able to see that channel. ```mermaid flowchart LR A["Member reacts 🫡"] --> B["Discord Gateway"] B --> C["Discord tracker stores it"] C --> D{"Reactor linked and joined, quest active?"} D -->|yes| E["Quest checks channel and cutoff"] D -->|no| F["Skipped, with a code"] E --> G["Completes once per message"] ``` ### How rewards are counted [#how-rewards-are-counted] * **No claim button.** The quest runs by itself when the member reacts. * **Once per announcement.** Each member earns once per announcement. Removing a reaction doesn't take the reward back, and removing then re-adding it can't pay twice for the same message. * **The date is when the announcement was posted.** The cutoff compares the announcement's publish time, not the time of the reaction. * **Hidden by default.** The quest is hidden from members' quest lists unless your developer makes it visible. > [!IMPORTANT] Link Discord before reacting > Domino stores a reaction only when the person reacting, or the author of the message, has already linked their Discord account in the project. This quest pays the person reacting, so it skips a reaction from someone no member linked, and reactions from before the quest was published. Linking Discord or finishing a required quest later doesn't replay earlier reactions. A reviewer can retry a skipped one under **Trigger outcomes**. > [!DEV] > The snippet installs `trackers/discord-announcement-reactions.ts`. Add `{ "entry": "trackers/discord-announcement-reactions.ts" }` to `trackers` in `relay.json` so it publishes with the quest; see [Reward Discord messages and reactions](/docs/build/trackers#reward-discord-messages-and-reactions). The quest uses `automatic({ tracker })` on the reactions tracker and a `keyed(...)` completion on the message ID. It is hidden by default; set `visibility: "visible"` to show progress. ### Where Discord events come from [#where-discord-events-come-from] Domino runs one shared Discord bot that listens for new messages and reactions and hands each one to every project connected to that server. The Discord card in **Integrations** shows whether events are arriving. When the bot is blocked, Domino has to fix its configuration; it reconnects on its own afterwards, and a project can't restart it. For how delivery, retries and its status endpoint work, see [Discord Gateway delivery](/docs/reference/trackers#discord-gateway-delivery). ### Coverage limits [#coverage-limits] Discord doesn't say when a reaction happened, and a reconnect can leave short gaps. So Domino can miss a reaction it has never seen before when Discord sends it again later, or one removed during a gap. The Discord card lists **Recent coverage gaps**. Treat Discord rewards as best effort, not a complete history of your server. The full rules are in [Discord Gateway delivery](/docs/reference/trackers#discord-gateway-delivery). ## Choose channels and cutoff dates [#choose-channels-and-cutoff-dates] Open the announcement quest's **Settings** in Console: | Setting | Behavior | | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Announcement channel** | Lists the text and announcement channels of your connected server, grouped by category, with search. Channels the bot can't see are disabled with an explanation. | | **Announcements published after** | A date and time in your local timezone. Domino stores the exact moment in UTC, so a teammate in another timezone sees the same moment in their own local time. | Reconnect Discord from **Integrations** if the server connection is missing. Publish the settings to apply them. * **Empty settings pause the quest.** A quest from code with an empty channel or role setting stays paused until you publish a choice. * **No silent substitutes.** If a saved channel is deleted or the bot loses access, Domino doesn't pick another channel. * **Outages keep the old settings.** If Discord is down while you save, the previous published settings stay in place. > [!DEV] > Authors reuse these controls through `settings.resource` and `settings.datetime`. See [Resource and date/time settings](/docs/reference/authoring#resource-and-datetime-settings). ## Reward contributions and recognition [#reward-contributions-and-recognition] ### Reward channel messages [#reward-channel-messages] Install `discord-contribution` to reward messages in a channel you choose, or in its threads, at most once per member per day. Days follow the [project calendar](/docs/concepts/calendar), which is UTC unless you choose a project timezone. Bot, webhook and Discord system messages never count. It checks that members took part, not how good their messages were, and it doesn't require consecutive days. To require several days in a row, such as a 7-day streak, ask your developer to switch the snippet to a streak. Each accepted message fills the day it was posted, and the streak pays its milestone once. > [!DEV] > The quest triggers from a `discord.messages()` tracker; add the installed `trackers/discord-contribution-messages.ts` to `trackers` in `relay.json`. For a streak, replace the snippet's keyed completion with `streak({ days: 7 })` and remove its daily budget. See [Calendar-day streaks](/docs/reference/quest-rules#calendar-day-streaks). ### Recognize moderator hearts [#recognize-moderator-hearts] Install `discord-moderator-recognition` to reward the author of a message when a moderator reacts with a heart. Choose the contribution channel and the moderator role. * **Roles at the time.** The moderator's roles are checked as they were when they reacted. Reacting to your own message doesn't count. * **Once per message.** The author earns once per message, however many moderators react. * **Who needs an account.** The moderator doesn't need to be a member. The author must have linked Discord already. * **No take-backs.** Removing the reaction doesn't remove the reward. > [!DEV] > The quest triggers from a `discord.reactions()` tracker with `beneficiary: "messageAuthor"`: it pays the message's author, and the record's `author` is the reactor. The example reads the reactor's roles from `data.reactorRoles`. Relay skips a reaction to one's own message with `beneficiary-is-author`. Add the installed `trackers/discord-recognition-reactions.ts` to `trackers` in `relay.json`. ### Read a message from quest code [#read-a-message-from-quest-code] Quest code can also look up one message in your server, for example to check that a member posted it or replied to the right thread. > [!DEV] > `ctx.discord.message({ channelId, messageId })` reads a message within the connected server. The result includes the author ID, channel, publication time, reply target and nullable content. Reads are captured for recovery, and a deleted message returns `null`. The read uses the bot in the server the attempt pinned, so the member needs no linked Discord account. A channel outside that server, or one the bot can't see, throws a `CapabilityError` with code `author-error`; it doesn't mark the integration as broken. > > For a manual authorship check, compare the message's `userId` with the non-null `userId` from `ctx.discord.member()`. Older captured member facts can have a null `userId` and must not pass that check. > [!WARNING] Message text is off by default > To check what a message says, a platform administrator must turn on the privileged **Message Content Intent** in the Discord Developer Portal and set `DISCORD_MESSAGE_CONTENT=true` on the API Worker. Discord may need to approve the bot. Restart or reconnect the managed Gateway after changing this. A messages tracker stores text only with `discord.messages({ text: true })`, and its `data.text` is null without the intent. Editing a message updates the stored text. Deleting the message, or publishing the tracker without `text: true`, erases that text from current records, retained observations and pinned attempts. Code that needs the text must reject null or missing text, never treat missing access as a pass. ## Grant a Discord role [#grant-a-discord-role] A quest can hand out a Discord role as a reward, such as a Contributor role once a member finishes onboarding. Role rewards belong on quests; referral programs can't grant them directly. ### 1. Install the snippet [#1-install-the-snippet] Install `discord-role-reward` and point its required quest, `welcome`, at your onboarding quest. ### 2. Choose the role [#2-choose-the-role] Choose **Reward role** in Console. The picker disables roles Domino can't hand out. Roles that an integration manages, such as a bot's own role, can't be rewards. ### 3. Give the bot permission [#3-give-the-bot-permission] In Discord, give the bot **Manage Roles** and place its highest role above the reward role. Publication checks the bot's current permissions. ### How delivery works [#how-delivery-works] * **Completion comes first.** The role is promised in the same step as the member's other rewards. A Discord outage never undoes the completion or pays points twice. * **It keeps trying.** Domino retries a failed grant with backoff. The member's details show whether a role is granting, retrying or granted. The **Rewards** page lists every role Domino has delivered, with all its deliveries counted, including roles no current quest grants. * **Bound to the original account.** A role goes to the Discord account and server the member earned it with. Reconnecting another account can't receive it. Restoring the original account, server and permissions lets a pending grant go through. * **One role, many reasons.** The same member can earn the same role from several quests, projects, or Test and Live. Revoking one of those completions doesn't remove a role another one still grants. * **Clean-up after revocation.** When the last completion that granted a role is revoked, Domino removes the role, unless the member had it before Domino first granted it. For the exact retry, timing and ownership rules, see [Discord role delivery](/docs/reference/authoring#discord-role-delivery). ### Recover or preserve a role [#recover-or-preserve-a-role] Open a member's **Discord roles** in Console. Once you've fixed the account, server or bot permissions, choose: * **Retry delivery** for a grant that failed. * **Reconcile role** for a removal that failed. Reconciling after a revocation never gives the role back. A Discord permission error while Domino reads the member's roles stops that round of retries, just like a rejected grant. These actions need the `review` permission. If an administrator or another bot takes over a role Domino granted, choose **Preserve external assignment**, give a reason and confirm. Domino then never removes that role automatically, in any project or environment that shares the member's Discord membership, until the role is gone and no accepted grant remains. Discord can't tell Domino about that takeover, so it's up to you to mark it. Remove an externally owned role in Discord yourself when appropriate. Operator backends call `management.retryRewardDelivery(id)` and `management.discordRoles.preserve(id, { reason })`, and `management.discordRoles.list()` to list every role deliveries have granted, with the quests that requested it and its deliveries counted by status. CLI automation uses the same routes: ```sh domino api GET /reward-deliveries/roles --json domino api GET '/reward-deliveries?member=alice' --json domino api POST /reward-deliveries/DELIVERY_ID/retry --json printf '%s' '{"reason":"Administrator owns this assignment"}' | domino api POST /reward-deliveries/DELIVERY_ID/preserve - --json ``` > [!DEV] > Preserving belongs to the Discord role reward kind: a delivery of another kind is refused with 409. `management.preserveRewardRole(id, { reason })` is its deprecated name. After an uncertain response, read the delivery's `reconciliation` before repeating a call. Test and Live both send real Discord requests, and the same server, account and role share one membership. CLI sessions can't write Live; use an API key with `review` permission. > > Authors use `discord.roles()` to select an eligibility role and `discord.roles({ assignable: true })` to select a reward role. Both reuse the integration's resource picker. ## Announce in a channel [#announce-in-a-channel] A quest can post a message in a channel of your server each time it pays, such as "@ana got +10 XP from @sam" when a moderator reacts to Ana's message. Telegram groups work the same way; see [Announce tips in the group](/docs/build/telegram#announce-tips-in-the-group). 1. **Choose the channel.** In the quest's **Settings**, choose the channel. The picker disables channels where the bot can't see or post. 2. **Give the bot permission.** In Discord, give the bot **View Channel** and **Send Messages** there. Publication checks the bot's current permissions. How it behaves: * **Points first.** The message is posted after the rewards are saved. A Discord outage or a refused message never undoes them. * **It keeps trying.** Domino retries a failed post with backoff for about a day. A post Discord refuses for good, for example because the bot lost access, shows why in the member's **Announcements**, and **Retry delivery** posts it once you've fixed the cause. * **Paced, never dropped.** Domino posts at most one message a second per channel and waits out Discord's rate limits, so a burst waits in line instead of being lost. * **Only the people it names are pinged.** Members are mentioned through their linked Discord account; `@everyone`, `@here` and role mentions never ping. A member without a linked Discord account is named by their Domino display name. A test-linked account, someone else's real account, is never mentioned. * **Only your server.** The channel must belong to the server connected for the project and environment. A Test environment never posts in a server any project connected to Live, and publication refuses one. * **Revoking.** Revoking the completion cancels a message not yet posted; one already posted stays. > [!DEV] > List `discord.postMessage({ channel, text, replyToTriggeringMessage })` in the quest's `announcements`, and declare `integrations: [discord]`. Choose the channel with `settings.resource({ label, source: discord.channels({ postable: true }) })`, which disables channels the bot can't post in. The text takes the placeholders `{beneficiary}`, `{author}`, `{points}`, `{rewards}` and `{quest}`; the [Telegram guide](/docs/build/telegram#announce-tips-in-the-group) has the table and a complete tipping quest. Unlike Telegram, the template keeps its Discord Markdown; names a member controls are escaped. `replyToTriggeringMessage` replies to a `discord.messages()` or `discord.reactions()` record's message in the same channel. Each post is a reward delivery with `provider: "discord"`, `action: "post-message"` and an empty `roleId`. ## Disconnect or recover [#disconnect-or-recover] Disconnecting the integration stops new Discord checks. Members keep their linked accounts, completions and earned rewards. It doesn't remove the bot from your server, because another project or environment may use it. Remove the bot in Discord to uninstall it completely. An attempt keeps the member's Discord account (or the fact that they had none) and the server from its first Discord check, and never checks them again. Changing either afterwards doesn't affect that attempt: use **Start a new submission** to check the new connection. Discord outages retry on their own. A missing connection, removed bot access or a connection that needs attention stop the attempt; see [Recover a configuration failure](#recover-a-configuration-failure). ## Configure the managed Discord application [#configure-the-managed-discord-application] This is for the Domino platform administrator, not for project teams. Configure these as separate API Worker secrets from the same Discord application: | Secret | Purpose | | ----------------------- | ---------------- | | `DISCORD_CLIENT_ID` | OAuth credential | | `DISCORD_CLIENT_SECRET` | OAuth credential | | `DISCORD_BOT_TOKEN` | Bot token | 1. Deploy an API version that supports these secrets before setting them. 2. Set both OAuth credentials before connecting. Existing provider JSON doesn't need to be read or replaced. JSON-based Discord credentials keep working while neither dedicated OAuth credential is set. To read message text, see the callout in [Read a message from quest code](#read-a-message-from-quest-code). ## Verify X activity [#verify-x-activity] Quests can check a member's X account: that they connected it, follow you, reposted a post, wrote a post, or changed their profile. Members connect X through your app's account linking. Your project needs no X setup; Domino supplies the access. ### 1. Check the connected account [#1-check-the-connected-account] The simplest X quest is "Connect your X account". It only checks that the member linked one. > [!DEV] > `await x.connection()` returns `{ userId }`, or `null` when no unambiguous X account is linked. It works without the platform's X credential. ### 2. Verify the action [#2-verify-the-action] This quest checks whether the member follows an X account you configure: ```text import { x, defineQuest, settings, manual, once, points, decision } from "@domino-sdk/relay/authoring"; import { community } from "./ledgers"; export default defineQuest({ key: "follow-on-x", integrations: [x], title: "Follow us on X", settings: settings.object({ userId: settings.text({ label: "X user ID", default: "2244994945" }), }), trigger: manual(), completion: once(), rewards: [points(community, 50)], async evaluate({ x, settings }) { // null when the member has not linked an X account. const follows = await x.follows({ userId: settings.userId }); if (follows === null) { return decision.reject({ reason: "Connect your X account, then start a new submission." }); } return follows ? decision.accept() : decision.reject({ reason: "Follow the account, then start a new submission." }); }, }); ``` Declare the `community` points ledger in the same project. Use numeric X IDs as strings, not handles or URLs. Find an account's ID with `domino x lookup HANDLE` ([details](/docs/build/trackers#find-an-x-accounts-user-id)). Publish the quest through the normal quest workflow. > [!DEV] The `x` readers > These reads use the platform's X wrapper credential. Quest code never receives a credential and needs no project integration. > > | Method | Result | > | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | > | `x.connection()` | The linked member's `{ userId }`, or `null`. | > | `x.profile()` | The linked member's current profile: `id`, `username`, `name`, `bio`, `profileImageUrl`, `followersCount`, `verified`. Unavailable optional fields are `null`. `null` without a linked account. | > | `x.post({ postId })` | Post content, author, available metrics, nullable `inReplyToStatusId`, `inReplyToUserId`, `quotedTweetId`, `retweetedTweetId`, and attached `media`. `null` when the post doesn't exist. | > | `x.follows({ userId })` | Whether the linked member follows the target user ID. `null` without a linked account. | > | `x.reposted({ postId })` | Whether the linked member reposted the target post ID. `null` without a linked account. | > > Every method except `connection()` needs the configured wrapper access. `post()` reads any public post and needs no linked account; the others answer for the linked account. A post read can return someone else's post: to check authorship, compare `post.author.id` with `connection.userId`, and also check the required parent or quoted post, content and publication time. A missing metric stays `null`, not zero. ### 3. Handle retries and unavailable evidence [#3-handle-retries-and-unavailable-evidence] * **Start a new submission after acting.** An attempt keeps the X account the member had (or that they had none) at its first X check. A member who links another account, or follows you after a "no", starts a new submission so Domino reads X again. * **Outages don't count as "no".** If X can't be reached, the check tries again later. It never becomes a "doesn't follow" or an accepted claim. > [!DEV] > Captured results survive evaluation retries. Provider failures, malformed responses and missing credentials throw a `CapabilityError` with code `provider-unavailable`, and Domino retries the attempt if your code lets the error through. See [evaluator readers](/docs/reference/verification#evaluator-readers-null-errors-and-pinning). The platform operator configures `X_API_WRAPPER_API_KEY` on the backend, with the same optional `X_API_WRAPPER_BASE_URL` trackers use. > [!NOTE] These are checks at verification time > A successful check doesn't prove that a follow, profile change or repost was kept for any length of time. Like checks and X event delivery aren't available here. You can combine these reads with URL submissions and AI text review. Check profile pictures and post images with [image checks](/docs/build/images). Use [trackers](/docs/build/trackers) to keep a running dataset of posts. ### Start from a complete X quest [#start-from-a-complete-x-quest] The SDK examples include five X quests: | Example file in `packages/sdk/examples` | Checks | | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `x-follow-quest.ts` | The linked member follows the configured account ID. | | `x-repost-quest.ts` | The linked member reposted the configured post ID. | | `x-bio-quest.ts` | The linked profile bio contains the exact configured phrase. | | `x-original-post-quest.ts` | A submitted URL leads to the member's own post within the campaign dates, and the post passes the configured AI criteria. Unclear reviews go to staff. Only members of the approved-creators group (`inGroup`) can submit, because X doesn't allow apps that reward users for posting. | | `x-profile-picture-quest.ts` | The linked profile picture matches a deployed reference image, or AI confirms a customized version. See [check images](/docs/build/images). | Each example pays once per member and doesn't prove that a follow, repost or bio stays in place. > [!DEV] > Copy the example into your project's quests directory and change its relative SDK imports to `@domino-sdk/relay/authoring` and `@domino-sdk/relay/schema`. The original-post example also imports the `approvedCreators` group from `groups.ts`; create that group in Console **Groups** and add the creators you approve. Declare a `community` point ledger or select an existing one, configure targets and dates, and include the quest entry in `relay.json`. Run `domino check`, preview the publication, then deploy through the normal workflow. > > Your member app must offer X account linking. The original-post quest also needs the `url` interaction. A URL's handle never proves authorship; the example compares the provider's author ID with the linked account. The AI sees the text X returns, not the submitted URL. ## Restore a retained OAuth selection [#restore-a-retained-oauth-selection] A reviewer can open a member's **Identities** section and choose **Relink** for an X or Discord account the member already proved. Disconnect any other current account first. The change applies in that project and environment. Relinking keeps ownership, sign-in permissions and existing sessions, and records a fresh link time. > [!DEV] > Read the member's identities with `management.members.identityStates.list(member)`, then call `identityStates.relink(member, { identity: { kind: "oauth", provider, issuer, subject }, expectedRevision, reason })`. Copy the issuer and subject from the listed choice; relink can't assign an identity the member never proved. Historical activity follows the current quest policy, and admitted attempts stay pinned. See [relink retained identities](/docs/concepts/trackers#relink-retained-identities). # Send reminders from Klaviyo (/docs/build/klaviyo) > [!NOTE] Experimental > The Klaviyo integration is experimental: what it sends and its settings can change in a minor release. Domino never emails your members. Connect Klaviyo, and Domino keeps every member's Klaviyo profile up to date with what only Domino knows: their points, rank, finished quests and rewards waiting to be picked up. It also records what happens in your program, such as a reward earned, on their profile. Your Klaviyo flows decide who hears what, and Klaviyo sends it. This guide builds the reminder a pizza club sends: **"You earned a pizza and haven't picked it up yet."** ## Before you start [#before-you-start] * A Klaviyo account, and someone who can sign in to it to grant Domino access. * Your app asks members for marketing email consent. Domino sends only members who opted in, and matches them to Klaviyo by their verified email. See [record consent from a checkbox](/docs/build/consent). ## 1. Try it in Test, without Klaviyo [#1-try-it-in-test-without-klaviyo] In Test, Klaviyo can run as a dry run: Domino works out everything it would send, and sends nothing. 1. Open **Integrations** in Test. Open the **Klaviyo** card and choose **Connect as dry run**. 2. Optionally fill in **List ID**, the Klaviyo list consenting members join, from the list's settings in Klaviyo. Without one, Klaviyo's own opt-in setting for your account applies. 3. Have a test member sign in with an email, opt in, and finish a quest. 4. In the Klaviyo panel, open **Dry-run log**. Within about a minute you see the profile update with the member's points, the email subscription, and an event for the finished quest. Members who haven't opted in, or have no verified email, are left out and counted in the panel by reason. To send members who haven't opted in too, turn on **Include members without consent**: their profiles and events reach Klaviyo, and Domino never subscribes them. > [!DEV] > `domino connections connect klaviyo --dry-run --config-json '{"listId":"…"}'` connects the dry run, and `domino connections log klaviyo` lists what it would have sent, newest first. Domino keeps the latest 500 requests. ## 2. Connect Klaviyo [#2-connect-klaviyo] 1. Open **Integrations** in Live. Open the **Klaviyo** card and choose **Connect with Klaviyo**. 2. Fill in the settings, then sign in to Klaviyo and grant Domino access. Klaviyo sends you back to the Klaviyo panel, connected. 3. Domino then sends every member who opted in to Klaviyo: their profile and their email subscription, dated when each one opted in. Past events are never replayed, so a quest finished last month doesn't set off a flow today. Someone Klaviyo already has as unsubscribed stays unsubscribed: Domino never subscribes them again on the opt-in they gave before you connected, since that would undo their unsubscribe in Klaviyo. Their profile is still kept up to date. They're subscribed again only when they opt in again in your app. To test against a real Klaviyo account first, connect a separate Klaviyo account in Test the same way; that ends the dry run there. From then on, a member's profile follows their changes within about a minute. Opting in subscribes them; opting out unsubscribes them. > [!DEV] > `domino connections authorize klaviyo --config-json '{…}'` prints the address to open instead; open it in the browser where you're signed in to Console, which finishes connecting as you. Connecting in Live from the CLI needs a signed-in CLI session. ## 3. Build the reminder flow in Klaviyo [#3-build-the-reminder-flow-in-klaviyo] In Klaviyo, create a flow: 1. **Trigger:** the metric **Domino Reward Earned**. 2. **Time delay:** 24 hours. 3. **Conditional split:** the profile property **domino\_unredeemed\_rewards** is greater than 0. Continue on the yes branch only. 4. **Email:** "You earned a pizza and haven't picked it up yet." The split reads the profile when the member reaches it, a day after the reward. A member who picked up their pizza in the meantime has `domino_unredeemed_rewards` back at 0 and gets nothing. Klaviyo ignores events for addresses at `example.com` and `test.com`. Test the flow with real addresses you own. ## What Klaviyo receives [#what-klaviyo-receives] | On the profile | Holds | | ---------------------------------- | ------------------------------------------------------ | | `domino_points_` | Points in each point balance, named by its key | | `domino_rank_` | Rank on each active leaderboard | | `domino_quests_completed` | Quests finished | | `domino_unredeemed_rewards` | Rewards earned and not picked up yet | | `domino_campaigns` | The campaigns the member is in | | `domino_user_id` and `external_id` | Your app's ID for the member, when your app linked one | | Klaviyo metric | When | | ------------------------- | --------------------------------------------- | | Domino Quest Completed | A member finished a quest | | Domino Reward Earned | A member earned a reward | | Domino Reward Redeemed | A reward was handed over | | Domino Points Awarded | A member received points | | Domino Referral Qualified | A referral counted, on both members' profiles | The [Klaviyo reference](/docs/plugins/klaviyo) lists every property, metric and setting. ## Keep an eye on it [#keep-an-eye-on-it] The Klaviyo card shows when Domino last synced, how many members it sent and how many it left out and why, and the latest errors with Klaviyo's answer. * **Resync** sends every member's profile again, without past events, to fix anything that drifted. * If sending keeps failing, after 10 failures in a row or a day of failures without one success, the card asks for attention with Klaviyo's latest answer. Domino keeps trying, and a resync fills the gap once Klaviyo answers again. * If someone removes Domino's access in Klaviyo, the card asks you to **Reconnect**. > [!DEV] > `domino connections activity klaviyo` prints the same status, and `domino connections resync klaviyo` starts a resync. # Run a leaderboard (/docs/build/leaderboards) A leaderboard ranks your members by their points. For example: * **"This week's top contributors"**, with prizes for the top three every Monday. * **"All-time XP"**, so long-time members can see where they stand. * **"Launch campaign race"**, between two fixed dates. * **"Token holders"**, ranked by a score your own system measures. Points from quests, referrals and your own server feed the board automatically. You never submit a score by hand. For the model behind this guide, read [Leaderboards](/docs/concepts/leaderboards) and [Points and ledgers](/docs/concepts/points). See [Compose a campaign](/docs/composition) for a complete example. ```mermaid flowchart LR Q["Quest awards"] --> L["Point ledger"] B["Trusted backend"] --> L R["Referral bonuses"] --> L L --> S["Score and window"] S --> T["Standings"] T --> A["Your app"] T --> F["Frozen settlement"] F --> O["Operator approval"] O --> P["Prize rewards"] ``` ## What members see [#what-members-see] * **The standings.** Each row shows a rank, a name and a score. Equal scores share a rank, such as `1, 2, 2, 4`, unless the board ranks whoever reached the score first. * **Their own place.** A signed-in member sees their own row and the members just above and below them. * **How they earned it.** Your app can list the point entries that make up a member's score. * **A name, or an alias.** Members appear under a stable alias, or under their chosen name and avatar when your project shows [profiles](/docs/build/profiles). * **Signed-in members only**, unless you make a board public. ## What you can do in Console [#what-you-can-do-in-console] Boards themselves are defined in code. In Console you can: | Without code | Where | | ------------------------------------------------------- | ---------------------------------------------------------- | | Create, rename and archive point ledgers | **Rewards → Point ledgers** | | Watch standings and see how a board is scored | **Leaderboards**, **About this board** | | Exclude a member from a board, or reinstate them | **Leaderboards**, select the member | | Award, spend, adjust or reverse a member's points | **Leaderboards** or the member's page | | Review prize winners, then approve or reject the prizes | **Leaderboards**, **Prize settlements** | | Take back one winner's prize | **Leaderboards**, **Revoke completion** beside the winner | | Get started with a coding agent | **Leaderboards**, **Copy prompt**, before any board exists | ## Choose a point ledger [#choose-a-point-ledger] A ledger holds the balances a leaderboard ranks. Manage ledgers in Console under **Rewards → Point ledgers**: | Action | Effect | | -------------------- | ----------------------------------------------------------------------------------------------------------------- | | **New ledger** | Creates a ledger with a name and a description that explains what it tracks. Domino generates its fixed ID. | | **Edit ledger** | Changes its name and description in a dialog without moving any points. | | **Archive ledger** | Hides it from new reward choices, after you give a reason. Existing rewards, balances and history stay available. | | **Unarchive ledger** | Offers an archived ledger to new rewards again, after you give a reason. | | Select its ID | Copies the ID for use in code. | Each ledger lists the published quests that pay into it. Two ledgers can both be named "XP" and still keep separate balances and histories. * **Change ledgers in test.** Ledgers are definitions, so live refuses creating, editing or archiving them, from Console too. Make the change in test, or declare the ledger in code and deploy it to test, then promote test to live. See [test and live](/docs/concepts/environments). * **Permissions.** Creating, editing and archiving a ledger need `publish` permission; reading the picker needs `read`. * **Audit.** Console **Audit history** records creating and archiving a ledger. An edit appears as **Points or leaderboard changed**. Ledgers declared in `relay.json` appear as part of the release that publishes them. Console member detail lists the member's balance in every ledger. Choose a ledger there to page through its history, award points, record a positive or negative adjustment, or reverse a positive award or adjustment. Point changes need `points` permission and always record the operator's reason. ### Reference ledgers in code [#reference-ledgers-in-code] > [!DEV] > - **Authored resources use handles.** Define a ledger with `defineLedger({ key, name })`, then pass the returned handle to `points`, `defineLeaderboard`, referral bonuses and balance prerequisites. Don't copy a Console ID into authored definitions. > - **Wire values use IDs.** Member and management reads, point commands and Console selections use the environment's opaque ledger ID, or a key where that API documents it. Metadata and points belong to one project and environment. > - **Settings get a picker.** Quest settings declared with `settings.pointLedger` show a searchable picker in Console. Search by name, description or ID; each option shows its ID so duplicate names stay distinguishable. > - **Live refusals.** Live refuses ledger changes from every credential with 422 `promotion-required`. > - **Management client.** `management.pointLedgers.list()` returns `archivedAt` with each ledger. Use `save({ id, name, description })` to update metadata and `archive(id)` to archive it. Saving never renames the accounting key or merges balances. > - **Member apps.** Reads return ledger IDs. Match them to your keys with `relay.pointLedgers.list()`, as described in [Match ledgers in your app](/docs/concepts/points#match-ledgers-in-your-app). ## Publish a leaderboard [#publish-a-leaderboard] Define a leaderboard in your project, add its module to `relay.json`, and deploy the project bundle. Leaderboards rank the project's existing point ledgers. > [!NOTE] Scoped to one project and environment > Test and live data stay separate. Organization-wide balances and rankings across projects are not supported. Use your hosted project's test environment while you build. Test points and rankings persist between `domino dev` sessions; restarting the app doesn't reset them. Publish the test configuration and check the rankings with real member accounts. The offline runtime has been removed. ### 1. Define the calculation [#1-define-the-calculation] ```text import { defineLeaderboard, points } from "@domino-sdk/relay/authoring"; import { community } from "../ledgers"; export default defineLeaderboard({ key: "weekly", title: "This week's contributors", ledger: community, score: { kind: "earned", window: { kind: "calendar", period: "week" }, }, settle: { at: "window-end", ties: "shared", prizes: [ { ranks: [1, 1], rewards: [points(community, 100)] }, { ranks: [2, 3], rewards: [points(community, 50)] }, ], }, }); ``` This board ranks the points members earned this week. When the week ends, first place wins 100 points and second and third win 50, once an operator approves. See [Choose what counts](#choose-what-counts) for scores and windows. The week is the [project calendar](/docs/concepts/calendar)'s. Set the project timezone once, in Console **Settings → General** or with `"calendar": { "timeZone": "Europe/Budapest" }` in `relay.json`. ### 2. Add the module to the manifest [#2-add-the-module-to-the-manifest] ```json { "leaderboards": [{ "entry": "src/weekly.ts" }] } ``` Keep the existing connection and quest fields in `relay.json`. > [!DEV] > An entry can select a named export with `exportName`. The build bundles the module without running it locally; Domino evaluates its definition in an isolated runtime without network access. ### 3. Preview and publish to test [#3-preview-and-publish-to-test] ```sh domino deploy --preview domino deploy ``` The preview shows the definition and the first page of proposed standings, with the time it calculated them. Points keep arriving after the preview, so it doesn't freeze any results. The second command publishes to test. ### Live publication [#live-publication] Publish to test directly with `domino deploy --environment test`. For live, the CLI prepares a request, and a person with publish permission approves it in Console. See [reviewed live publication](/docs/build/publishing#publish-behavior-to-live). ### Change a published leaderboard [#change-a-published-leaderboard] > [!IMPORTANT] Leaving a board out doesn't unpublish it > A board left out of a later bundle stays published. Change boards in source and publish another bundle. Every version of a board is kept. Console **Leaderboards** shows the standings and member controls for the selected project and environment. Before any board is published there, the page offers ready-made requests instead, such as a weekly board or all-time standings. Choose **Copy prompt** and paste it into your coding agent; the prompt includes the project, environment and setup steps. ## Choose what counts [#choose-what-counts] | Score | Ranks by | | --------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `balance` | The member's whole balance. Spending lowers their rank. This is the default. | | `earned` | Points earned in the window: awards, adjustments up or down, and reversals. Spending doesn't count. You can limit it to some quests. | | `net` | Every change in the window, including spending. | Earned and net scores take a window: | Window | Example | Meaning | | ---------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `all-time` | `{ "kind": "all-time" }` | The default. | | `fixed` | `{ "kind": "fixed", "start": 1790812800000, "end": 1793491200000 }` | Between two dates, in UTC epoch milliseconds; the start is included and the end isn't. | | `rolling` | `{ "kind": "rolling", "days": 7 }` | The last seven 24-hour periods, including entries accepted at the moment of calculation. | | `calendar` | `{ "kind": "calendar", "period": "day" }` | The current `day`, `week` or `month` of the [project calendar](/docs/concepts/calendar); weeks start Monday. Boundaries follow the project timezone, including daylight-saving changes, and match the project's budgets. | > [!DEV] > `earned({ quests })` takes quest handles to choose which awards count. ### Ranking rules [#ranking-rules] * **Quest awards count automatically.** They count when they pay into the board's ledger. For example, a quest award of 100 `community` points adds 100 to the weekly board above when it falls in the current week. You don't submit a separate leaderboard score. * **Points are whole numbers.** Custom formulas and outside measurements become points before they reach Domino. Use a dedicated ledger when an outside system owns the total; see [Rank an external score](#rank-an-external-score). * **Ties share ranks by default.** Equal scores share a rank, for example `1, 2, 2, 4`, and tied members keep a stable order. * **Or first to reach the score ranks higher.** A board can instead order equal scores by when each member reached their score: their latest qualifying entry, earliest first, then by member ID. Every member who scored gets their own rank, such as `1, 2, 3, 4`. On a balance or net board, a spend or a negative adjustment is a qualifying entry too, so it moves that time. * **No points, no rank, by default.** Members without a qualifying entry aren't ranked. A member who spends their balance down to zero stays ranked on a balance board. * **Or list everyone.** A board can also list members with no qualifying entry, at a score of 0. They share one rank after everyone who scored, even someone whose entries add up to 0 or less, and they never win prizes. * **Points on hold still count.** Points held for a redemption reduce what a member can spend, but they aren't spending. * **Everyone is eligible by default.** Every member of the project is eligible. A board can rank only the members of one [group](/docs/concepts/groups), such as those who enrolled. Group membership and points are separate records. > [!DEV] > Rank first-to-reach with `ties: "earliest"` on the board; omitted or `"shared"` keeps shared ranks. It is independent of `settle.ties`, so set both on a board that settles. List every member with `includeUnscored: true`. Unscored members are ordered by member ID, share one rank under either `ties`, and count in `total`. The board's `group` and exclusion group still apply. They appear in standings, `me`, `nearby` and `relay.leaderboards.placement`, but never in a [settlement](#settle-prizes-after-a-window). Rank only a group's members with `group: "enrolled"`. ## Settle prizes after a window [#settle-prizes-after-a-window] A board can hand out prizes when its window ends, such as every week or at the end of a campaign. Domino freezes the final standings, an operator checks them, and prizes go out only after approval. Add `settle` to an `earned` or `net` board with a `fixed` or `calendar` window. `at` is `"window-end"`. Prize ranges are inclusive, in order, and can't overlap. ```ts import { defineLeaderboard, defineLedger, defineTieredReward, points, staffHandover, untracked, } from "@domino-sdk/relay/authoring"; const community = defineLedger({ key: "community", name: "Community" }); const podium = defineTieredReward({ key: "podium", title: "Podium prize", tiers: [{ key: "gold", title: "Gold" }], inventory: untracked(), fulfillment: staffHandover(), }); export default defineLeaderboard({ key: "weekly", title: "This week's contributors", ledger: community, score: { kind: "earned", window: { kind: "calendar", period: "week" }, }, settle: { at: "window-end", ties: "shared", prizes: [ { ranks: [1, 1], rewards: [podium.tier("gold"), points(community, 100)], }, { ranks: [2, 3], rewards: [points(community, 50)] }, ], }, }); ``` * **Prizes are points and tiered rewards.** A prize can't repeat the same point ledger or tiered reward. Badges, group memberships and integration rewards, such as Discord roles, aren't supported: a badge or group prize makes `defineLeaderboard` throw, and a deployment refuse it, with a message naming the reward kind. Give those from a quest instead. * **Only boards with an end.** Balance, rolling and all-time boards can't settle. Choose how ties are paid before you publish: | `ties` | Result | | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `shared` | Equal scores share a rank. Every member whose shared rank is in a prize range gets that prize. | | `earliest` | Equal scores are ordered by who reached their score first (their last qualifying entry), then by member ID. Every position has its own rank. | The standings members see before the cutoff follow the board's own `ties`, not `settle.ties`. Set `ties: "earliest"` on the board as well so the live order matches what the settlement freezes. When the two differ, including a board that sets only `settle.ties: "earliest"`, `defineLeaderboard` prints a warning and `domino check` reports a `LEADERBOARD_TIES_DIFFER` warning that names the file. Neither stops the deploy. At the end of the window, Domino freezes the board version active at that moment, every eligible ranked row, the exclusions, the ranks and the prizes each row wins. Points, group or exclusion changes and new board versions after that don't change what you review. ### Split a pool by score [#split-a-pool-by-score] Add `pool` to `settle` to split a fixed amount over the eligible members in proportion to their scores. A settlement needs rank prizes, a pool, or both: with a pool, `prizes` can be empty, and `defineLeaderboard` lets you leave it out. ```ts import { defineLeaderboard, defineLedger } from "@domino-sdk/relay/authoring"; const views = defineLedger({ key: "views", name: "Clip views" }); export default defineLeaderboard({ key: "clip-contest", title: "Clip contest", ledger: views, score: { kind: "earned", window: { kind: "fixed", start: 1767225600000, end: 1769904000000 }, }, settle: { at: "window-end", ties: "shared", pool: { amount: "10000000000", unit: { code: "USDC", decimals: 6 }, minScore: 100, }, }, }); ``` | Field | Meaning | | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `amount` | The whole pool in base units, as a decimal string. `"10000000000"` is 10,000 USDC at 6 decimals. `parseUnits("10000", 6).toString()` from `@domino-sdk/relay` converts. | | `unit` | `"points"`, or `{ code, decimals }` for a token or currency (code up to 32 characters, 0–36 decimals). | | `ledger` | The point ledger a points pool pays into: a ledger handle or key. Required for `"points"`, refused for any other unit. | | `minScore` | Optional. Members scoring at or below it get no share. Members scoring 0 or less never get one. | > [!DEV] > A published leaderboard names two ledgers. `balance` is the one it ranks, which `defineLeaderboard` takes as `ledger`; `settle.pool.ledger` is the one a points pool pays into, and can be another. New fields that name a ledger are called `ledger`; the older ones, such as a leaderboard's `balance` and a points reward's `balance`, keep their name through SDK 1.x. A points pool holds at most 2,147,483,647 points. Each pool share rounds down, and the leftover base units go one each to the largest remainders, earlier positions first, so the shares sum exactly to the pool. See [Pool prizes](/docs/concepts/leaderboards#pool-prizes). The settlement reads (`settlementReview`, `settlement`, and `domino leaderboard-settlements get`) carry the frozen `pool` with its `allocated` and `unallocated` totals, and each row's `share` in base units. Console shows each winner's pool share, formatted with the unit's decimals, before you approve. ### Export the payout list [#export-the-payout-list] Relay pays a points pool on approval. For any other unit it only records the shares, and you pay them. In Console **Leaderboards**, select the window under **Prize settlements** and select **Download payout list (CSV)**. The button appears for a pending or approved settlement whose pool is not points. From the CLI, export the same list: ```sh domino leaderboard-settlements export SETTLEMENT > payouts.csv domino leaderboard-settlements export SETTLEMENT --json ``` The CSV has one row per frozen member, with columns `member`, `score`, `rank`, `share` (base units), `amount` (whole units), `unit`, and `wallets`: the member's verified linked wallets as `chain:address`, separated by spaces, with the CAIP-2 network in place of the chain for Cosmos wallets. Wallets a member import attached without proof are left out. `--json`, and `management.leaderboards.settlementExport(id)` from a server, return the same rows with each wallet as `{ chain, network, address }`. `settlementPayoutCsv(result)` from `@domino-sdk/relay/server` turns that result into the CSV the CLI prints. Reading the export requires `read` permission. A pending settlement's export shows what approval would make owed. A rejected settlement allocates nothing, so its export lists every share as 0 and the whole pool as unallocated, while its review keeps the frozen shares. ### Review and approve delivery [#review-and-approve-delivery] Open Console **Leaderboards**, select the board and the window under **Prize settlements**, and compare the frozen winners and prizes. Enter a reason and select **Approve and deliver**. A confirmation states how many members the prizes reach, the points they pay per ledger, and the environment; select **Approve and deliver** again to deliver them. This needs `review` permission, and delivers every listed prize together. Approval gives each winner one completion for their prize. Console shows the approval reason with who approved it and when. Server operators can do the same review: ```ts import { createManagementClient } from "@domino-sdk/relay/server"; declare const management: ReturnType; const windows = await management.leaderboards.settlements("weekly"); const latest = windows.at(0); if (!latest) throw new Error("No ended window to review"); const frozen = await management.leaderboards.settlement(latest.id); const approved = await management.leaderboards.approveSettlement(frozen.id, { actionId: `approve-${frozen.id}`, reason: "Eligibility and final scores verified", }); ``` > [!DEV] > Approval creates one completion per member who wins a rank prize or a points pool share (a member owed only a token or currency share gets none), and sends a `completion.created` [webhook event](/docs/reference/webhooks) for each, with `source.kind` set to `leaderboard`, the board ID in `source.id` and `quest`, and the settlement ID in `key`. Each point prize also sends `points.awarded` with the same `source`. The settlement keeps the approval reason, the approver and the approval time, and Console shows all three. An exact retry with the same action ID returns the first result without delivering the prizes twice. ### Correct one winner [#correct-one-winner] An approved settlement stays as the record of the review. To take back one winner's prize, use **Revoke completion** beside that winner in Console. * **Points come back as far as they can.** Domino reverses each point prize up to what the member hasn't spent or put on hold in that ledger, and shows what it couldn't reverse. It records no debt, so points the member already spent or reserved stay with them. Console keeps that remainder visible after a reload. Someone with `points` permission can open the linked point adjustment and record an explicit correction with its own reason. * **Tiered prizes are corrected too.** The winner's tiered-reward entitlement is updated. * **No second payout.** A leaderboard prize can't be freed and awarded again. Console keeps the winner in the settlement and marks the completion revoked. A trusted backend can revoke the row's completion instead: ```ts import { createManagementClient } from "@domino-sdk/relay/server"; declare const management: ReturnType; const approved = await management.leaderboards.settlement( "leaderboard_settlement_example", ); const winner = approved.rows.find((row) => row.member === "member_alice"); if (winner?.completion) { await management.completions.revoke({ actionId: `revoke-${winner.completion}`, completion: winner.completion, reason: "Member was not eligible at cutoff", freeOccurrence: false, }); } ``` > [!DEV] > The result reports any unreversed remainder per point ledger. Its `source` is `{ kind: "leaderboard", id }` with the board ID, as on the webhook. ## Read standings in your app [#read-standings-in-your-app] Your app reads the boards and their standings, one page at a time: ```ts import { createRelayClient } from "@domino-sdk/relay"; const domino = createRelayClient({ baseUrl: "/relay" }); const boards = await domino.leaderboards.list(); const page = await domino.leaderboards.standings("weekly", { limit: 25 }); const { items, me, nearby, period, calculatedAt, lastEntryAt } = page; if (page.nextCursor) { const nextPage = await domino.leaderboards.standings("weekly", { cursor: page.nextCursor, limit: 25, }); } ``` | Field | Contains | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `items` | Ranked rows. Render each item's `rank`, `name` and `score`, and use `participant` as its stable row key for this board. `profile` holds the name and avatar where the project shows profiles. | | `me` | The signed-in member's row, or `null` when unranked or signed out. With `includeUnscored`, an eligible member always has a row. | | `nearby` | Up to two competitors on either side, plus that member. | | `total` | The number of ranked members. | | `lastEntryAt` | When Domino received the latest balance entry, not when an outside provider last synced. | | `nextCursor` | The cursor for the next page, when there is one. | Relay may add fields to a page and its rows in later releases, so read the ones you use and ignore the rest. Show the returned rank, not the row's position, so ties display correctly. On a board with `ties: "earliest"` the rows come back already in first-to-reach order, each with its own rank, so one paged read gives the tie-broken board. Show an empty state when `items` is empty, and an unranked state when a signed-in member's `me` is null. > [!DEV] Newer score kinds and paging > To read definitions that may include score or window kinds newer than your SDK, use `domino.response("/v1/leaderboards", leaderboardListResponseSchema)` with the schema from `@domino-sdk/relay/schema`. Each definition has a `supported` or `unsupported` result, and `unsupportedCount` says how many your SDK can't interpret. Keep unsupported boards visible as unavailable instead of guessing their rules. See [Read evolving responses](/docs/reference/participant-api#read-evolving-responses). Response readers accept added fields and larger server windows or quest lists without applying authoring limits; authoring definitions still enforce them. > > If points, group membership, exclusions or the definition change, an earlier cursor returns HTTP 409. On a board with `includeUnscored`, so does a member joining the project or a test-member reset. Restart at the first page. Cursors expire after 15 minutes, with the same 409. A cursor from another project or board, or one that isn't a cursor, returns HTTP 400 `invalid-cursor`. A rolling window keeps the first page's calculation time while paging. ### Show how a member earned their score [#show-how-a-member-earned-their-score] Your app can list the point entries that make up a signed-in member's score on one board, so they can see where it came from. Domino picks the entries with the board's own rules: spending is left out of an `earned` board, and imported opening balances count only on a `balance` board. Together the pages add up to the member's score, as long as they aren't excluded or removed from the board's group while you page. ```ts import { createRelayClient } from "@domino-sdk/relay"; const domino = createRelayClient({ baseUrl: "/relay" }); const page = await domino.me.leaderboardHistory({ board: "weekly" }); const { items, period } = page; if (page.nextCursor) { const older = await domino.me.leaderboardHistory({ board: "weekly", cursor: page.nextCursor, }); } ``` > [!DEV] > `relay.me.leaderboardHistory({ board })` takes the board by key or ID and applies its ledger, window, `quests` and calculation. In React, `useLeaderboardHistory(board)` returns the same entries with `loadMore()`. > > - **The point history's entries.** Each item has the same shape as an entry from [`relay.me.pointHistory()`](/docs/concepts/points): `id`, `amount`, `kind`, `at`, `quest`, `reverses` and `imported`, without the operator's reason. Pages hold up to 100 entries, most recently recorded first. > - **One window across pages.** `period` is the window the first page counted, and the cursor keeps it, so a rolling or calendar window that moves while you page doesn't change which entries count. Republishing the board invalidates a cursor with HTTP 400 `invalid-cursor`; restart at the first page. > - **The member's own entries only.** It needs a signed-in member on every board, public ones included, and takes no member. A member the board excludes, or one outside its `group`, gets no entries, matching their `null` rank. An unknown board returns 404 `not-found`, and an archived one 404 `leaderboard-archived`. ### Access and privacy [#access-and-privacy] * **Signed-in members by default.** Only signed-in members can read a board, unless you make it public. Signing out hides private boards. * **Aliases unless you show profiles.** Every row has a stable alias for that board. Where an operator makes [member profiles](/docs/build/profiles) visible, the row shows the member's chosen name and avatar instead; members who hid their profile keep the alias. * **No private details.** Rows never show internal member IDs, emails, account IDs or wallet addresses. * **A member's place on a profile page.** Your app can show one member's place on a board, applying the board's access and the profile's visibility. See [compose a profile page](/docs/build/profiles#compose-a-profile-page). > [!NOTE] Why rows say "Participant 1a2b3c4d" > Profiles are private until you turn them on, so a new project shows every member under a generated name that nobody recognises, even members who linked Discord or X. To show names, make profiles visible and choose X or Discord as default names in **Settings → Profiles**. See [show member profiles](/docs/build/profiles#turn-profiles-on). The change applies in test right away, and in live once you [promote](/docs/concepts/promotion) it. > [!DEV] > Boards default to `access: "participants"`; set `access: "public"` to allow anonymous reads through the project's configured participant endpoint. The alias is in `participant`, and `profile` is always present, `null` when no profile is visible; `name` is the profile name when one is visible. Management responses also include member IDs, and the profile whatever the settings, for staff work. Read one member's place with `relay.leaderboards.placement(board, profileId)`. The participant proxy and existing-app identity bridge support these read-only routes. Never put a management credential in member-facing code. ## Supply points from a trusted backend [#supply-points-from-a-trusted-backend] Your own server can award points for things Domino doesn't see, such as purchases or event check-ins, and correct them. Those points count on leaderboards like any other. It writes with a management credential scoped to the project and environment: | Where the code runs | Credential | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | A Domino-hosted app's own Worker | The app's key, already bound as `DOMINO_MANAGEMENT_KEY`. See [Call the management API from your server](/docs/build/hosted-app#call-the-management-api-from-your-server). | | Any other backend | A [service key](/docs/start/existing-app#5-call-domino-from-your-backend) created in Console, kept in that backend's secret store. | In a hosted app's Worker, build the client from its bindings: ```ts import { hostedManagementClient } from "@domino-sdk/relay/server"; export function management(env: Record) { return hostedManagementClient(env); } ``` Elsewhere, pass your own key to `createManagementClient`, as in the example below. Both clients have the same methods. | Operation | Required permission | | ---------------------- | ------------------- | | Point updates | `points` | | Reads | `read` | | Custom tracker records | `ingest` | Editors and hosted-app keys have all three. `ingest` allows custom tracker records and nothing else. Reviewers and redemption staff can't change points. Every update needs an existing member of the project, an `actionId` and a reason. Reuse the same action ID and payload after a timeout; a changed payload with the same ID is rejected. ```ts import { createManagementClient } from "@domino-sdk/relay/server"; const management = createManagementClient({ baseUrl: "https://api.example.com", organization: "my-organization", project: "community", environment: "test", getToken: async () => "SERVER_SIDE_MANAGEMENT_KEY", }); const award = await management.points.record({ kind: "award", actionId: "external-activity-42", member: "member_existing", balance: "community", amount: 100, reason: "Verified external activity 42", effectiveAt: Date.parse("2026-09-01T12:00:00Z"), }); await management.points.record({ kind: "reverse", actionId: "activity-42-correction", member: "member_existing", balance: "community", entry: award.entry.id, amount: 20, reason: "Twenty units were attributed incorrectly", }); ``` | `kind` | Takes | Behavior | | --------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `award` | A positive amount | Adds points. | | `spend` | A positive amount | Spends points. Spending can't increase a balance. | | `adjust` | A nonzero signed amount | Changes earned scores. | | `reverse` | A positive amount and the `entry` ID of a positive award or adjustment | Can't exceed what's left unreversed. It takes the original entry's effective time and quest, so it corrects past earned scores. | | `set` | `total`, `expectedTotal`, and `classification: "earned"` or `"spend"` | Replaces the total. See [Absolute updates](#absolute-updates). | Every entry also records when it was received and who made it. ### Absolute updates [#absolute-updates] Read the current total with `management.points.balance(member, balance)` first. If another write changes it in between, the update fails with HTTP 409. Read again, decide again, and send a new action ID. An exact retry of a successful command returns its original result even if the balance has changed since. > [!WARNING] `set` replaces the whole balance > An absolute update replaces the entire balance; it doesn't separate one source's share. ### Timing, limits, and history [#timing-limits-and-history] * **Effective time.** Without `effectiveAt`, an entry takes effect when it's received. Backdated entries are allowed; future dates are rejected. * **No negative balances.** A deduction can't make a balance negative or use points on hold. Sort out the conflicting spending or hold before applying a correction. * **Journal.** Read the append-only journal with `management.points.history(member, balance, { cursor? })`, 100 entries a page; pass a page's `nextCursor` back as `cursor` for the next. ## Rank an external score [#rank-an-external-score] To rank something measured outside Domino, such as a rating from your game server or tokens held in members' wallets, give it its own ledger. Your server keeps that ledger equal to the score with absolute updates, every change is recorded, and a `balance` board ranks it. ```ts import { defineLedger } from "@domino-sdk/relay/authoring"; export const tokenBalance = defineLedger({ key: "token-balance", name: "Tokens", description: "Tokens held across linked wallets, in whole tokens. Never spent.", }); ``` Register this export under `pointLedgers` in `relay.json`, then publish a `balance` board over the handle: `defineLeaderboard({ key: "token-balance", title: "Token holders", ledger: tokenBalance, score: { kind: "balance" }, access: "public" })`. A score your server already knows needs only the `set` loop at the end of the example below. For a balance, keep the samples in a [wallet tracker](trackers.md#wallet-accounts) and set the ledger from the tracker's current total. Reading the chain is up to you: supply `readBalance(account)`, and the example samples every linked wallet through it. ```ts import { caip10 } from "@domino-sdk/relay/schema"; import { hostedManagementClient } from "@domino-sdk/relay/server"; type Management = ReturnType; /** * Supplied by your app: one account's balance in base units, or null for an account you do not * read, such as one on another chain. `account` is a CAIP-10 account ID. */ declare function readBalance(account: string): Promise<{ amount: string; decimals: number } | null>; export async function refreshBalance(env: Record, member: string) { const management = hostedManagementClient(env); const accounts = (await management.members.get(member)).wallets .filter((wallet) => wallet.status === "linked") .map((wallet) => caip10(wallet)); const observedAt = Date.now(); const samples = await Promise.all( accounts.map(async (account) => ({ account, balance: await readBalance(account) })), ); const records = samples.flatMap(({ account, balance }) => balance ? [{ id: account, author: { id: account }, occurredAt: observedAt, observedAt, data: balance }] : [], ); if (records.length) { await management.trackers.ingest("token-balance", { actionId: `token-balance-${member}-${observedAt}`, records, }); } await setScore(management, member); } async function setScore(management: Management, member: string) { for (let attempt = 0; attempt < 2; attempt++) { // Sum what the tracker holds now, never the values just read: a slower refresh cannot win. const page = await management.trackers.records("token-balance", { member, limit: 100 }); // Sum in base units at the finest precision any wallet reports, then convert to whole tokens once. const decimals = Math.max(0, ...page.items.map((record) => Number(record.data.decimals))); const units = page.items.reduce( (sum, record) => sum + BigInt(String(record.data.amount)) * 10n ** BigInt(decimals - Number(record.data.decimals)), 0n, ); const total = Number(units / 10n ** BigInt(decimals)); const watermark = Math.max(0, ...page.items.map((record) => record.observedAt)); const { total: expectedTotal } = await management.points.balance(member, "token-balance"); if (total === expectedTotal) { return; } try { await management.points.record({ kind: "set", classification: "earned", member, balance: "token-balance", total, expectedTotal, actionId: `score-token-balance-${member}-${expectedTotal}-${total}-${watermark}`, reason: `token-balance total at ${new Date(watermark).toISOString()}`, }); return; } catch (error) { // 409: another refresh changed the balance first. Read again once; the next refresh converges. if ((error as { status?: number }).status !== 409) { throw error; } } } } ``` * **Your app chooses the chains.** `caip10(wallet)` names Ethereum mainnet for an EVM wallet and Solana mainnet for a Solana wallet; pass the network you read, such as `caip10(wallet, "eip155:8453")`, to name another. A smart-contract wallet is named on the chain that proved it (`provedOn`), and `caip10` throws for any other network: skip it when you read another chain. A Cosmos wallet's chain ID is the one it was linked under, which proves nothing: check the address prefix in `readBalance` and return `null` for chains you don't read. * **The tracker is the source of truth.** The score sums the tracker's current records for the member's linked wallets, so a refresh that finishes out of order still computes from the latest samples, and an unlinked wallet stops counting at the next refresh. * **Compare-and-set.** `set` with `expectedTotal` refuses a concurrent change with 409. The action ID names the inputs, so an exact retry replays and a new total gets a new entry. * **Whole units.** Scores are whole numbers, so the example sums base units and converts the total to whole tokens once, rounding down. * **Earned, never spent.** A decrease becomes a negative adjustment, which a `balance` board ranks correctly. Don't price rewards in a score ledger or pay it from quests; a Console adjustment is overwritten by the next refresh. * **Current as of the last refresh.** Refresh after sign-in, after a wallet link, and from a [scheduled hosted-app route](/docs/build/schedules#feed-a-custom-tracker) that pages through members. The scheduled route verifies Domino's signature and sends custom tracker records with the hosted app's key, so refreshes continue when members don't come back. A hosted app's key belongs to one environment: the staged build's key works on test data and the live app's on live data. To refresh from any other server, use a Console management key with `ingest`, `points` and `read`. ## Moderate participation [#moderate-participation] In Console **Leaderboards**, **About this board** beside the standings names the scoring method, **Current balance**, **Points earned** or **Net points**, and shows the board's ledger, period, audience, eligibility and ID. Standings stay visible while the next page loads. **Previous** and **Next** appear once a board has more than one page, and wait until that page has loaded. Select a member's row to open them beside the standings. To reach a member who isn't ranked, search by name, email or ID in **Find a member** above the standings and choose them from the results. | Where | Action | Effect | | ------------------------------------ | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Eligibility** | **Exclude from this board** / **Reinstate on this board** | Needs a **Reason for eligibility change**. Adds them to, or removes them from, the board's [exclusion group](/docs/concepts/leaderboards#exclusions). Their points don't change, and they stay listed under **Excluded members** until reinstated. | | **Excluded members** | **Change exclusion group**, choose an **Exclusion group** and give a **Reason**, then **Save** | Points the board at another group, such as one several boards share, or back to **This board's own group**. Later standings and settlements leave out that group's members. | | **Eligibility**, boards with a group | **Add to group** / **Remove from group** | Changes membership of the board's [group](/docs/concepts/groups), with the same reason. Publishing the board created the group if it was missing. | | **Change points** | **Award**, **Spend** or **Adjustment**, then **Apply points** | Needs the points and a **Reason for change**. Console shows the new balance before you apply it and blocks a deduction larger than the available points. | | **Points history** | **Reverse** beside a positive award or adjustment, then **Reverse points** | Takes back up to that entry's points, with a reason, in the entry's original period. Entries are newest first; **Load older entries** reads the next page. | A [banned member](/docs/use/member-bans) is left out of every board's standings, and of any settlement that freezes while they are banned, without being added to an exclusion group. Unbanning them brings them back. Exclusions and group membership can change in live. Changing which group a board excludes is a definition change, so live refuses it, like other definition changes: make it in test and promote. > [!DEV] > Backends with `publish` permission can call `management.leaderboards.exclude(board, { actionId, member, excluded, reason })`, and change a board's group with `management.groups.addMember(group, { member, reason })` and `removeMember(group, member, { reason })` (see [Groups](/docs/concepts/groups)). These work in live with a personal API key, so automation can moderate a live board. **Audit history** records each change under the key's owner. A CLI sign-in cannot make these changes in live. > > Read definitions with `list()`, definition versions with `history(board)`, and exclusions with `exclusions(board)`. `exclusionGroup(board)` returns `{ leaderboard, group, generated, boundAt }`, the group whose members the board leaves out; `boundAt` is `null` until the first exclusion creates the board's own group. Change that group's members with `management.groups` as well, and bind another one with `bindExclusionGroup(board, { group, reason })`. Binding is a definition change, so live refuses it with `promotion-required`, as it does **Change exclusion group** in Console. ### What leaderboards do not do [#what-leaderboards-do-not-do] A board without `settle` never awards prizes or finalizes results; only a board with `settle` freezes its window and pays [prizes](#settle-prizes-after-a-window), after a reviewer approves them. A "top ten" reward built elsewhere must decide how it treats ties and when results become final, and can read standings through an authorized backend. No board finds members' outside accounts or collects activity from other services on its own: points reach it from quests, referrals and your server. Boards never pay tokens or money either: a pool in another unit is recorded for you to [pay](#export-the-payout-list). ### Referral bonuses [#referral-bonuses] [Referral programs](/docs/build/referrals) can add inviter bonuses to the same ledger. Those points count on boards like other earned points. Awards from your own server earn bonuses only when they opt in, and reading a leaderboard never triggers a referral payout. Prize points earn no referral bonus. > [!DEV] IDs > Point entry IDs are opaque prefixed strings. New IDs have 22 random alphanumeric characters after the prefix; keep the full returned ID when reversing an entry. Existing IDs stay valid. ## Reject an incorrect settlement [#reject-an-incorrect-settlement] Open the frozen settlement in Console and choose **Reject settlement** with a **Rejection reason** before anyone approves it. The window stays in history as rejected and pays no prizes. Rejection is final for that window: it can't create a new freeze. To correct a prize that's already approved, revoke that winner's completion instead. > [!DEV] > Scripts read `management.leaderboards.settlementReviews(board)` or `settlementReview(id)`, then call `rejectSettlement(id, { expectedCreatedAt: settlement.createdAt, reason })` with `review` permission. The CLI provides `domino leaderboard-settlements list BOARD`, `get SETTLEMENT` and `reject SETTLEMENT --expected-created-at TIME --reason TEXT`. ## Compose achievement displays [#compose-achievement-displays] Leaderboard prizes grant points and tiered rewards. Badges come from quests or reviewers. Your app can show a member's badges next to their leaderboard results. See [badges](/docs/concepts/rewards#badges). > [!DEV] > Read a member's badges with `relay.me.badges()`, or a visible profile's with `relay.profiles.badges(profileId)`. # Use Domino with Para (/docs/build/para) If your app signs users in with [Para](https://www.getpara.com), they can use Domino's quests, points and leaderboards without a second login. Your server checks a Para JWT and tells Domino who the user is and which wallets Para holds for them. Their Para wallets join their Domino wallets, so wallet quests count them straight away. This is [app login](/docs/build/app-login) with Para's JWT as the session. It needs a server route, such as a [Domino-hosted app's Worker](/docs/build/hosted-app) or your own backend. ```mermaid sequenceDiagram actor User participant Browser as Your app participant Server as Your server participant Domino User->>Browser: Log in with Para Browser->>Server: Token on /relay Server->>Server: Verify it Server->>Domino: User ID, wallets Domino-->>Browser: Quests ``` ## Before you start [#before-you-start] * **An app login key that vouches for accounts.** An organization admin [creates the key](/docs/build/app-login#1-create-an-integration-key) for your app's address and [turns on **Allow vouching**](/docs/build/app-login#turn-it-on). Your server reads it as `APP_IDENTITY_KEY`. * **Your Para API key's ID.** Para sets each JWT's audience (`aud`) to the ID of the API key that issued it, so the route only accepts your app's tokens. Save it as `PARA_API_KEY_ID`. * **Para's environment.** The route reads Para's production keys from `https://api.getpara.com`. With a beta API key, change `apiUrl` to `https://api.beta.getpara.com`. ## Connect Para [#connect-para] ### 1. Add the server route [#1-add-the-server-route] Mount this module on `/relay/*` in your server, with your app's address as `origin`, and set `cosmos` to your app's Cosmos chain. It fetches Para's public keys and accepts the JWT as an `Authorization: Bearer` header. ```ts import { createParticipantBackend } from "@domino-sdk/relay/portal-proxy" import { createRemoteJWKSet, errors, jwtVerify } from "jose" // Your app's Cosmos chain: Para's token names no chain ID, and Domino needs one. const cosmos = { network: "cosmos:cosmoshub-4", prefix: "cosmos" } as const type ParaWallet = { type?: string; address?: string } /** A wallet Domino can link, or null for a chain it doesn't take. */ function wallet({ type, address = "" }: ParaWallet) { if (type === "EVM") { return { kind: "wallet", chain: "eip155", address } as const } if (type === "SOLANA") { return { kind: "wallet", chain: "solana", address } as const } // Only your chain's addresses: another prefix is another Cosmos chain. if ( type === "COSMOS" && address.slice(0, address.lastIndexOf("1")) === cosmos.prefix ) { return { kind: "wallet", chain: "cosmos", network: cosmos.network, address, } as const } return null } /** * The wallets in a Para JWT: its embedded `wallets` first, then the external wallet the user * signed in with, at most 20. */ export function paraWallets(data: { wallets?: ParaWallet[] externalWallet?: ParaWallet }) { return [...(data.wallets ?? []), data.externalWallet] .map((entry) => (entry ? wallet(entry) : null)) .filter((account) => account !== null) .slice(0, 20) } /** Verifies the JWT from Para's `issueJwt()` against Para's public keys and your API key's ID. */ export function paraAuthenticate(config: { /** The `aud` of your app's tokens: your Para API key's ID. */ apiKeyId: string /** `https://api.getpara.com`, or `https://api.beta.getpara.com` for Para's beta environment. */ apiUrl: string }) { const jwks = createRemoteJWKSet( new URL("/.well-known/jwks.json", config.apiUrl) ) return async (request: Request) => { const token = request.headers .get("Authorization") ?.match(/^Bearer (\S+)$/)?.[1] if (!token) { return null } try { const { payload } = await jwtVerify<{ data?: { wallets?: ParaWallet[]; externalWallet?: ParaWallet } }>(token, jwks, { audience: config.apiKeyId }) return payload.sub ? { subject: payload.sub, accounts: paraWallets(payload.data ?? {}) } : null } catch (error) { // A forged, expired or misaddressed token is a signed-out visitor. Anything else, such as // the provider's keys failing to load, is a fault. if ( [ errors.JWTClaimValidationFailed, errors.JWTExpired, errors.JWSSignatureVerificationFailed, errors.JWSInvalid, errors.JWTInvalid, errors.JWKSNoMatchingKey, errors.JOSEAlgNotAllowed, ].some((type) => error instanceof type) ) { return null } throw error } } } let backend: ReturnType | undefined /** The `/relay/*` handler, built once so the verifier keeps its keys between requests. */ export function participant(env: { APP_IDENTITY_KEY: string PARA_API_KEY_ID: string }) { backend ??= createParticipantBackend({ baseUrl: "https://relay.domino.run", origin: "https://app.example.com", integrationKey: env.APP_IDENTITY_KEY, authenticate: paraAuthenticate({ apiKeyId: env.PARA_API_KEY_ID, apiUrl: "https://api.getpara.com", }), }) return backend } ``` The route signs nobody in for a missing, expired or forged token, or one issued for another Para API key. Domino gets the Para user ID as the member's app identity. > [!DEV] What the route sends > - **EVM, Solana and your Cosmos chain.** Para's `data.wallets` entries of type `EVM` and `SOLANA`, and the external wallet a user logged in with. A Domino Cosmos wallet needs a chain ID, and Para's token names none, so the route vouches `COSMOS` wallets only on the chain you name, and only addresses with that chain's prefix. Other wallet types are left out, because Domino refuses the whole exchange when one account is invalid. > - **Embedded wallets first, at most 20.** Domino attaches up to 5 wallets per chain family and refuses the rest with `limit-reached`, and an exchange carries at most 20 accounts. Para's own wallets come before the external one. > - **Not `/sessions/verify`.** Para's verification-token endpoint confirms a session but returns no wallets, so the route reads them from the JWT. > > On a Domino-hosted Worker, call it from your `fetch` handler: `if (new URL(request.url).pathname.startsWith("/relay/")) return participant(env)(request)`. `participant` builds the handler on the first request and reuses it, so the provider's keys are fetched once, not on every request. ### 2. Send the JWT from the browser [#2-send-the-jwt-from-the-browser] Give Domino's browser client a `getToken` that returns a Para JWT, and pass the client to `RelayProvider`. Each `issueJwt()` call asks Para's API for a new token and extends the Para session, so keep the token until shortly before it expires; it lasts as long as your Para session length, 30 minutes by default. ```tsx import { createBrowserRelayClient } from "@domino-sdk/relay/browser" import { RelayProvider } from "@domino-sdk/relay/react" import type { ReactNode } from "react" // From @getpara/react-sdk or @getpara/web-sdk: the Para client your app created. declare const para: { isFullyLoggedIn(): Promise issueJwt(): Promise<{ token: string }> } let issued: { token: string; expiresAt: number } | undefined async function paraToken() { if (!(await para.isFullyLoggedIn())) return null if (!issued || issued.expiresAt < Date.now() + 60_000) { const { token } = await para.issueJwt() const { exp } = JSON.parse(atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))) issued = { token, expiresAt: exp * 1000 } } return issued.token } const relay = createBrowserRelayClient({ baseUrl: "/relay", getToken: paraToken }) export function Community({ children }: { children: ReactNode }) { return {children} } ``` When the user logs in or out with Para, clear `issued` on logout and call `useSession().refresh()` so the page reads the Domino session again, as [app login](/docs/build/app-login#3-connect-the-browser) explains. ### 3. Link the embedded wallet for payouts [#3-link-the-embedded-wallet-for-payouts] A leaderboard's [payout list](/docs/build/leaderboards#export-the-payout-list) leaves out vouched wallets, because the member never proved them to Domino. To pay prizes to the embedded EVM wallet, ask the member to sign Domino's message with it once. The signature proves the wallet, and it stays linked as if the member had linked it themselves. Para doesn't hand out its embedded wallet as an EIP-1193 provider, so `evmSigner` doesn't fit. It signs EVM messages through its viem account instead, so wrap that in a signer: ```tsx import type { WalletSigner } from "@domino-sdk/relay" import { useWallets } from "@domino-sdk/relay/react" // From @getpara/viem-v2-integration: createParaAccount(para), a viem account. declare const account: { address: `0x${string}` signMessage(input: { message: string }): Promise<`0x${string}`> } const paraSigner: WalletSigner = { chain: "eip155", name: "Para", address: async () => account.address, sign: async (message) => ({ signature: await account.signMessage({ message }) }), } export function useProveEmbeddedWallet() { const { link } = useWallets() return () => link(paraSigner) } ``` The message states that it authorizes no transaction or transfer. See [Bring your own wallet provider](/docs/build/wallets#bring-your-own-wallet-provider) for the other signers. ### 4. Check it [#4-check-it] * Log in with Para and check that the app shows the member's Domino quests. * In Console, open the member's page: **Wallets** lists the Para wallets, marked **Vouched by** your key's name. * Prove the embedded wallet and check that it is no longer marked **Vouched by**. * Log out of Para and check that the member's data disappears. > [!NOTE] Checked against Para's documentation > The route is tested against Para's documented JWT (your API key's ID as audience, the user ID as `sub`, wallets in `data.wallets`) with a throwaway key served as Para's JWKS. It has not yet been run against a live Para app. Para documents only `EVM` and `SOLANA` wallets in the token; whether Cosmos wallets appear as `COSMOS`, and in which address format, is not yet confirmed. ## Where next [#where-next] - [Use your app's login](/docs/build/app-login) — keys, refusals and how to test the login in a browser. - [Wallets](/docs/build/wallets) — wallet quests, limits and what operators see. # Build in Playground (/docs/build/playground) Playground is a coding agent inside Console for a [hosted app](/docs/concepts/hosting). Describe a change in plain words, such as "add a page that lists our rewards", and the agent edits your project. You watch the result in a live preview next to the chat, and every turn is saved as a checkpoint you can go back to. * **Members don't see your work yet.** Changes reach members only after you [stage](/docs/build/staging) them and a promotion takes that build live. * **No setup on your computer.** The agent works in a sandbox with a copy of your project's repository and runs the app's development preview for you. * **Where to find it.** Open **Developers → Build** with the **Test** environment selected. You need publish permission in **Test**. ```mermaid flowchart LR Chat["Chat with the agent"] --> Checkpoint["Checkpoint saved"] Checkpoint --> Preview["Preview updates"] Checkpoint --> Save["Save to main"] Save --> Stage["Stage"] Stage --> Staging["Staging app"] ``` ## Chat with the agent [#chat-with-the-agent] Open **Developers → Build** in **Test** to chat with the hosted agent beside your app's preview. Send a description of the change. Your message appears immediately, and the conversation follows the response as it grows. Scroll up to read earlier work; choose **Scroll to end** to follow again. Consecutive tool calls and reasoning appear in a collapsed summary, such as **Ran 3 commands and read 2 files**. Progress messages separate the groups. Expand a summary to see individual steps, then expand a tool call for its command, output, or file diff. Running work and failed calls are marked in the summary. When a turn finishes, the chat keeps your message, the final answer, and the checkpoint's **Stage** or **Restore** control visible. Expand **Worked for…** to read intermediate messages and work summaries. ### Answer questions and permission requests [#answer-questions-and-permission-requests] Questions appear above the message box. Select the requested options or enter your own answer, then choose **Next** for the following question or **Submit** after the last one. **Skip** dismisses the request and lets the agent continue without an answer. Permission requests show the action and affected resources. Choose **Allow once**, **Always allow** when offered, or **Reject**. Requests from a subagent appear here too. Beside the model picker, the message box shows the AI credit the last turn used and the AI balance left for this project, for example **$0.12 last turn · $8.40 left**. AI usage draws credit at Domino's rates. Use **Stop** to interrupt the current turn. If sending fails, the message returns to the composer so you can retry. Questions and permissions are loaded again when you reopen the playground. ## Work in the side panes [#work-in-the-side-panes] Next to **Chat**, Playground has six panes: | Pane | What it shows | | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | **Preview** | The app's development preview, with the [preview controls](#point-to-a-change-in-the-preview) | | **Map** | The project's campaign as a [map](#see-the-campaign-as-a-map): trackers, quests, caps and rewards, with their numbers | | **Simulate** | A pasted campaign, built and published to a throwaway project, with generated members run through it; see [Simulate a campaign](/docs/build/simulate) | | **Files** | The project's files in the sandbox | | **Changes** | What the playground changed compared with `main` | | **Terminal** | A terminal in the sandbox | Playground starts the development server for you. Its agent can inspect the server's status and logs, recover a stopped server, or restart it after configuration or dependency changes. These controls use the same server shown in **Preview**. Source edits update through the running server. If the agent reports that its preview controls are unavailable, reload Playground to boot the sandbox again. ## See the campaign as a map [#see-the-campaign-as-a-map] **Map** draws what the project runs in **Test**, read from its latest published deployment rather than from the source files. Playground publishes the project's quests to **Test** each time the agent's changes build, and the map follows within about ten seconds while the pane is open. | On the map | What it shows | | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | | **Sources** | Trackers, with their source, X search query and refresh interval, and **Members** or **Staff** for quests people submit or staff confirm | | **Quests** | How often each quest completes, the data it checks, staff review, when it is open, and the group it is limited to | | **Budgets and caps** | Each limit, such as "5 completions per member per day", shared by every quest that spends it | | **Rewards** | The point ledgers quests pay into, and badges, groups, Discord roles and other rewards | | **Leaderboards and referrals** | Which ledger each leaderboard ranks and its prizes, and what each referral program pays the inviter and the invitee | Each arrow says what crosses it: which records start a quest and who it pays, what a completion takes from a cap, and the points a quest, prize or referral pays. A dashed arrow is a prerequisite, such as another quest or a minimum balance. Archived quests, leaderboards and referral programs are left out. Notices about the deployment, such as a missing integration, appear above the map. When the latest change doesn't build, **Map** shows the error instead of a map that no longer matches the project. Ask the agent to fix it; the map returns once the quests build and publish again. The map is read-only: change the campaign by asking the agent. ## Keep threads apart [#keep-threads-apart] Playground keeps each conversation as a thread. Use the thread menu above the chat to start a **New thread**, switch between threads, rename one, or archive one you no longer need; archived threads stay under **Archived** and can be moved back. * **One copy of the project.** Your threads share it, so they see each other's changes. * **One thread works at a time.** Others wait their turn; see [queue follow-up messages](#queue-follow-up-messages). * **Restore rewinds everything after it.** Restoring a checkpoint rewinds that thread's conversation and the project's files, including changes other threads made after it. * **Nothing is lost when the sandbox sleeps.** Threads are saved after every turn and survive the sandbox sleeping or being replaced. ## When you step away [#when-you-step-away] After five minutes without interaction, an idle Playground tab pauses its live connections and preview so the hosted sandbox can sleep. Running agent turns and checkpoint saves finish first. Interacting with the preview counts as activity. Return to the tab or choose **Resume playground** to reconnect. Your chat and unsent prompt remain. The hosted sandbox can sleep after its additional 15-minute inactivity period. Local Docker containers continue until you stop the local stack. ## Save to main and stage [#save-to-main-and-stage] Playground works on its own branch of your repository, so its changes stay out of `main` until you save them. **Sync with main** above the chat asks the agent to move work between the two: | Action | What the agent does | | ------------------------- | ----------------------------------------------------------------------------------------------------------------- | | **Get latest from main** | Merges everything saved to `main` into this playground | | **Save to main** | Merges this playground into `main` so new work starts from it; it does not stage or publish anything | | **Stage** on a checkpoint | Brings the checkpoint up to date with `main`, checks it, pushes it, and [stages](/docs/build/staging) that commit | **Restore** on a checkpoint rewinds that thread's conversation and the project's files to it. ## Continue in your own agent [#continue-in-your-own-agent] **Open in your agent** shows the commands that hand the project to Codex or Claude Code on your computer. Your agent checks out the repository; when it saves to `main`, bring that work back into Playground with **Get latest from main**. Once the project has published quests or staging builds, the instructions pick up that work. The agent reads the project and its saved brief, summarizes what exists, and asks what to change, without running brand or campaign discovery again. ## Queue follow-up messages [#queue-follow-up-messages] While the agent works, press Enter or choose **Queue** to hold your next message. Queued messages appear above the message box and go out one at a time as each turn finishes. Remove a queued message if it no longer applies. A selected element or captured logs are attached when you queue the message. Only one thread runs at a time. While another thread works, the message box offers **Queue** instead of sending, and the message goes out once that thread's turn ends. Choosing **Stop**, restoring a checkpoint, or a failed send pauses the queue, because the queued messages may no longer apply. Choose **Send next** to resume. ## Send a request from another page [#send-a-request-from-another-page] Some Console pages offer **Send to Build** beside **Copy prompt**: the example requests on an empty **Quests**, **Rewards**, **Leaderboards**, **Referrals** or **Trackers** page, and the [X search preview](/docs/build/trackers#preview-an-x-search). It needs permission to build the project. 1. Choose **Send to Build**. **Developers → Build** opens in **Test** on a new thread, with the request in the message box under **Prepared from …**. 2. Review or edit the request. Nothing is sent until you send it, or queue it while another thread works. 3. Send it. The thread is named after the request. Choose **Discard** instead to empty the message box. A request that hasn't been sent is dropped when you reload the page; send it again from the page that wrote it. A project whose code Domino doesn't host has no playground, so Build shows the request with **Copy request** to paste into your own agent. ## Attach files [#attach-files] Attach files with the paperclip, by pasting a screenshot, or by dropping files on the message box. A message can carry up to five files of up to 5 MB each, and can contain only attachments. Remove a file from the message box before sending if you change your mind. The agent receives the path of each file in its sandbox, so it can use the file directly, for example by copying a logo into your app. | File | What the agent gets | | ------------------------------------ | ------------------------------------------------------------------- | | PNG, JPEG, GIF and WebP images | The path, and the image itself when the selected model reads images | | Text and JSON files | The path, and the full contents | | Anything else, such as PDFs and SVGs | The path only | Attachments are kept outside your repository and are not saved in checkpoints unless the agent copies them into the project. If an upload fails, nothing is sent and your message keeps its files. Queued messages keep their attachments. ## Point to a change in the preview [#point-to-a-change-in-the-preview] Choose **Select**, then click an element in the preview. The element appears above your message box. Describe the change and send your message; the agent receives its selector, visible text, and a compact markup summary. The selected click does not open links or submit forms. Press **Escape** to cancel selection, or remove the attachment before sending. Input values are excluded. | Preview control | What it does | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Back** and **Forward** | Move between pages you opened in the preview. | | Address | Shows the page the preview is on. Type a path such as `/rewards` and press Enter to open it, or Escape to cancel. Addresses outside your preview are not opened. | | **Reload** | Reloads the current page. | | **Desktop** | Fits the preview to the available pane. | | **Tablet · 768px** | Uses a 768-pixel viewport. | | **Mobile · 390px** | Uses a 390-pixel viewport. | | **Console** | Shows up to 100 recent browser messages and uncaught errors. | | **Add to chat** | Attaches up to 20 recent console entries to your next message. Review them before sending. | | **Clear** | Clears the captured console entries. | On narrow screens, **Forward** is hidden. Changing the device width keeps the app running. A fixed viewport wider than the pane scrolls horizontally. These controls change viewport width, not the browser's operating system or touch behavior. Element selection and logs need a connected development preview. If they are unavailable, wait for development to start and choose **Reload preview**. Staged previews support resizing but do not capture elements or logs. Captured logs stay in this browser until you attach and send them. Reloading the preview or navigating to another document clears them. They do not include server logs or the contents of nested frames. > [!DEV] > Element selection and logs work with existing hosted repositories without a package upgrade. A custom content security policy can block the bridge; allow the same-origin `/.domino/preview-tools.js` script in development if you need these tools. ## Upgrade an older project [#upgrade-an-older-project] The preview needs Domino CLI 1.0.0 or newer. Projects created from earlier starters build quests the API no longer accepts. Instead of starting the preview, Playground shows **Upgrade with AI**. The agent upgrades `@domino-sdk/relay` and `@domino-sdk/relay-cli` and applies the required migrations. When it finishes, choose **Start the preview**. Your dependencies change only when you choose the upgrade. See [When Vite is ready but the preview does not start](/docs/build/development#when-vite-is-ready-but-the-preview-does-not-start) for other startup failures. ## Where next [#where-next] - [Hosted apps](/docs/concepts/hosting) — how the repository, previews, and staging fit together. - [Stage and share](/docs/build/staging) — staging builds and who can open them. - [Preview from your own computer](/docs/build/development) — run the preview on your own device instead. # Use Domino with Privy (/docs/build/privy) If your app signs users in with [Privy](https://privy.io), they can use Domino's quests, points and leaderboards without a second login. Your server checks the user's Privy identity token and tells Domino who they are and which wallets they hold. Their embedded wallet and every wallet they linked in Privy join their Domino wallets, so wallet quests count them straight away. This is [app login](/docs/build/app-login) with Privy's token as the session. It needs a server route, such as a [Domino-hosted app's Worker](/docs/build/hosted-app) or your own backend. ```mermaid sequenceDiagram actor User participant Browser as Your app participant Server as Your server participant Domino User->>Browser: Log in with Privy Browser->>Server: Token on /relay Server->>Server: Verify it Server->>Domino: User ID, wallets Domino-->>Browser: Quests ``` ## Before you start [#before-you-start] * **An app login key that vouches for accounts.** An organization admin [creates the key](/docs/build/app-login#1-create-an-integration-key) for your app's address and [turns on **Allow vouching**](/docs/build/app-login#turn-it-on). Your server reads it as `APP_IDENTITY_KEY`. * **Identity tokens in Privy.** In the Privy Dashboard, open **User management → Authentication → Advanced** and turn on **Return user data in an identity token**. The wallets your server vouches for come from this token. * **Your Privy app ID and verification key.** Both are in the Privy Dashboard. Save them as `PRIVY_APP_ID` and `PRIVY_VERIFICATION_KEY` next to `APP_IDENTITY_KEY`. ## Connect Privy [#connect-privy] ### 1. Add the server route [#1-add-the-server-route] Mount this module on `/relay/*` in your server, with your app's address as `origin`. It accepts the identity token as an `Authorization: Bearer` header or as Privy's `privy-id-token` cookie. ```ts import { createParticipantBackend } from "@domino-sdk/relay/portal-proxy" import { errors, importSPKI, jwtVerify } from "jose" // Your app's Cosmos chain: Privy's token names no chain ID, and Domino needs one. const cosmos = { network: "cosmos:cosmoshub-4", prefix: "cosmos" } as const type LinkedAccount = { type: string address?: string chain_type?: string wallet_client_type?: string connector_type?: string } /** A wallet Domino can link, or null for a chain it doesn't take. */ function wallet(chainType: string | undefined, address: string) { if (chainType === "ethereum") { return { kind: "wallet", chain: "eip155", address } as const } if (chainType === "solana") { return { kind: "wallet", chain: "solana", address } as const } // Only your chain's addresses: another prefix is another Cosmos chain. if ( chainType === "cosmos" && address.slice(0, address.lastIndexOf("1")) === cosmos.prefix ) { return { kind: "wallet", chain: "cosmos", network: cosmos.network, address, } as const } return null } /** The wallets in an identity token's `linked_accounts`, embedded wallets first, at most 20. */ export function privyWallets(linkedAccounts: string) { const embedded = (account: LinkedAccount) => account.wallet_client_type === "privy" || account.connector_type === "embedded" return (JSON.parse(linkedAccounts) as LinkedAccount[]) .filter((account) => account.type === "wallet") .sort((a, b) => Number(embedded(b)) - Number(embedded(a))) .map((account) => wallet(account.chain_type, account.address ?? "")) .filter((account) => account !== null) .slice(0, 20) } /** Verifies the Privy identity token the browser sends, as a header or Privy's cookie. */ export function privyAuthenticate(config: { appId: string verificationKey: string }) { const key = importSPKI(config.verificationKey, "ES256") return async (request: Request) => { const token = request.headers.get("Authorization")?.match(/^Bearer (\S+)$/)?.[1] ?? request.headers .get("Cookie") ?.match(/(?:^|;\s*)privy-id-token=([^;]+)/)?.[1] if (!token) { return null } try { const { payload } = await jwtVerify<{ linked_accounts?: string }>( token, await key, { issuer: "privy.io", audience: config.appId, algorithms: ["ES256"] } ) return payload.sub ? { subject: payload.sub, accounts: privyWallets(payload.linked_accounts ?? "[]"), } : null } catch (error) { // A forged, expired or misaddressed token is a signed-out visitor. Anything else, such as // the provider's keys failing to load, is a fault. if ( [ errors.JWTClaimValidationFailed, errors.JWTExpired, errors.JWSSignatureVerificationFailed, errors.JWSInvalid, errors.JWTInvalid, errors.JWKSNoMatchingKey, errors.JOSEAlgNotAllowed, ].some((type) => error instanceof type) ) { return null } throw error } } } let backend: ReturnType | undefined /** The `/relay/*` handler, built once so the verifier keeps its keys between requests. */ export function participant(env: { APP_IDENTITY_KEY: string PRIVY_APP_ID: string PRIVY_VERIFICATION_KEY: string }) { backend ??= createParticipantBackend({ baseUrl: "https://relay.domino.run", origin: "https://app.example.com", integrationKey: env.APP_IDENTITY_KEY, authenticate: privyAuthenticate({ appId: env.PRIVY_APP_ID, verificationKey: env.PRIVY_VERIFICATION_KEY, }), }) return backend } ``` The route signs nobody in for a missing, expired or forged token, or one issued to another Privy app. Domino gets the Privy user ID (`did:privy:…`) as the member's app identity. > [!DEV] What the route sends > - **Wallets only.** Ethereum and Solana wallets, plus Cosmos wallets on the chain you name in `cosmos`. Privy's token names no Cosmos chain, so the route vouches only addresses with your chain's prefix. Other chain types, such as Bitcoin, are left out, because Domino refuses the whole exchange when one account is invalid. > - **Embedded wallet first, at most 20.** Domino attaches up to 5 wallets per chain family and refuses the rest with `limit-reached`, and an exchange carries at most 20 accounts. Sending the embedded wallet first keeps it from being the one refused. > - **Privy's own check.** `@privy-io/node` can verify the token instead of jose: `privy.users().get({ id_token })` returns the user, with `linked_accounts` already parsed. > > On a Domino-hosted Worker, call it from your `fetch` handler: `if (new URL(request.url).pathname.startsWith("/relay/")) return participant(env)(request)`. `participant` builds the handler on the first request and reuses it, so the verification key is imported once, not on every request. ### 2. Send the token from the browser [#2-send-the-token-from-the-browser] Give Domino's browser client a `getToken` that returns Privy's identity token, and pass the client to `RelayProvider`. Domino's client sends it with every request to `/relay`. ```tsx import { createBrowserRelayClient } from "@domino-sdk/relay/browser" import { RelayProvider } from "@domino-sdk/relay/react" import type { ReactNode } from "react" // From @privy-io/react-auth: the identity token, or null when nobody is logged in. declare function getIdentityToken(): Promise const relay = createBrowserRelayClient({ baseUrl: "/relay", getToken: getIdentityToken, }) export function Community({ children }: { children: ReactNode }) { return {children} } ``` When Privy's login or logout finishes, read the Domino session again, as [app login](/docs/build/app-login#3-connect-the-browser) explains: ```tsx import { useSession } from "@domino-sdk/relay/react" // From @privy-io/react-auth. declare function useLogin(callbacks: { onComplete: () => void }): { login: () => void } declare function useLogout(callbacks: { onSuccess: () => void }): { logout: () => Promise } export function usePrivyLogin() { const { refresh } = useSession() const { login } = useLogin({ onComplete: () => void refresh() }) const { logout } = useLogout({ onSuccess: () => void refresh() }) return { login, logout } } ``` ### 3. Link the embedded wallet for payouts [#3-link-the-embedded-wallet-for-payouts] A leaderboard's [payout list](/docs/build/leaderboards#export-the-payout-list) leaves out vouched wallets, because the member never proved them to Domino. To pay prizes to the embedded wallet, ask the member to sign Domino's message with it once. The signature proves the wallet, and it stays linked as if the member had linked it themselves. ```tsx import { evmSigner, type Eip1193Provider } from "@domino-sdk/relay/browser" import { useWallets } from "@domino-sdk/relay/react" // From @privy-io/react-auth's useWallets(): the wallet whose walletClientType is "privy". declare const embeddedWallet: { getEthereumProvider(): Promise } export function useProveEmbeddedWallet() { const { link } = useWallets() return async () => link(evmSigner(await embeddedWallet.getEthereumProvider(), { name: "Privy" })) } ``` Both libraries export a `useWallets`; import Privy's under another name. The message states that it authorizes no transaction or transfer. See [Bring your own wallet provider](/docs/build/wallets#bring-your-own-wallet-provider) for Solana wallets. ### 4. Check it [#4-check-it] * Log in with Privy and check that the app shows the member's Domino quests. * In Console, open the member's page: **Wallets** lists the Privy wallets, marked **Vouched by** your key's name. * Prove the embedded wallet and check that it is no longer marked **Vouched by**. * Log out of Privy and check that the member's data disappears. > [!NOTE] Checked against Privy's documentation > The route is tested against Privy's documented identity token (ES256, issuer `privy.io`, your app ID as audience, `linked_accounts` as a string) with a throwaway key. It has not yet been run against a live Privy app, and the embedded-wallet test (`wallet_client_type` `privy` or `connector_type` `embedded`) follows Privy's user object documentation. ## Where next [#where-next] - [Use your app's login](/docs/build/app-login) — keys, refusals and how to test the login in a browser. - [Wallets](/docs/build/wallets) — wallet quests, limits and what operators see. # Show member profiles (/docs/build/profiles) A profile is the name and picture other members see for someone in your project, for example on a leaderboard. Profiles are private until you turn them on. Members can always read and edit their own. * **What members see.** Names and pictures on leaderboard rows, and on any profile page your app shows. Each member can pick a name, upload a picture, or hide their profile. * **What you do in Console.** Choose who sees profiles and where default names and pictures come from, under **Settings → Profiles**. Reset a name, remove a picture or lock a profile from the member's page. * **What your developer does.** Adds the profile editor and profile pages to your app. Domino provides the profile and a few reads keyed by its public ID. Your app composes them into whatever page it needs. ```mermaid flowchart LR Member["Member edits name and picture"] --> Profile Settings["Operator's profile settings"] --> Profile Profile --> Boards["Leaderboard rows"] Profile --> Reads["Reads keyed by the profile ID"] Reads --> Page["Your app's profile page"] ``` ## Turn profiles on [#turn-profiles-on] ### 1. Open the settings [#1-open-the-settings] In Console, open **Settings → Profiles** in test. You need publish permission. > [!NOTE] Live settings are read-only > Profile settings are a definition. Live refuses changes to them from every credential, Console included, with `promotion-required`, so change them in test and [promote](/docs/concepts/promotion) them to live. See [test and live](/docs/concepts/environments). ### 2. Choose who sees profiles [#2-choose-who-sees-profiles] | Option | Leaderboard rows | Reads keyed by a profile | | -------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | **Private** (default) | Board-specific aliases, as before | Closed to everyone but the member | | **Signed-in participants** | Names and pictures for signed-in members; aliases for signed-out visitors | Open to signed-in members | | **Anyone** | Names and pictures for everyone | Open to signed-out visitors too, where the read's own resource allows it | ### 3. Choose default names and pictures [#3-choose-default-names-and-pictures] Under **Default names and pictures**, check the accounts that fill in a member's name and picture until they choose their own: **X**, **Discord**, or both. Domino uses the first checked account the member connected that has a name or picture. With both checked, use the arrow buttons to put one first. With none checked, the default, members start with a generated name such as `Participant 3fK9x2Qa` and a generated picture. Only X and Discord can be defaults, because their profiles are public personas. Google and Apple accounts are usually a legal name and a personal photo, so members can only choose them in their own editor. ### 4. Save with a reason [#4-save-with-a-reason] Enter a reason and select **Save**. The audit log records it. If someone saved in between, reload and try again. > [!DEV] Configure from code or the CLI > From a trusted server, `management.profiles.configure({ visibility, defaults, expectedRevision, reason })` takes the same choice, such as `defaults: ["discord", "x"]`, and needs publish permission. From the CLI, `domino profiles settings` shows the current settings and revision, and `domino profiles configure settings.json` takes a file with those four fields. > [!NOTE] Existing accounts > Domino stores the names and pictures of X, Discord and Google accounts when a member signs in or connects the account. Members who connected before this release show them after their next sign-in or connection. ## Moderate profiles [#moderate-profiles] Reviewers moderate in Console: open the member, then use **Reset name**, **Remove picture**, or **Lock profile**. Each needs a reason and appears in the audit log. Find a member by their profile name or profile ID in **Members** search. A removed picture is replaced by the generated one; a locked profile keeps its name and picture until you unlock it. > [!DEV] > From a trusted server, `management.profiles.moderate(member, { resetName, removeAvatar, lock, reason })` needs review permission, as does `domino profiles moderate MEMBER --reset-name --remove-avatar --lock --reason "…"` (pass any of the three actions; `--unlock` unlocks). ## Let members edit their profile [#let-members-edit-their-profile] The signed-in member reads their profile with `relay.profiles.mine()` and changes it with `relay.profiles.update()`. Omitted fields stay as they are; `null` returns to the default. ```ts import { avatarSrc } from "@domino-sdk/relay/avatar"; import { createBrowserRelayClient } from "@domino-sdk/relay/browser"; const relay = createBrowserRelayClient({ baseUrl: "/relay" }); const profile = await relay.profiles.mine(); console.log(profile.name, avatarSrc(profile.avatar), profile.visibility); await relay.profiles.update({ displayName: "Ada" }); await relay.profiles.update({ avatar: { source: "account", provider: "x" } }); await relay.profiles.update({ displayName: null, avatar: null }); await relay.profiles.update({ hidden: true }); ``` | Field | Accepts | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `displayName` | 1 to 40 visible characters after trimming; whitespace collapses. Control, zero-width and bidirectional formatting characters are refused. | | `avatar` | `{ source: "account", provider: "x" \| "discord" \| "google" }` for a connected account's picture, `{ source: "generated" }`, or `null`. | | `hidden` | `true` shows an alias to everyone else, whatever the project's settings. | `profile.options` lists the member's connected X, Discord and Google accounts, each with its `name`, `givenName` and `imageUrl`. Choosing an account the member has not connected answers 422. ### Greet the member by name [#greet-the-member-by-name] A member who signed in with Google can be greeted by their first name without asking for it. Read it from their own profile: `givenName` is the first name Google sent, and `name` the full name. Both are null when the account has none; X and Discord have no `givenName`. ```ts import { createBrowserRelayClient } from "@domino-sdk/relay/browser"; const relay = createBrowserRelayClient({ baseUrl: "/relay" }); const profile = await relay.profiles.mine(); const google = profile.options.find((option) => option.provider === "google"); const firstName = profile.displayName ?? google?.givenName ?? null; console.log(firstName ? `Hi ${firstName}!` : "Hi there!"); ``` Only the member's own session reads these. Profile pages, leaderboard rows and other members never show a Google name: Google can't be a default, so it appears only if the member types it as their display name. ### Upload a picture [#upload-a-picture] ```ts import { avatarSrc } from "@domino-sdk/relay/avatar"; import { createBrowserRelayClient } from "@domino-sdk/relay/browser"; const relay = createBrowserRelayClient({ baseUrl: "/relay" }); const input = document.querySelector("#avatar")!; const file = input.files?.[0]; if (file) { const updated = await relay.profiles.uploadAvatar(file); console.log(avatarSrc(updated.avatar)); } ``` * **Formats and limits.** JPEG, PNG or WebP, at most 5 MB and 25 megapixels. Larger files answer 413; a file whose content does not match its type, or that cannot be decoded, answers 400. * **What is stored.** The first frame, rotated upright, center-cropped to 512×512 and stored as WebP without its metadata, so camera and location data are removed. * **Retries.** The client never resends an avatar upload on its own. Pass the same `{ uploadId }` and file to retry one whose outcome is unknown without creating another picture, before any other upload: replaying an upload a newer one replaced makes the older picture current again. The client makes an ID up when you omit it. * **Replacing.** A new upload, choosing another avatar, or an operator removing it deletes the previous upload. Browsers may keep showing it for up to an hour. Upload answers 503 "Avatar uploads are not configured" where Domino has no image storage, including the local development stack. > [!IMPORTANT] Locked profiles > An operator can lock a profile. Changing the name or picture then answers 403 until they unlock it; the member can still hide the profile. In React, `useMyProfile()` returns the profile in `data` with `update`, `uploadAvatar`, `isPending` and `actionError`. It reads again on window focus and after each change, and binds each change to the session it read the profile with: ```tsx import { avatarSrc } from "@domino-sdk/relay/avatar"; import { useMyProfile } from "@domino-sdk/relay/react"; export function ProfileSettings() { const { data: profile, isPending, actionError, update, uploadAvatar } = useMyProfile(); if (!profile) { return null; } return (
{ event.preventDefault(); const name = new FormData(event.currentTarget).get("name"); void update({ displayName: typeof name === "string" && name ? name : null }); }} > { const file = event.target.files?.[0]; if (file) { void uploadAvatar(file); } }} /> {actionError &&

{actionError.message}

}
); } ``` ## Show avatars [#show-avatars] Every member has a picture, even before they choose one. Every avatar has a `source` and a `seed`. `avatarSrc(avatar)` returns the picture's URL, or draws the generated avatar from its seed. Pictures from X, Discord and Google load from their sites and can disappear, so fall back to `defaultAvatar(avatar.seed)` when an image fails to load: ```tsx import type { ProfileAvatar } from "@domino-sdk/relay"; import { avatarSrc, defaultAvatar } from "@domino-sdk/relay/avatar"; export function Avatar({ avatar }: { avatar: ProfileAvatar }) { return ( { event.currentTarget.src = defaultAvatar(avatar.seed); }} /> ); } ``` The seed is the same `avatarSeed` that `relay.me.get()` returns. Domino derives it from the member with a secret per project, so it stays the same when the member links wallets or accounts, differs between projects, and reveals nothing about the member. ## Leaderboard rows [#leaderboard-rows] Once profiles are on, leaderboards show members' names and pictures to the viewers your settings allow. Each row has a `profile` where the viewer can see it, and `null` otherwise. When present, `row.name` is the profile's name, so apps that render `row.name` show names without changes. Keep `row.participant` as the row key. See [leaderboards](/docs/build/leaderboards). ## Compose a profile page [#compose-a-profile-page] A profile page is yours to design. Key it by the profile's public ID (`profile.id` from a leaderboard row or `mine()`), such as `/p/:id`, and combine the reads you need. Each read checks the profile's visibility and then its own resource's access: | Read | Returns | Also needs | | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | | `relay.profiles.get(id)` | The profile's `id`, `name` and `avatar`. | Nothing else. | | `relay.leaderboards.placement(board, id)` | `rank`, `score` and the ranked `total` on one board, calculated now. `rank` and `score` are `null` while the member is unranked there, such as when excluded. | The board's access: a board without public access answers 401 to signed-out visitors. | | `relay.profiles.completions(id, { limit, cursor })` | Completions grouped by quest, most recent first, with `completions` and `lastCompletedAt` (null for completions recorded before dates were). Pass `nextCursor` as `cursor` for the next page; `limit` is 1 to 100, default 20. | Only quests that opt in with `profile: { visible: true }`. | | `relay.profiles.referral(id, program)` | `invited` members, `qualified` invitations and `milestones` in one referral program. Never codes or accounts. | A signed-in member: signed-out visitors get 401. | ```ts import { createBrowserRelayClient } from "@domino-sdk/relay/browser"; const relay = createBrowserRelayClient({ baseUrl: "/relay" }); const id = "profile_3fK9x2Qa7Lm1Vb8Nc4Rt6y"; const profile = await relay.profiles.get(id); const placement = await relay.leaderboards.placement("season-1", id); console.log(profile.name, placement.rank ?? "Unranked", "of", placement.total); const page = await relay.profiles.completions(id, { limit: 10 }); for (const quest of page.items) { console.log(quest.title, quest.completions, quest.lastCompletedAt); } const referral = await relay.profiles.referral(id, "invite-friends"); console.log(referral.qualified, "of", referral.invited, "qualified"); ``` A missing profile, a hidden one, and one the viewer cannot see all answer the same 404 "Profile not found", on every read. A board or program that does not exist answers 404 too. ### Show completions on profiles [#show-completions-on-profiles] Quest completions stay off profiles unless the quest opts in, because a completion can reveal what a member holds or did. Set `profile` in the quest definition and publish: ```ts import { defineQuest, manual, once, settings } from "@domino-sdk/relay/authoring"; export default defineQuest({ key: "join-the-community-call", title: "Join the community call", settings: settings.object({}), trigger: manual(), completion: once(), rewards: [], profile: { visible: true }, }); ``` The quest's active release decides: publishing it with `profile: { visible: false }`, or without `profile`, removes its completions from profiles, including earlier ones. Archiving the quest removes them too, and unarchiving brings them back. The flag is part of the quest's code; Console does not change it. It also applies to a quest with `visibility: "hidden"`: its completions and title appear on profiles although members' quest lists leave it out. Leaderboard prize completions are operator accounting records, not quest activity. They stay in the leaderboard settlement review and do not appear in a member's profile completions or quest progress. ### In React [#in-react] Each read has a hook that returns `{ status, data, error, refresh }`. It reads again for each member who signs in or out, after the member changes their profile, and when a new completion appears in the quest experience. A profile that is missing, hidden or not visible to this viewer is `status: "error"` with `error.status` 404, the same for all three. | Hook | `data` | | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | `useProfile(id, { intervalMs })` | The profile's name and avatar. Reads again every 60 seconds by default while the page is visible. | | `useLeaderboardPlacement(board, id, { intervalMs })` | The rank and score. Reads again every 30 seconds by default. | | `useProfileReferral(id, program, { intervalMs })` | The invited and qualified counts and milestones. Needs a signed-in member: `signed-out` otherwise. | | `useProfileCompletions(id, { limit })` | `items` and `nextCursor`. `loadMore()` appends the next page, with `loadingMore` and `loadMoreError`. | ```tsx import { avatarSrc } from "@domino-sdk/relay/avatar"; import { useProfile, useProfileCompletions } from "@domino-sdk/relay/react"; export function ProfilePage({ id }: { id: string }) { const profile = useProfile(id); const completions = useProfileCompletions(id); if (profile.status === "error" && profile.error.status === 404) { return

This profile is not available.

; } if (profile.status !== "ready") { return null; } return (

{profile.data.name}

{completions.status === "ready" && (
    {completions.data.items.map((quest) => (
  • {quest.title} × {quest.completions}
  • ))}
)} {completions.status === "ready" && completions.data.nextCursor !== null && ( )}
); } ``` Profiles and leaderboard rows never contain emails, wallet addresses, member IDs or account subjects. ## Routes and proxies [#routes-and-proxies] This section is for developers who run their own proxy or call the participant API directly. | Route | Method | Session | | -------------------------------------- | ------ | ------------------------------------------------------ | | `/v1/me/profile` | GET | Member | | `/v1/me/profile` | POST | Member, with the browser CSRF header | | `/v1/me/profile/avatar` | POST | Member, with the browser CSRF header | | `/v1/profiles/:id` | GET | Optional, as the settings allow | | `/v1/profiles/:id/completions` | GET | Optional, as the settings allow | | `/v1/profiles/:id/referrals/:program` | GET | Member | | `/v1/leaderboards/:board/profiles/:id` | GET | Optional, as the settings and the board's access allow | | `/avatars/:file` | GET | None: uploaded pictures, cacheable for an hour | `proxyParticipant` and existing-app bridges admit these routes in the `@domino-sdk/relay` release that adds each client method. An app whose proxy runs an older SDK gets 404 for them until it upgrades. Through an existing-app bridge (`createParticipantBackend`), every request needs the application's own sign-in, so profile reads there are for signed-in visitors only. # Publish changes and go live (/docs/build/publishing) Every change to your quests, rewards and other definitions lands in **Test** first. When test looks right, someone prepares a promotion, and a person approves it in Console. Approval makes live match test in one step. Live never changes any other way, so nothing reaches live members without a person's approval. * **In Console.** Choose **Promote to live**, then approve or reject the promotion under **Live → Deployments**, where you can also roll back to an earlier one. Quests and other resources can be archived in Console too. * **Your website rides along.** If Domino hosts your app, the promotion also takes the build serving [staging](/docs/build/staging) live. * **Code publishes to test.** Your developer publishes quests written in code to test with `domino deploy`. Quests made in Console are published through Quest Studio. Hosting your website and publishing Domino behavior start as separate tracks. `domino stage` builds a pushed commit into a staging app on test data. `domino deploy` publishes quests and other resources to test. A promotion joins the tracks: it takes test's definitions and the build serving staging to live together. ```mermaid flowchart LR subgraph Hosting["Website hosting (test data)"] Dev["domino dev preview"] --> Push["git push"] --> Stage["domino stage"] end subgraph Behavior["Domino behavior"] Preview["domino deploy --preview"] --> Test["Publish to test"] Test --> Promote["domino promote"] --> Approval["Approve in Console"] end Stage --> Promote Approval --> LiveApp["Live app and live definitions"] ``` | Command | What it does | Data | After you stop working | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------ | --------------------------------------------------------------------------- | | [`domino dev`](/docs/build/development) | Serves the app on your device through an HTTPS preview URL and synchronizes authored behavior. | Test | Goes offline when development stops. | | `domino stage` | Builds the exact pushed commit into the staging app. | Test | Stays online. | | `domino deploy --preview` | Validates the deployment against test. Publishes nothing. | Test | — | | `domino deploy --environment test` | Publishes behavior to test. | Test | — | | `domino deploy rollback --environment test` | Publishes the previous published test deployment again, undoing the latest one. | Test | — | | `domino promote` | Prepares a promotion that makes live match test and carries the build serving staging, for Console approval. | Test to live | Remains pending until approved, rejected, or replaced by a newer promotion. | | `domino deploy --environment live` | Refused with 422 `promotion-required`. Code reaches live through a promotion. | Live | — | ## Publish behavior to test [#publish-behavior-to-test] Your developer registers quest definitions, reusable types, collection slots, and other authored resources in `relay.json` and publishes them together. For ongoing content rewards, add `engagement: [{ entry: "engagement.ts" }]`. The engagement rule can reference a content quest and points balance in the same deployment. See [daily engagement rewards](/docs/build/trackers#settle-engagement-points-daily). The project timezone decides where days, weeks and months begin for budgets, streaks and calendar leaderboards. See the [project calendar](/docs/concepts/calendar). > [!DEV] Timezone and limits > Set the project timezone with `"calendar": { "timeZone": "Europe/Budapest" }`. A deployment schedules a change only when this value differs from the last one deployed, so a timezone chosen later in Console stays until you edit relay.json; the preview's `calendar` shows the change it schedules, or `null`. > > One deployment holds up to 1,000 quests and 200 quest types; a release batch (`POST /releases/batch`) holds up to 1,000 releases, and a Console draft up to 1,000 quests and 1,000 collections. Engagement rules, trackers, point ledgers, referral programs and leaderboards stay at 50 each. Large deployments rely on the [shared SDK runtime](/docs/concepts/quests#shared-sdk-runtime): without it every quest carries its own copy of the SDK, and a deployment reaches the 64 MB request limit somewhere between about 180 and 400 quests, depending on what each imports. ### 1. Validate the remote deployment [#1-validate-the-remote-deployment] ```sh pnpm exec domino deploy --preview --environment test --json ``` > [!WARNING] Console values win > Console custom values take precedence over source defaults. A removed or incompatible custom setting can reject the entire batch. Resolve it in Console or preserve an intentional rename with `settingRenames`. The preview says what publishing does to each quest, in `questChanges`, by key: | Change | Means | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `created` | The quest is new. | | `updated` | A new release replaces the quest's current one; `replaces` names that release and when it was created. | | `unchanged` | The current release already has the same code, SDK runtime, provider, settings and archive state. Publishing keeps it: no new release, and its ID stays the same. | Check the `updated` quests before you publish. A quest you did not mean to change shows up here, for example when your checkout holds older code for a quest someone else deployed since. Unchanged compares what would be published, not your source files: a rebuild that produces the same output is unchanged. Missing project configuration does not reject the deployment. When a quest needs an integration the project hasn't connected, such as Discord, the preview and the published result list it in `warnings`, and the CLI prints it: ```text Warning: 2 quests need Discord, but no Discord server is connected to this project and environment. Publication continues; until Discord is connected in Console Integrations, attempts fail with integration-required. Connecting it admits the failed attempts of automatic quests without a tracker again. Quests: join-server, earn-role. ``` Each warning has a `code`, the affected `quests` by key, a `message`, and, for an integration, the `integration`. Treat an unknown code as a notice its message explains. A broken quest definition still rejects the deployment. Archived quests are not listed. | Code | Means | What to do | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `integration-required` | The quests need an integration the project hasn't connected. | Connect it before members attempt those quests; see [recover a configuration failure](/docs/build/integrations#recover-a-configuration-failure). | | `x-post-rewards-ungated` | The quests reward X posts from anyone who posts: an X tracker trigger that pays the post's author, or a content quest on an X tracker, with declared rewards or an engagement rule, and no `inGroup` prerequisite. X does not allow apps that reward users for posting. | Add `inGroup(group)` with a group of creators you approve, and manage its members in Console **Groups**. See [reward X posts only for approved creators](/docs/build/trackers). | | `quests-not-in-bundle` | The quests are published in test from code but this deployment leaves them out, usually because their files were deleted, such as a starter's sample quests. They stay as published. Quests created in Console and `--only` deployments are not listed. | [Archive](#archive-a-quest) each quest you no longer want. | ### 2. Publish to test [#2-publish-to-test] ```sh pnpm exec domino deploy --environment test --json ``` In a terminal, `domino deploy` validates the server preview, then shows the organization, project, environment, API URL, deployment ID, resource counts, and each created or updated quest by key (`station-race: updated`), with a count of the unchanged ones, before asking to publish. Press Enter to cancel, or type `yes` to publish. Cancellation leaves the validated preview unpublished. Engagement entries also show whether each rule is new, unchanged, or a formula change, with the retained accounting summary for changes. Formula changes preserve past earnings and budgets. A stale engagement checkpoint rejects the entire deployment; create a fresh preview. Recover a lost publication response with `domino deploy --resume DEPLOYMENT_ID`. > [!DEV] When the publication response is lost > The CLI sends the same publication again, at most three times in all within 30 seconds; Domino publishes a preview once and answers a repeat with that result. If no answer arrives, the command exits 3 with the error code `OUTCOME_UNCERTAIN` and prints the `domino deploy --resume ` command that finishes it; with `--json`, the error's `operation.resume` holds its arguments, including the scope flags a resume requires. Resuming publishes that preview once or returns its committed result. Starting another deployment can create another immutable release. See [Resume an uncertain change](/docs/reference/automation#resume-an-uncertain-change). > > Losing the answer while the CLI prepares the preview publishes nothing, so there is nothing to resume: the command exits 1 with `TIMEOUT` or `NETWORK_ERROR`, and running `domino deploy` again is safe. ### When the CLI asks for confirmation [#when-the-cli-asks-for-confirmation] | Invocation | Confirmation | | --------------------------------------------------------------- | ------------------------------------------ | | `domino deploy` in a terminal | Asks before publishing. | | `domino deploy --resume ` | Also asks before publication. | | `--preview` or `--dry-run` | Does not ask, because they do not publish. | | `--yes` | Skips the terminal confirmation. | | `--json`, `--no-interactive`, CI, or redirected input or stderr | Preserves noninteractive execution. | With `--promote`, the confirmation prepares a promotion for review. It does not publish. `--yes` skips only that terminal confirmation; a person still approves the promotion in Console. ### Publish only some quests [#publish-only-some-quests] When several people or agents work on one project in parallel checkouts, each full deployment republishes every quest from its own checkout and can overwrite a quest someone else just changed. Publish only the quests you worked on, by key: ```sh pnpm exec domino deploy --only daily-check-in --only station-race --environment test ``` Repeat `--only` once per quest. Every other quest keeps its current release, and every other resource in `relay.json` (quest types, collections, point ledgers, engagement rules, leaderboards, the calendar and the rest) stays as published. The exception is `supportedInteractions`: the deployment sets it from your `relay.json`, so a named quest can use an interaction you just added. Keep your checkout's list current, or an older list replaces a newer one. Rolling back a partial deployment republishes the full input of the deployment before it, so every quest it holds, including the ones the partial deployment changed, goes back to its version there; quests only the partial deployment added stay. In a terminal, the confirmation says it is a partial deployment and lists the quests. > [!DEV] What a partial deployment holds > The bundle holds the named quests, the SDK runtime they import, the reference images whose key their code names as a string literal, and `relay.json`'s `supportedInteractions`. Domino checks the named quests against what test already has, so deploy the whole project when they need a resource that isn't published yet. `--only` matches the `key` a quest file passes to `defineQuest`; a key computed in code can't be selected. An unknown key fails with `INVALID_ARGUMENT` (exit 2), naming the discovered keys, before anything is sent. `--only` works with `--preview` and `--yes`, and is refused with `--resume`, `--promote`, `--dry-run` and `--out`, which act on a whole stored deployment or bundle. ### Roll back a test deployment [#roll-back-a-test-deployment] To undo the latest deployment to test, publish the one before it again. * **In Console.** In test, open **Deployments**. **Code deployments** lists what `domino deploy` published, newest first, with the one test runs marked **Current**. Choose **Roll back to** with the previous deployment's time, check what the confirmation says changes, then choose **Roll back**. The button is unavailable, with the reason beside it, when nothing was published before the current deployment. People without publish permission don't see it. * **From the CLI.** Run `pnpm exec domino deploy rollback --environment test`. It names both deployments and asks before publishing, like `domino deploy`. The rollback publishes at once, as a new deployment, and appears in the list as **Rollback to** that time. Only the previous published deployment can be rolled back to, so rolling back again undoes the rollback. > [!WARNING] What a rollback leaves alone > Only what the earlier deployment contains goes back. Quests and other definitions that only the later deployment added stay; [archive them](#archive-a-quest) if they should go. The app build serving [staging](/docs/build/staging) does not change either: stage the earlier commit again for that. A staged build publishes its own definitions, so once a hosted app build has published to test since the earlier deployment, the rollback is refused with 409 `conflict`; stage the earlier version of the app instead. This rolls back test only. To take the rollback live, [promote](#publish-behavior-to-live) afterwards; to undo a change in live directly, [roll back a promotion](#roll-back-a-promotion). > [!DEV] Roll back from code or a script > `domino deploy rollback [DEPLOYMENT]` rolls back to the previous published deployment by default; the API refuses any other ID with `validation-failed`. `--yes` skips the confirmation. The CLI saves the change before sending it, so after an uncertain response (exit 3) run the error's `operations resume` command, which finishes it once. With `--json` the result has the same shape as `domino deploy`'s: `deployment` is the new deployment's ID and `status` is `published`, with `rolledBackTo` naming the earlier one. In live it fails with `ENVIRONMENT_UNSUPPORTED` and `apiCode` `promotion-required`. > > `management.deployments.rollback(DEPLOYMENT_ID)` with a test client does the same, with publish permission; its record has `source: { kind: "rollback", deployment }`. `management.deployments.history()` names the target as the second entry of `published`. ## Stage a hosted app [#stage-a-hosted-app] `domino stage` builds a pushed commit into the project's persistent staging app on test data. It is separate from publishing behavior: see [Stage and share](/docs/build/staging). To put the staged build live, promote; set live's [app secrets](/docs/build/hosted-app#set-app-secrets) first. ## Publish behavior to live [#publish-behavior-to-live] Live changes only when a person approves it. The usual path is a [promotion](/docs/concepts/promotion): change and try definitions in test, then promote the whole environment. * **Who prepares.** Anyone with publish permission in test, from Console, the CLI or the management client. * **Who approves.** Only a person signed in to Console with publish permission can approve or reject it. API keys, service keys, hosted-app keys and CLI sessions cannot approve. Live refuses a direct change to a quest, its settings, a point ledger, a badge, the calendar or another definition with 422 `promotion-required`, from Console as well as from API keys. It refuses a direct code deployment the same way. See [test and live](/docs/concepts/environments). ### 1. Prepare a promotion [#1-prepare-a-promotion] In Console, choose **Promote to live** in the project header beside the environment switch, which shows while you're in test, or on the notice that every definition editor shows in live. Console prepares the promotion from test and opens it in **Live → Deployments**, ready to approve. If a promotion is already awaiting approval, Console asks first: **Open it** shows the waiting one, and **Replace it** prepares a new one, which supersedes it. People without publish permission don't see the action. When live already matches test, Console says **Live already matches test** and saves nothing. Your developer can prepare the same promotion from the CLI: ```sh pnpm exec domino promote --environment test --json ``` > [!DEV] Prepare from code > From code, call `management.promotions.prepare()` with a client for the test environment. The result is a pending promotion: > > | Field | What to check | > | --- | --- | > | `changes` | Each definition live would change, by `kind` and authoring `key`, with `added`, `changed`, `archived` or `unarchived`. | > | `warnings` | `binding-missing`: an integration, webhook endpoint, sign-in method or app secret test uses and live lacks. Set it up in live; a promotion never copies it. `live-only`: a definition only live has, left unchanged. `group-created`: a group, by `key`, that approval creates in live with no members. | > | `build` | The hosted app build serving staging, which the promotion carries, or `null`. | > > When live already matches test, preparing fails with `promotion-empty` and nothing is saved. Preparing again replaces any older pending promotion, which becomes `superseded`. > > The CLI saves the request before sending it. If the response is lost, run the `operations resume` command from the error; the same operation ID answers with the same promotion. ### 2. Approve it in Console [#2-approve-it-in-console] Select the project and **Live**, then open **Deployments** under Developers. **Awaiting approval** shows the pending promotion: * **Changes in live**, grouped as **Added**, **Changed**, **Unarchived** and **Archived**, each with its kind, title and authoring key. * **Groups approval creates**: groups the definitions name and live lacks, created empty. * **Left as they are**: the other warnings. A missing binding has a button that opens where live sets it up: **Open Integrations**, **Open Webhooks**, **Open Sign-in** or **Open App secrets**. * **App build**: the hosted app build it carries, or **None**. Choose **Approve and apply**, check the confirmation that counts the definition changes and names the build it takes live, and choose **Approve and apply** again to apply every change in one transaction; or **Reject**, enter a **Rejection reason**, and choose **Reject promotion**. Without publish permission, the promotion shows without these actions. When the promotion carries a build live does not serve yet, Domino then deploys it as the live app at `.domino.page`, typically within a couple of minutes; the previous live build serves until it switches. If that deployment fails, the previous build keeps serving, and approving the next promotion retries it. See [the hosted app](/docs/concepts/promotion#the-hosted-app). If test or live changed after the promotion was prepared, approval fails with `promotion-changed` and live is unchanged. Console explains this and offers **Prepare again**, which prepares a new promotion to review. > [!IMPORTANT] Groups are never copied > When a quest, offer, leaderboard or referral program names a group live lacks, such as one a quest rewards with [group membership](/docs/concepts/groups#add-members-with-a-quest-reward), approval creates it in live with no members. Members are never copied from test. A group archived in live is never unarchived: if a quest rewards membership in it, or a leaderboard binds it as its exclusion group, approval refuses with `409`, naming the quest and the group, until you unarchive the group in live. **Promotion history**, below, lists applied, rejected and superseded promotions, newest first; the one live matches is marked **Live now**. `domino promotions list` and `domino promotions get PROMOTION_ID`, or `management.promotions.history()` and `get(id)`, read promotions from either environment. ### Roll back a promotion [#roll-back-a-promotion] In **Live → Deployments**, choose **Prepare rollback** on an earlier applied promotion in **Promotion history**. The one marked **Live now** has none, because live already matches it. The rollback appears under **Awaiting approval** as **Rollback to the promotion approved** with that promotion's approval time; review and approve it like any other. It adds to the history. Changes made in test since then are carried again by the next promotion from test. A rollback is a new promotion of the definitions and build that the earlier promotion made live. Every live definition that promotion did not carry is archived in it, and pickup policies it did not have go back to untracked. > [!DEV] > From code, `management.promotions.rollback(PROMOTION_ID)`, with a client for live, prepares the same rollback. ### Code deployments reach live through a promotion [#code-deployments-reach-live-through-a-promotion] Live refuses a code bundle sent to it directly. `domino deploy --environment live`, with or without `--preview`, and `management.deployments.preview` with a client for live fail with 422 before the bundle is checked, whatever the credential: ```json { "error": "Code changes in test and reaches live through a promotion. Deploy to test, then run `domino promote`.", "code": "promotion-required" } ``` The CLI reports it as `ENVIRONMENT_UNSUPPORTED` (exit 1) with `apiCode: "promotion-required"`. Deploy the bundle to test, then [prepare a promotion](#1-prepare-a-promotion). `domino deploy --promote TEST_DEPLOYMENT_ID --environment live` and `management.deployments.promote(id)` are deprecated and removed in 2.0.0: use [`domino promote`](#1-prepare-a-promotion) and `management.promotions.prepare()` with a client for test, which answer the promotion itself. Until then they check that the test deployment was published, then prepare a promotion of all of test. They publish nothing, and answer in the deployment record shape: `status: "pending"`, no resources, and a first warning with code `environment-promotion` that names the promotion. Its `id` is the promotion's: read it with `management.promotions.get(id)` and approve or reject it as a promotion; `deployments.get`, `deployments.publish`, `deployments.submit` and `deployments.reject` do not accept it. Like `domino promote`, the command runs in the checkout's project and needs no scope flags. `management.deployments.submit(id)` and `management.deployments.reject(id, { reason })` are deprecated too: they act only on live deployments made before live refused them. Console's **Live → Deployments** shows promotions only. `management.deployments.history({ cursor, limit })` still pages live deployments submitted before live refused them, newest first, and names the two latest published deployments beside every page. ### Work already under way keeps its rules [#work-already-under-way-keeps-its-rules] Publishing new rules does not change attempts members already started: they finish under the rules they were admitted with, and completions are not reset. Pausing a quest stops new attempts only. See [republishing and automatic quests](/docs/reference/quest-rules#republishing-and-automatic-quests). ## Archive a quest [#archive-a-quest] Archiving stops new participation in a quest and hides it from your app, while members keep what they earned. Deleting a quest's source file stops deploying it, but the quest stays published, so archive it too. Operators archive a quest from its page in Console, under **Participation**. From the CLI: ```sh pnpm exec domino quests archive show-your-setup --reason "Replaced by the new campaign" --environment test --json ``` The argument is the quest's key or ID. | Effect | Details | | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | No new attempts | Submissions, staff confirmations, and tracker triggers stop admitting the quest. Attempts already admitted keep their release; content qualification and engagement settlement for the quest wait, with the reason "Quest is archived", until it is unarchived. | | Hidden from collections | Your app's collections stop listing it. Completions, attempts, points, and rewards stay. | | Not counted as an interaction | An archived quest no longer needs your app to support its interaction, so a deployment can drop that interaction from `supportedInteractions`. | | Stays archived | Deploying the quest's code again publishes the new rules as archived. | | Requirements keep working | Quests that require it stay open to members who completed it. Others can no longer unlock them. | Run `domino quests unarchive --reason "…"` to reopen it. Unarchiving fails when your app no longer supports the quest's interaction or the quest no longer fits the prerequisite rules, such as the shared streak timezone. A quest with an empty resource setting reopens paused. Activity from while it was archived is not replayed. > [!NOTE] Archiving in live > Archive state is a definition, so live refuses archive and unarchive with `promotion-required`, from Console too. To stop new participation in live, pause the quest with `management.quests.pause` or **Pause quest** in Console. Quests created in Console change participation in their Console editor. ## Keep resource identity stable [#keep-resource-identity-stable] Each quest, reward or leaderboard in code has a `key` that Domino uses to recognize it across deployments. Keep it unchanged and members keep their progress; change it and Domino treats the result as a new resource. Use `key` for authored quest, quest type, leaderboard, referral program, reward, and collection declarations. Keep each key unchanged when renaming a title or moving a source file. Domino assigns the stored ID and reuses it on later deployments. Existing stored IDs and references remain valid. Define point ledgers with `defineLedger({ key, name, description? })`, then list each module export under `pointLedgers` in `relay.json`. You can also create ledgers in Console for operators to select through `settings.pointLedger`. * **References.** Rewards can reference a declared key or an existing ledger ID. Unknown references fail publication. * **Timing.** Previewing a bundle does not make a new ledger available for awards; publishing commits the ledger with the rest of the bundle. The CLI uses the project ID returned by project creation. Project names and hosted URL slugs are separate from that identity. ## Upgrade existing declarations [#upgrade-existing-declarations] Authoring constructors use `key`; the legacy `id` alias is removed. Keep the old value when renaming the property so redeployments retain identity. This source key is not the opaque ID Domino stores. ### 1. Review the migration [#1-review-the-migration] Run `domino migrate` to review an offline diff. It renames `id` to `key` and `balance` to `ledger`, migrates collection declarations, and proposes missing point ledger declarations for static references. A `HANDLE_REFERENCE_REQUIRED` error marks a string that needs a handle import; make that change by hand. ### 2. Apply it [#2-apply-it] Run `domino migrate --write` to apply the diff. Review the ledger names before publication. ### 3. Check the result [#3-check-the-result] Run `domino check --json` afterward. Source diagnostics include file locations, stable codes, and suggested fixes. * **Warnings** allow existing code to keep working. * **Conflicting identity values** fail the check. * **Dynamic declarations** remain unchanged and receive manual instructions. The migration is safe to rerun and never renames stored server IDs. ### Evaluator protocol [#evaluator-protocol] Each quest bundle declares the evaluator protocol it was built for, and Relay refuses an unsupported version before evaluation; rebuild with a supported SDK. See [evaluator protocol](/docs/reference/verification#evaluator-protocol) and [the v1 upgrade guide](/docs/reference/upgrade-to-v1#evaluator-protocol). ## Archive and restore resources [#archive-and-restore-resources] Archiving a tracker, leaderboard, referral program or other resource stops it without deleting its data or history. Restoring brings it back. Open **Settings → Resources** in Console, select the project and environment, and find the resource by name, key or kind. Choose its archive or unarchive action, such as **Archive tracker**, read the effect, and provide a reason. Trackers, leaderboards and referral programs offer the same action on their own pages: a tracker on its page, a leaderboard under **About this board**, and a referral program on its card. Read permission allows inspection; publish permission allows changes. Live refuses archive and restore with `promotion-required`, from Console too, so make these changes in test. For the test environment, use the CLI: ```sh domino resources list --environment test --json domino trackers archive events --reason "Source retired" --environment test domino trackers unarchive events --reason "Source returned" --environment test ``` The kinds are `tracker`, `referral`, `leaderboard`, `engagement`, `collection`, `quest-type`, `image`, and `point-ledger`. The reference accepts a key or stored ID. For a quest instance, use `domino quests archive|unarchive --reason ` or its Console page instead. Each change keeps its resource's ID, data and history. See [resource lifecycle](/docs/concepts/quests#resource-lifecycle) for the effect on each kind. Restoring a collection also restores its selected quests. Restoring an engagement rule can pay growth since its retained accounting watermark. Deleting source files, promoting another environment's code, or republishing older code does not change the destination's archive state. An archive or restore invalidates a prepared catalog preview; preview again to publish against the state you reviewed. > [!DEV] Retries and the management client > The `resources archive|unarchive` compatibility commands save the operation before sending it. The resource-specific commands use `--action-id ` for identical retries. For the compatibility commands, reuse `--operation-id ` with identical input or run `domino operations resume ` after an interruption. > > The management client exposes `management.resources.list()` and `management.trackers.archive(reference, { actionId, reason, expectedArchivedAt })` and `management.trackers.unarchive(reference, { actionId, reason, expectedArchivedAt })`. `expectedArchivedAt` is optional; supply the timestamp or `null` from the list to refuse a change after another operator changed that resource. # Referrals and inviter bonuses (/docs/build/referrals) A referral program rewards members for bringing friends who go on to take part. For example: * **"Invite a friend: you both get points"** once the friend finishes the welcome quest. * **"Bring five friends, get a 250-point bonus."** * **"Earn 10% of what your friends earn for 90 days"**, up to a cap. Domino keeps track of who invited whom, decides when a friend counts, and pays both sides. Your app shows the invitation link and the results. For the model behind this guide, read [Referral programs](/docs/concepts/referrals). See [Compose a campaign](/docs/composition) for a complete example. ## What members see [#what-members-see] * **Their invitation link.** Each member has a code your app puts in a link they share. * **A friend joins through the link.** The friend opens it and signs in. The first invitation a friend accepts is the one that counts. * **Rewards once the friend qualifies.** When the friend does what the program asks, such as finishing a quest, the inviter and the friend get their rewards. * **Their progress.** The inviter sees how many friends joined and qualified, their progress toward milestones, and their payouts. * **Privacy for friends.** An inviter never sees their friends' identities, balances or activity. ```mermaid sequenceDiagram autonumber actor Alice as Alice (inviter) participant App as Your app participant Domino actor Bob as Bob (friend) Alice->>App: Share link with her code Bob->>App: Open link, sign in App->>Domino: referrals.accept(code) Domino-->>App: Alice is Bob's inviter Bob->>Domino: Complete the qualifying quest Domino-->>Alice: Inviter reward and bonuses Domino-->>Bob: Invitee reward ``` ## What you can do in Console [#what-you-can-do-in-console] The program itself is defined in code. Once it's published, Console **Referrals** shows, for the selected project and environment, its programs, accepted invitations, who qualified, payouts, point corrections, and rewards that need review. You can: * **Correct a referral** with a reason: suspend or resume payouts, qualify a friend or revoke their qualification, and clear a reward held for review. See [Correct, suspend, and resume](#correct-suspend-and-resume). * **Manage inviter groups** in **Groups**, for programs only some inviters earn from. * **Get started with a coding agent.** Until a program is published, the page offers ready-made requests, such as rewarding both sides or adding milestones. Choose **Copy prompt** and paste it into your coding agent; the prompt includes the project, environment and setup steps. ## Publish a referral program [#publish-a-referral-program] Domino records one inviter per member in each project and environment. > [!TIP] Test with two real accounts > Check accepting an invitation and qualifying with two real member accounts in the same hosted test project. Development uses real sign-in and your configured providers; anonymous simulated previews are no longer available. Test keeps these points and referrals separate from live. ### 1. Define qualification and rewards [#1-define-qualification-and-rewards] ```text import { defineReferral, points, questCompleted } from "@domino-sdk/relay/authoring"; import { community } from "../../ledgers"; import welcome from "../../quests/welcome.quest"; import weeklyActivity from "../../quests/weekly-activity.quest"; export default defineReferral({ key: "friends", title: "Bring your friends", qualification: questCompleted(welcome), rewards: { inviter: [points(community, 25)], invitee: [points(community, 10)], }, milestones: [ { key: "five-friends", count: 5, rewards: [points(community, 250)] }, ], bonuses: [ { key: "ongoing", ledger: community, basisPoints: 1000, quests: [welcome, weeklyActivity], durationDays: 90, maxPoints: 1000, }, ], }); ``` This program pays the inviter 25 points and the friend 10 once the friend finishes the welcome quest, 250 more at five friends, and 10% of what each friend earns from two quests for 90 days, up to 1,000 points. See [Choose eligible activity](#choose-eligible-activity) for each field. ### 2. Add the module to your project [#2-add-the-module-to-your-project] Add the module to your existing `relay.json`: ```json { "referrals": [{ "entry": "src/referrals/friends.ts" }] } ``` > [!DEV] > Entries can select a named export with `exportName`. `domino check` bundles referral modules, including in projects without quests. `domino dev` synchronizes referral definitions with the other project resources. ### 3. Preview and publish to test [#3-preview-and-publish-to-test] ```sh domino deploy --preview domino deploy --environment test ``` The first command shows the proposed definitions. The second publishes them to test. ### Live publication [#live-publication] Publish to test directly with `domino deploy --environment test`. For live, the CLI prepares a request, and a person with publish permission approves it in Console. See [reviewed live publication](/docs/build/publishing#publish-behavior-to-live). > [!IMPORTANT] Removing a program does not stop it > A program you leave out of a later publication stays published. To stop new qualifications and bonuses, publish it with `enabled: false`. ## Choose eligible activity [#choose-eligible-activity] ### Qualification [#qualification] Qualification is what a friend must do before anyone is paid. | `qualification` | The friend qualifies when… | | ------------------------- | --------------------------------------------------------------------------- | | `{ kind: "signup" }` | They sign in and accept the invitation. | | `questCompleted(welcome)` | They complete the quest you name. | | `{ kind: "external" }` | Your server tells Domino they qualify, after checking the condition itself. | A member has one inviter, but each program decides on its own whether that friend qualifies. Each friend counts once per program. Completing a quest again, or your server qualifying them again, doesn't add another friend or pay fixed rewards twice. * **Several actions.** Qualify through a quest that counts actions, such as three verified posts, and name that quest. See [qualification with counters](/docs/concepts/referrals#qualification-with-counters). * **A condition that can be withdrawn.** Use `{ kind: "external" }`. Your server tells Domino the friend qualifies or no longer does, and Domino adjusts the friend's rewards, milestones and bonuses each time. * **A one-time achievement your app checks.** Your app adds the member to a [group](/docs/concepts/groups) once it has checked the condition. An automatic quest that requires that group completes, and the referral qualifies on that quest. Removing the member from the group later leaves the friend qualified. As with any quest qualification, the friend accepts the invitation before completing any other quest (see [acceptance rules](#acceptance-rules)). > [!DEV] > For several actions, give the quest a counter, `completion: progress({ distinct, goal: count(3) })`, and pass it to `questCompleted`. For an external condition, your server calls `management.referrals.qualify` with `qualified: true` or `false`. For a group, qualify with `questCompleted` on an `automatic()` quest that `requires: [inGroup(group)]` and completes `once()`; qualification follows that quest's completion, not the group write. ### Fixed rewards and milestones [#fixed-rewards-and-milestones] The inviter, the friend and each milestone can get points or tiered rewards. Badges, group memberships and Discord roles come from quests only. Tiered rewards use the same staff handover as quest rewards. Each referral reward is its own entitlement, so different qualified friends can earn separate items. Restoring a qualification reuses its original entitlement. > [!IMPORTANT] Put Discord roles, badges and groups on a quest > Referral rewards can be points and tiered rewards only. A deployment that puts a Discord role in an inviter, invitee or milestone reward fails with `Referral programs can grant points and tiered rewards; Discord roles need a quest`. A badge or a group membership there makes `defineReferral` throw, and a deployment refuse it, with `Badge rewards can only be granted by quests or manually, not by referral programs` or `Group membership rewards can only be granted by quests, not by referral programs`. Grant the role, badge or group from the qualifying quest instead, where Domino can tie its delivery to the member's attempt. A referral program published by an older Domino release may still contain a Discord role. Domino keeps that program readable so an operator can archive it. When a friend qualifies, it skips the role and records `referral-reward-skipped` with the reason in **Audit history**; the qualification and the program's other rewards still go through. Publish a replacement program with points or tiered rewards, or move the role to the qualifying quest. ### Percentage bonuses [#percentage-bonuses] A bonus gives the inviter a percentage of what their friend earns, on top of the friend's own points. The friend never loses anything. > [!NOTE] Worked example > At 10% (`1000` basis points), when Bob earns 100 eligible points, Bob keeps 100 and Alice receives 10 extra points. The qualifying quest's own award counts. Points Bob earned before qualifying don't earn a bonus. Fixed referral rewards and bonus payouts never trigger further bonuses. | Field | Meaning | | -------------- | ------------------------------------------------------------------------------ | | `ledger` | The point ledger the bonus reads and pays. One per bonus. | | `basisPoints` | The rate. `1000` basis points means 10%. | | `quests` | The quests that count. Leave it out to count every quest award to that ledger. | | `external` | `true` also counts awards from your own server that opt in. | | `durationDays` | Optional. Starts when the friend first qualifies. Suspensions don't extend it. | | `maxPoints` | Optional. Caps the bonus for that friend and rule. | * **Engagement rewards count.** A quest's earnings include its completion award and the daily payouts of an [engagement rule](/docs/concepts/ugc#engagement-credits-growth) that reads it. A bonus in the same ledger that covers the qualifying quest, or names no quests, earns on each engagement payout. The usual limits apply: the friend must be qualified and not suspended, the program's inviter group, `durationDays` and `maxPoints` still apply, only whole points pay out, and an outstanding correction takes the payout first. * **Leaderboard prizes don't count.** Prize points aren't quest earnings and earn no bonus, even from a bonus that names no quests. The points that won the prize already earned theirs. * **Fractions add up.** Partial points build up per friend, rule and balance. Ten awards of 3 points at 10% eventually pay 3 whole points. > [!WARNING] Overlapping rules are rejected > Two percentage rules for the same balance and source can't both exist, even in separate programs. Named groups don't keep them apart, because a member can be in both. Fixed rewards, milestones and a percentage bonus can coexist. ### Inviter groups [#inviter-groups] A program can pay only inviters in one [group](/docs/concepts/groups), such as ambassadors. Friends of other members can still accept invitations and qualify, but neither side earns anything from that program: no inviter or friend reward, no milestone and no bonus. * **Groups are checked at the time.** Domino checks the inviter's group when a friend qualifies and when the friend's points are accepted. It doesn't pay missed rewards or bonuses after the inviter joins. * **Publishing creates the group.** If the environment lacks the group, publishing creates it empty. Manage its members in Console **Groups**. > [!DEV] > Set `inviterGroup` on the program, next to `qualification`, and pass a group handle from `defineGroup`. Manage its members from code with `management.groups`. ## Share and accept invitations [#share-and-accept-invitations] Your app shows each member their link and accepts the invitation for the friend after they sign in. The friend is always the signed-in member, never someone named in the request. You can also offer a box to type a code by hand. Your app decides how it all looks; Domino doesn't need a separate confirmation screen. ```ts import { createBrowserRelayClient } from "@domino-sdk/relay/browser"; const domino = createBrowserRelayClient({ baseUrl: "/relay" }); const summary = await domino.referrals.summary(); const invitation = new URL("https://app.example.com/join"); invitation.searchParams.set("ref", summary.code); console.log(invitation.toString()); console.log(summary.attributed, summary.programs, summary.accounts); // On the invited friend's landing page, retain the code through sign-in. const code = new URL(window.location.href).searchParams.get("ref"); if (code) sessionStorage.setItem("pending-referral", code); // After sign-in, before any quest other than the program's qualifying quest completes: const pending = sessionStorage.getItem("pending-referral"); if (pending) { await domino.referrals.accept({ code: pending, actionId: "accept-invitation" }); sessionStorage.removeItem("pending-referral"); } const history = await domino.referrals.history(); if (history.nextCursor) { const older = await domino.referrals.history({ cursor: history.nextCursor }); } ``` > [!DEV] > Use your configured participant proxy or existing-app identity bridge at `/relay`. A member session is required. A manual code input uses the same `accept` call. ### Acceptance rules [#acceptance-rules] * **Accept early.** Accept the invitation during onboarding. Completing any quest closes the window, except a referral program's qualifying quest: a friend who checked in, or claimed the entry quest at sign-in, can still accept, and accepting qualifies them in that program and pays both sides. Points the friend earned before accepting don't earn the inviter a percentage bonus. A revoked qualifying completion doesn't qualify. A leaderboard prize is an operator accounting record and doesn't close this window. * **First inviter wins.** Opening another invitation later can't replace them. * **Invalid codes are rejected.** This includes inviting yourself, invitation loops, and codes from another project or environment. * **Retries are safe.** Repeating `accept` with the same action ID and payload after a timeout returns the original result. Keep the code until you know the result. Show a clear error for an invalid code or a closed invitation window. ### What the reads return [#what-the-reads-return] | Call | Contains | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `summary()` | The inviter's code, how many friends accepted, qualified counts and milestone progress per program, and point accounts. Accounts separate earned amounts, settled amounts and outstanding corrections. Earned amounts leave out referral payouts an operator reversed. A settled amount can include points that paid off a correction instead of reaching the balance. | | `history()` | Paginated payout history, `{ items, nextCursor }`. Pass `nextCursor` as the next call's `cursor`. | Neither response shows invited friends' identities, balances or detailed activity. > [!DEV] > Programs, rewards, accounts and history name ledgers and programs by ID. Find your program by `definition.key`, and match ledgers through `relay.pointLedgers.list()` ([Match ledgers in your app](/docs/concepts/points#match-ledgers-in-your-app)). Comparing a returned `balance` or `program` with a key never matches. ## Award external points and qualify a friend [#award-external-points-and-qualify-a-friend] Your own server can qualify friends for programs that use `{ kind: "external" }`, and award points that count toward bonuses. It uses a server-side management key scoped to the project and environment. | Operation | Required scope | | ---------------------------- | -------------- | | External point writes | `points` | | Qualification and moderation | `publish` | | Reads | `read` | A hosted app's own key (`DOMINO_MANAGEMENT_KEY`, see [Call the management API from your server](/docs/build/hosted-app#call-the-management-api-from-your-server)) covers point writes and reads, but not qualification, which needs `publish`. To qualify from a hosted app, store a key with `publish` as an [app secret](/docs/build/hosted-app#set-app-secrets) and use it for those calls, as the example below does. An [automation key](/docs/agents/automation) with Publish works in test; a personal API key with `publish` can qualify and moderate in live too. **Audit history** records each change under the key: its owner for a personal key, the automation key itself for an automation key. > [!WARNING] `resolveReward` releases rewards > `management.referrals.resolveReward(entitlement, { actionId, reason })` is Console's **Mark reconciled**. It clears the review block on a referral reward, so an entitlement held for review can be handed over again. Any key with `publish` can call it in live. Give that permission only to automation you trust to release rewards. ```ts import { createManagementClient } from "@domino-sdk/relay/server"; const scope = { baseUrl: "https://relay.domino.run", organization: "my-organization", project: "community", environment: "test", } as const; // Qualification needs a key with publish, stored as an app secret. const publisher = createManagementClient({ ...scope, getToken: async () => process.env.REFERRALS_PUBLISH_KEY!, }); // For a program whose qualification kind is external: await publisher.referrals.qualify("friends-external", "member_existing", { actionId: "verified-referral-42", qualified: true, reason: "Verified the required external activity", }); // The hosted app's own key covers point writes. const management = createManagementClient({ ...scope, getToken: async () => process.env.DOMINO_MANAGEMENT_KEY!, }); await management.points.record({ kind: "award", actionId: "external-activity-42", member: "member_existing", balance: "community", amount: 100, referralEligible: true, reason: "Verified external activity 42", }); ``` > [!IMPORTANT] External awards need two opt-ins > Awards from your server earn no bonus by default. They need both `referralEligible: true` on the award and `external: true` on a matching rule. Adjustments, absolute-total updates, spending and referral rewards never generate bonuses. Reuse the source event's action ID and payload for retries. A source award and its inviter bonus are saved together, so a failed bonus never leaves a half-saved award. ## Correct, suspend, and resume [#correct-suspend-and-resume] Select your project and **Test** or **Live** in the Console header, then open **Referrals**. Select an invited friend in the invitations list, or search for anyone by name, email or ID in **Find a participant**. Their details open beside the list, with their programs, payouts and payout history. Enter a **Reason**, then choose: | Where | Action | Effect | | -------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------- | | **Referral payouts** | **Suspend** | Keeps the invitation and stops future payouts to both sides. | | **Referral payouts** | **Resume** | Restarts payouts. The suspended period is not paid afterwards. | | Each program | **Qualify** / **Revoke** | Qualifies the friend for that program, or revokes their qualification. | | A reward marked **Needs review** | **Mark reconciled** | Clears the review block once staff have resolved the original reward. It doesn't issue, delete or reclaim a physical item. | Corrections need publish access. Select an inviter to see their payout history. Suspending covers one invitation. To stop a farmer everywhere, [ban the member](/docs/use/member-bans): no referral payouts reach them or come from their points, and their invitation code stops working. The ban can also reverse the referral payouts they already received, following the rules below. ### How corrections work [#how-corrections-work] * **Reversals use the original rate.** A reversed bonus uses the rate recorded with the original award. * **Revoking qualification undoes its rewards.** Fully reversing the qualifying quest's award also revokes the qualification. Staff can revoke it themselves when other evidence turns out to be invalid. The friend stops counting as qualified, their past percentage bonuses are corrected, and affected fixed rewards and milestones are adjusted. Qualifying again doesn't replay earlier percentage earnings. * **Balances never go negative.** A correction takes back available points right away without touching points held for a redemption. Anything left over comes out of future referral payouts in the same balance; ordinary future earnings aren't taken. Bob's reversal can go through even after Alice spent her bonus, as long as Bob's own balance covers his reversal. * **A reversed payout stays reversed.** If an operator reverses all or part of a referral payout from point history, Domino writes those points off on the referral account. Later payouts never pay them again, whatever reconciles the account, and a later qualification correction doesn't take them a second time. Earnings that come after the reversal still pay: if a percentage bonus grows, the inviter receives only the growth. Revoking and restoring the qualification doesn't bring the reversed points back; to pay them again, award them by hand. Reusing the reversal's action ID returns its first result, and a new reversal can't exceed what's left of the payout. * **Restoring reuses the original entitlement.** When a qualification or milestone is restored, Domino first cancels its outstanding correction, then restores amounts already taken back. It never grants a second copy of a delivered tiered reward. Invalidated tiered entitlements can't be handed over until restored or reviewed by staff. ### Editing a published program [#editing-a-published-program] Changes to a program apply to actions accepted from then on. Backdated point awards use the current rules and eligibility; their effective date still counts for leaderboards. > [!CAUTION] Keep IDs stable > Keep program, bonus and milestone keys stable. Domino maps them to stored IDs; fixed rewards already issued keep their original payload and milestone threshold. > [!DEV] > Generated referral record IDs are the `ref_` prefix followed by 22 random alphanumeric characters. Treat the full value as opaque; existing record IDs stay valid. Invitation codes are separate from record IDs. ## Qualify referrals through Discord membership [#qualify-referrals-through-discord-membership] A program can count a friend once they join your Discord server: name the [Discord membership quest](/docs/build/integrations#check-that-someone-joined-your-server) as the qualification, `qualification: questCompleted(discordMembership)`. Publish that quest in the same project first. This uses Domino invitation codes and counts each friend once. It doesn't track Discord invite links or read Discord's invite counts. Referral `balance` values are stable point ledger IDs. Find the ID in Console **Rewards → Point ledgers**. Two ledgers can share a display name, so use descriptions and IDs to tell them apart. See [point ledgers](/docs/build/leaderboards#choose-a-point-ledger). # Use Domino with Reown AppKit (/docs/build/reown) If your app connects wallets with [Reown AppKit](https://reown.com/appkit), members can sign in to Domino with the wallet AppKit connected, including mobile wallets over WalletConnect. AppKit gives your server no token to check, so members sign Domino's own message instead: the signature signs them in and proves the wallet. This is [wallet sign-in](/docs/build/wallets#sign-in-with-a-wallet), which Domino offers with its own [member sign-in](/docs/build/hosted-app#add-domino-participant-sign-in) in a Domino-hosted app. An app with its own login uses [app login](/docs/build/app-login) instead, and members link their AppKit wallets as in [step 3](#3-link-more-wallets). ## Before you start [#before-you-start] * **Wallet sign-in on.** In Console, open **Settings → Sign-in** and check **EVM** under **Wallet sign-in**, plus **Solana** if AppKit connects Solana wallets. Each environment has its own setting. * **AppKit set up.** Your app creates AppKit with its Reown project ID and the EVM or Solana adapter, as Reown's documentation shows. ## Connect AppKit [#connect-appkit] ### 1. Wrap the connected wallet in a signer [#1-wrap-the-connected-wallet-in-a-signer] AppKit's `useAppKitProvider` returns the connected wallet's provider. An EVM provider is an EIP-1193 provider, so pass it to `evmSigner`. A Solana provider fits `solanaSigner` with a small wrapper: ```tsx import { evmSigner, solanaSigner, type Eip1193Provider, } from "@domino-sdk/relay/browser" // From @reown/appkit/react: useAppKitProvider("eip155").walletProvider. declare const evmProvider: Eip1193Provider // From useAppKitProvider("solana").walletProvider. declare const solanaProvider: { publicKey?: { toBase58(): string } signMessage(message: Uint8Array): Promise } export const evm = evmSigner(evmProvider, { name: "WalletConnect" }) export const solana = solanaSigner({ connect: async () => ({ publicKey: solanaProvider.publicKey }), publicKey: solanaProvider.publicKey, signMessage: (message) => solanaProvider.signMessage(message), }) ``` AppKit opens its own modal to connect; call `open()` from `useAppKit()` first if `useAppKitAccount().isConnected` is false. ### 2. Sign in with it [#2-sign-in-with-it] Pass the signer to `useSignIn().withWallet`. The member's wallet shows Domino's message for your app's domain, which states that it authorizes no transaction or transfer. ```tsx import { useSignIn } from "@domino-sdk/relay/react" import type { WalletSigner } from "@domino-sdk/relay" export function SignInWithAppKit({ signer }: { signer: WalletSigner }) { const signIn = useSignIn() return ( <> {signIn.error &&

{signIn.error.message}

} ) } ``` A new address creates a new member, and the same wallet signs them in again later. `RelayProvider` signs the member out when the EVM wallet switches to another account or disconnects, as [wallet sign-in](/docs/build/wallets#2-offer-the-sign-in-options) describes. ### 3. Link more wallets [#3-link-more-wallets] A signed-in member links another wallet the same way, with `useWallets().link(signer)`. A wallet linked by signature is proved, so it appears in a leaderboard's [payout list](/docs/build/leaderboards#export-the-payout-list), and the wallet a member signed in with is in it too. ```tsx import { useWallets } from "@domino-sdk/relay/react" import type { WalletSigner } from "@domino-sdk/relay" export function LinkWithAppKit({ signer }: { signer: WalletSigner }) { const { link, isPending, actionError } = useWallets() return ( <> {actionError &&

{actionError.message}

} ) } ``` A member can link 5 wallets per chain family, besides the ones they sign in with. Smart-contract wallets, such as Base Account (formerly Coinbase Smart Wallet), sign in and link this way too: Domino checks their signature [on the chain the wallet is on](/docs/build/wallets#limits). ### 4. Check it [#4-check-it] * Connect a wallet with AppKit, sign in, and check that the app shows the member's Domino quests. * Connect a phone wallet over WalletConnect and sign in with it. * In Console, open the member's page and check that **Wallets** lists the wallet. > [!NOTE] Not yet checked against a live AppKit app > `evmSigner` uses `eth_requestAccounts`, `eth_chainId` and `personal_sign`, which EIP-1193 providers implement. The Solana wrapper follows AppKit's documented Solana provider (`publicKey` and `signMessage`); neither has been run against a live AppKit project. ## Where next [#where-next] - [Wallets](/docs/build/wallets) — wallet quests, limits and what operators see. - [Host your app](/docs/build/hosted-app) — Domino-hosted apps and their member sign-in. # Run scheduled jobs (/docs/build/schedules) A scheduled job is work your hosted app does on a timer, even when no member has the app open. For example: * **Refresh token balances every 15 minutes**, so a holders leaderboard stays current. * **Pull activity from an outside service every hour** into a custom tracker, so quests that depend on it can pay out. * **Check members' quest progress every morning** and update your own records. Members don't see the jobs themselves. They see the result: standings, quests and rewards that stay up to date. Jobs run in a Worker app that Domino hosts: on the staged build in test, and on the live app once a promotion puts that build live. Static apps and development previews don't run jobs. ## What you can do in Console [#what-you-can-do-in-console] The jobs are declared in your app's code. Open **Settings → Schedules**, with **Test** or **Live** selected, to: * **See every job**: when it last ran, its status and duration, what it reported, and when it runs next. * **Read a job's history** with **Run history**. * **Run a job now** with **Run now**, for example to check a fix. * **Pause, archive or resume** all of the project's jobs in that environment. * **Unblock a job** whose last run is **Uncertain** with **Recover uncertain run**. See [Inspect and control jobs](#inspect-and-control-jobs) and [Timeouts and recovery](#timeouts-and-recovery). ## Declare a schedule [#declare-a-schedule] Add `schedules` beside `app.secrets` and `app.storage` in your existing Worker configuration, then stage the app: ```json { "app": { "kind": "worker", "schedules": [ { "name": "refresh-data", "path": "/jobs/refresh-data", "every": "15m", "description": "Refresh custom tracker data from an external service" } ] } } ``` This is a fragment of `relay.json`. Keep your existing build scripts, output directories and Worker settings. Use a route your Worker handles, and set `runWorkerFirst: true` if public assets could intercept that route. | Field | Rule | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | Unique within the app. A lowercase letter followed by lowercase letters, digits or hyphens, up to 64 characters. | | `path` | An absolute route such as `/jobs/refresh-data`. Use letters, digits, underscores, hyphens and slashes; no query, hostname, empty segment or traversal. | | `every` | An integer followed by `m`, `h` or `d`, from `5m` through `365d`. | | `cron` | Instead of `every`, a five-field UTC cron expression using numeric fields, `*`, ranges, lists and steps. Minute slots must be at least five minutes apart, including across an hour boundary. Examples: `*/15 * * * *` or `0 9 * * 1-5`. | | `description` | What the job does, from 1 to 500 characters. | * **Up to 10 jobs** per app. Declare exactly one of `every` and `cron` for each. * **When they run.** Intervals start when the build becomes active. Cron schedules use UTC. A new build restarts the clock from its own activation time. * **Test and live are separate.** Each keeps its own jobs, runs and next run times. ## Verify the call [#verify-the-call] Domino calls each job with a signed `POST`. Check the signature before doing any work, so nobody else can trigger your job: ```ts import { verifyScheduledRequest } from "@domino-sdk/relay/server"; declare function refreshData(runId: string): Promise; export default { async fetch(request: Request, env: { DOMINO_SCHEDULE_KEY: string }) { if (new URL(request.url).pathname !== "/jobs/refresh-data") { return new Response("Not found", { status: 404 }); } const run = await verifyScheduledRequest(request, env.DOMINO_SCHEDULE_KEY); if (!run || run.name !== "refresh-data") { return new Response("Unauthorized", { status: 401 }); } await refreshData(run.id); return new Response(null, { status: 204 }); }, }; ``` Implement `refreshData` with your app's own bounded background work. The timestamp check limits replays but doesn't remove duplicates, so use `run.id` as an idempotency key when writing to an outside service or store. > [!DEV] > Relay binds a separate `DOMINO_SCHEDULE_KEY` secret to scheduled Worker builds. The live app's key has a different value from staging's, so a request signed for one doesn't verify on the other. The helper returns the verified run's `id`, `build`, `name`, `scheduledAt`, `startedAt` and `manual`, or `null`. It checks the method, route, exact body, HMAC-SHA-256 signature and a 60-second timestamp window. It rejects query parameters, because Relay calls only the declared bare path. It reads a clone, so the original request body stays readable. The signing key is separate from `DOMINO_MANAGEMENT_KEY`; keep both in server code, and never send either to a browser or log it. ## Feed a custom tracker [#feed-a-custom-tracker] A common job collects outside data, such as on-chain balances or activity from another service, and sends it to a [custom tracker](/docs/build/trackers#feed-another-source-including-a-blockchain-indexer). Your app decides what to fetch and how to map it to the tracker's declared schema; Domino doesn't collect custom data for you. Regular refreshes keep tracker-based quests and leaderboards current even when members don't open the app. ```ts import { hostedManagementClient, verifyScheduledRequest, type HostedManagementEnv, } from "@domino-sdk/relay/server"; import { trackerIngestSchema } from "@domino-sdk/relay/schema"; declare function collectRecords(): Promise; export default { async fetch(request: Request, env: HostedManagementEnv & { DOMINO_SCHEDULE_KEY: string }) { if (new URL(request.url).pathname !== "/jobs/refresh-data") { return new Response("Not found", { status: 404 }); } const run = await verifyScheduledRequest(request, env.DOMINO_SCHEDULE_KEY); if (!run || run.name !== "refresh-data") { return new Response("Unauthorized", { status: 401 }); } // Fetch and map records for your custom source, including its verified accounts. const input = trackerIngestSchema.parse({ actionId: run.id, records: await collectRecords(), }); await hostedManagementClient(env).trackers.ingest("external-data", input); return new Response(null, { status: 204 }); }, }; ``` Implement `collectRecords` to fetch and map your outside source. Page through large datasets and keep each run bounded. > [!DEV] > The hosted app's management key already has custom tracker ingestion permission. A stable record key updates the same record on later runs; the run ID deduplicates ingestion for this run. See [custom tracker ingestion](/docs/build/trackers#feed-another-source-including-a-blockchain-indexer) for record shapes and account ownership. ## Report what a run did [#report-what-a-run-did] Have each job report what it did: an outcome, a summary, counts and per-item notes. Console then shows it under the job and in its run history. Without a report, Domino records only the HTTP status, so a `204` reads as **Succeeded** even when some of the work failed. ```ts import { scheduleResult, verifyScheduledRequest } from "@domino-sdk/relay/server"; declare const members: string[]; declare function refreshMember(member: string): Promise; export default { async fetch(request: Request, env: { DOMINO_SCHEDULE_KEY: string }) { const run = await verifyScheduledRequest(request, env.DOMINO_SCHEDULE_KEY); if (!run || run.name !== "refresh-data") { return new Response("Unauthorized", { status: 401 }); } const failed: { level: "error"; subject: string; message: string }[] = []; for (const member of members) { try { await refreshMember(member); } catch { failed.push({ level: "error", subject: member, message: "Refresh failed" }); } } return scheduleResult({ outcome: failed.length === 0 ? "succeeded" : failed.length < members.length ? "partial" : "failed", summary: `${members.length - failed.length} of ${members.length} members refreshed`, metrics: { processed: members.length, failed: failed.length }, diagnostics: failed, }); }, }; ``` | Field | Rule | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `outcome` | `succeeded`, `partial` or `failed`. `partial` and `failed` record the run as **Failed**, whatever the HTTP status. | | `summary` | Optional, up to 500 characters. It also ends the run's error, such as `App reported a partial run: 4 of 5 members refreshed`. | | `metrics` | Optional. Up to 50 named numbers, such as counts and gauges. Names start with a letter and use letters, digits, `_`, `.` or `-`. | | `diagnostics` | Optional. Up to 50 lines, each with a `level` (`error`, `warning` or `info`), a `message` of up to 500 characters, and an optional `subject` (such as a member or record ID) and `code`. | > [!DEV] > Relay reads a JSON answer (`Content-Type: application/json`) of up to 16,384 bytes with any HTTP status, so a `500` can carry a result too. It ignores other content types. A JSON answer that isn't a valid result, or is larger, never changes the run's status; the run's error starts with `Result ignored:` and says why. `scheduleResult` throws on an invalid or oversized result, so the mistake shows up in your code. > [!WARNING] Keep results free of secrets > Anyone who can read the project in that environment sees run results. Name members and records by ID; never include tokens, keys, request headers or personal data. ## Fit a run into 30 seconds [#fit-a-run-into-30-seconds] Each run must finish within 30 seconds, including every call it makes to Domino. * **Batch tracker ingests.** Ingests into one tracker run one at a time on the server, so parallel calls into the same tracker wait for each other and gain nothing. Send one `trackers.ingest` call per tracker per run, with up to 100 records, instead of one call per member. * **Time calls from what's left.** Pass a call's `timeout` from the time remaining, such as `trackers.ingest(reference, input, { timeout })`. The client's default is 30 seconds, the whole run. * **Retry with the same action ID.** A timed-out ingest may still have been stored. Sending the same `actionId` again returns the first answer instead of ingesting twice. An action ID is 1–100 letters, digits, `_` or `-`: the run's `id`, or `${run.id}-${step}`, fits; a `:` or `.` is refused before anything is sent. * **Stop early and say so.** When time runs low, stop, report a `partial` outcome with what's left, and let the next run continue. ## Delete old evidence nightly [#delete-old-evidence-nightly] Domino already deletes submitted evidence 90 days after the decision, unless you changed your project's period or turned it off; see [how long evidence is kept](/docs/concepts/quests#how-long-evidence-is-kept). A job is how you delete it sooner, or on a different schedule per quest, to keep a shorter promise to members. This one deletes the evidence of one quest's attempts decided more than 30 days ago, every night at 03:00 UTC. See [delete old evidence on a schedule](/docs/concepts/quests#delete-old-evidence-on-a-schedule) for what a pass deletes and keeps. Deleting evidence needs `review` permission, which the hosted app's own key doesn't have, and in live a pass also needs an organization admin's Console session or personal key, or a service key. An organization admin creates a [service key](/docs/start/existing-app#5-call-domino-from-your-backend) with only **Review**, in the environment the job runs in, and you save it as an [app secret](/docs/build/hosted-app#set-app-secrets) named `RETENTION_KEY`: ```json { "app": { "kind": "worker", "secrets": { "RETENTION_KEY": { "description": "Service key with review permission, for the evidence retention job" } }, "schedules": [ { "name": "evidence-retention", "path": "/jobs/evidence-retention", "cron": "0 3 * * *", "description": "Delete selfie evidence decided more than 30 days ago" } ] } } ``` ```ts import { hostedManagementClient, scheduleResult, verifyScheduledRequest, type HostedManagementEnv, } from "@domino-sdk/relay/server"; type Env = HostedManagementEnv & { DOMINO_SCHEDULE_KEY: string; RETENTION_KEY: string }; const day = 24 * 60 * 60 * 1000; export default { async fetch(request: Request, env: Env) { const run = await verifyScheduledRequest(request, env.DOMINO_SCHEDULE_KEY); if (!run || run.name !== "evidence-retention") { return new Response("Unauthorized", { status: 401 }); } // The app's own bindings, with the service key in place of the app's key. const management = hostedManagementClient({ ...env, DOMINO_MANAGEMENT_KEY: env.RETENTION_KEY }); const decidedBefore = run.scheduledAt - 30 * day; let deleted = 0; let more = true; // Each call deletes up to 100 attempts' evidence. Stop well inside the 30 seconds; the next night continues. for (let call = 0; more && Date.now() - run.startedAt < 20_000; call++) { const pass = await management.attempts.redactEvidenceBefore({ actionId: `${run.id}-${call}`, quest: "selfie", decidedBefore, reason: "Retention: selfies are kept for 30 days", }); deleted += pass.attempts.length; more = pass.more; } return scheduleResult({ outcome: more ? "partial" : "succeeded", summary: `${deleted} attempts' evidence deleted${more ? ", more left for the next run" : ""}`, metrics: { deleted }, }); }, }; ``` Each call's action ID comes from the run, so a retried call answers its first result instead of deleting again. The service key appears as the actor on each deleted attempt and in **Audit history**. ## Read member progress [#read-member-progress] A job that checks members' quests reads each member's progress: their state on each active quest, their recent attempts and their point balances. The read stays small however long the member has taken part. ```ts import { hostedManagementClient, type HostedManagementEnv } from "@domino-sdk/relay/server"; export async function completedQuests(env: HostedManagementEnv, member: string) { const progress = await hostedManagementClient(env).members.progress(member); return progress.quests.filter((quest) => quest.completed).map((quest) => quest.quest); } ``` From a terminal, `domino members progress MEMBER --json` prints the same read. It needs `read` permission, which the hosted app's management key has. > [!DEV] > `management.members.progress(member)` returns the member's progress on each active quest (`completed`, `completionCount`, the latest `attempt`), their 25 most recent `attempts` and their point `balances`. Every list is bounded by the project's configuration. > > Avoid `management.members.get(member)` in jobs. Member detail also returns every active quest release with its evaluator code, which runs to megabytes per member in a project with many quests. It's still where the member's rewards, completion records and each streak's qualifying days are. For the member's linked wallets, read `management.members.identityStates.list(member)`, which pages through their wallet and account identities. ## Inspect and control jobs [#inspect-and-control-jobs] Open **Settings → Schedules** in Console, with **Test** or **Live** selected: * **The list** shows the active build's jobs, each one's last run time, status and duration, its error and [result](#report-what-a-run-did), and the next run. * **Run history** under a job shows its latest 20 runs, newest first, each with its ID, build, status, error and result. Domino keeps the latest 20 runs of each job. * **Run now** queues one run, which goes through the same checks as a timed run. * **Paused** or **Archived**, chosen in the state menu, stops new runs for the whole project in that environment, and **Active** restores either. Console asks for a reason before it changes the state, and **Audit history** records it. Your choice stays through new builds and promotions, and pausing test's jobs leaves live's running. Removing a job from `relay.json` and staging stops it in test; promoting that build stops it in live. Deleting the project stops all its jobs, and purging it removes their records. The same controls are in the CLI: ```sh domino schedules list --json domino schedules list --environment live --json domino schedules runs refresh-data --json domino schedules run refresh-data domino schedules pause --reason "Rotating the API key" domino schedules resume domino schedules archive --reason "App retired" ``` > [!DEV] > Without `--environment live`, `domino schedules` uses test; it ignores `RELAY_ENVIRONMENT` and the environment in `relay.json`, and warns when either names live. Its `--json` result says which environment it used in `environment`. Listing and reading runs need `read` in the environment. Running jobs, changing state and recovering a job need `publish` there. A CLI sign-in session can't run or control live jobs, so use Console or an API key for those. The management client provides `management.schedules.list()`, `.runs(name)`, `.run(name)`, `.setState("active" | "paused" | "archived", { reason })`, where the reason is optional, and `.recover(name, { stopped: true })`. The routes are `GET /management/v1/schedules`, `GET /management/v1/schedules/:name/runs`, `PUT /management/v1/schedules/state`, and `POST /management/v1/schedules/:name/run` or `/:name/recover`. ## Timeouts and recovery [#timeouts-and-recovery] > [!WARNING] Finish all work before answering > A scheduled request has a **30-second timeout**. Don't put scheduled work in `waitUntil`, start work that outlives the response, or answer before ingestion finishes. Domino can't prove that remote work stopped after a lost connection. * **One try per run.** Domino tries each timed run once and never retries it automatically. Runs missed during downtime are skipped, and the next one runs as usual. * **Failures don't stop the schedule.** A failed response, or a `partial` or `failed` outcome, records a failure, and the next run still happens. So does a run whose build has no Worker to run it, since the job never started. * **No overlap.** The same job never runs twice at once. If Domino can't tell whether a run finished, it blocks that job, even across new builds. The run's error says what Domino saw: the HTTP status and cause from the dispatch, no answer within 30 seconds, or the error that interrupted the call. An exception your app throws before answering is shown there too, so keep secrets out of error messages. Once the timeout has passed, Console shows the run as **Uncertain** and offers recovery: ### 1. Check that the job stopped [#1-check-that-the-job-stopped] Look at your app's own records and any outside work the job started, and confirm the job has stopped. ### 2. Recover the job [#2-recover-the-job] Choose **Recover uncertain run**, turn on **I confirmed the previous job has stopped**, and choose **Allow future runs**. The CLI equivalent is: ```sh domino schedules recover refresh-data --yes ``` Recovery allows future runs; it never replays the uncertain one. Fix app errors in source and stage a new build, then use **Run now** to check the fix. A fix reaches live's jobs when that build is promoted. For exactly when a run counts as failed or uncertain, see [Schedule dispatch guarantees](/docs/reference/management#schedule-dispatch-guarantees). # Wallet sign-in in your own Next.js app (/docs/build/self-hosted-wallet-sign-in) Your Next.js app signs users in with their wallet, the way Scaffold-ETH 2, create-solana-dapp and most wagmi apps already do. Domino then uses that login: your server tells Domino which wallet signed in, and members collect quests and points under it without a second sign-in. This is [app login](/docs/build/app-login) with a wallet as your app's user. Your server checks the wallet's signature itself with `verifyWalletSignIn`, so it works on any host, `localhost` included. ```mermaid sequenceDiagram actor User participant Browser participant Server as Your Next.js server participant Domino Browser->>Server: GET /api/wallet/nonce Server-->>Browser: A fresh nonce, kept in the session User->>Browser: Signs the sign-in message in the wallet Browser->>Server: POST /api/wallet/verify with message and signature Server->>Server: verifyWalletSignIn checks domain, nonce, expiry, chain and signature Browser->>Server: Participant request at /relay Server->>Domino: Exchange for the wallet's member, vouching the wallet Domino-->>Server: Quests and progress ``` ## Before you start [#before-you-start] * **An app login key.** An organization admin creates one in Console under **Settings → App login keys** in **Test**, with the app's address as its origin. For local development that is `http://localhost:3000`; see [develop on localhost](#develop-on-localhost). Save it in the server's environment as `APP_IDENTITY_KEY`. * **Allow vouching on the key.** Under **Connected apps**, choose **Allow vouching** on the key, give a reason, and confirm. This needs an organization admin with **Publish** permission. Your server can then attach the wallet to the member; see [vouch for your members' accounts](/docs/build/app-login#vouch-for-your-members-accounts). * **A session library.** The examples keep the nonce and the signed-in wallet in an encrypted cookie session, such as `iron-session`'s. `appSession()` stands for yours. ## Sign in with Ethereum [#sign-in-with-ethereum] ### 1. Issue a nonce [#1-issue-a-nonce] The server issues a fresh nonce for each sign-in and keeps it in the session, and the verify route clears it before checking the message, so the browser can't send the same message twice. A cookie-only session, such as `iron-session`'s, keeps no record of a cleared nonce: someone holding the earlier cookie and the signed message can replay both until the message expires, at most 10 minutes later. Store sessions on the server when each message must work once. ```ts // app/api/wallet/nonce/route.ts import { randomBytes } from "node:crypto" import { appSession } from "./your-app-session" export async function GET() { const session = await appSession() session.nonce = randomBytes(16).toString("hex") await session.save() return Response.json({ nonce: session.nonce }) } ``` ### 2. Sign the message in the browser [#2-sign-the-message-in-the-browser] Build an EIP-4361 message with viem for your page's host and the chain the wallet is on, and ask the wallet to sign it. ```ts "use client" import "viem/window" import { createWalletClient, custom } from "viem" import { createSiweMessage } from "viem/siwe" export async function signInWithEthereum() { const wallet = createWalletClient({ transport: custom(window.ethereum!) }) const [address] = await wallet.requestAddresses() const { nonce } = await (await fetch("/api/wallet/nonce")).json() const message = createSiweMessage({ domain: window.location.host, address, statement: "Sign in to Booth.", uri: window.location.origin, version: "1", chainId: await wallet.getChainId(), nonce, issuedAt: new Date(), expirationTime: new Date(Date.now() + 5 * 60 * 1000), }) const signature = await wallet.signMessage({ account: address, message }) const answer = await fetch("/api/wallet/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message, signature }), }) if (!answer.ok) { throw new Error("Wallet sign-in failed") } } ``` With wagmi, take `address` and `chainId` from `useAccount()` and sign with `useSignMessage().signMessageAsync({ message })`. With RainbowKit, call the same two routes from your authentication adapter's `getNonce` and `verify`. ### 3. Verify it on the server [#3-verify-it-on-the-server] `verifyWalletSignIn` from `@domino-sdk/relay/server` checks the message without calling any network. It refuses it unless its domain is your origin's host, its nonce is the session's, it is no older than 10 minutes and not past its expiry, its chain is one of `chains`, and the signature is the address's. ```ts // app/api/wallet/verify/route.ts import { isWalletSignInError, verifyWalletSignIn } from "@domino-sdk/relay/server" import { appSession } from "./your-app-session" export async function POST(request: Request) { const session = await appSession() const { message, signature } = await request.json() const nonce = session.nonce // Clear the nonce before checking, so a refused message can't be retried with it. session.nonce = undefined await session.save() if (!nonce) { return Response.json({ error: "Ask for a nonce first" }, { status: 400 }) } try { const signedIn = await verifyWalletSignIn({ message, signature, origin: process.env.APP_ORIGIN!, nonce, chains: ["eip155:1", "eip155:8453"], }) session.wallet = signedIn.account await session.save() return Response.json({ address: signedIn.account.address }) } catch (error) { if (isWalletSignInError(error)) { return Response.json({ error: "Sign-in failed", code: error.code }, { status: 401 }) } throw error } } ``` `APP_ORIGIN` is the exact origin of the app, such as `http://localhost:3000` or `https://booth.example.com`, the one the app login key names. > [!DEV] What verifyWalletSignIn answers > It resolves `{ account, accountId, chainId, issuedAt, expiresAt }`: `account` is the wallet as `authenticate` returns it in `accounts` (`{ kind: "wallet", chain: "eip155", address }`, the address EIP-55 checksummed), and `accountId` its CAIP-10 ID, such as `eip155:8453:0xAb…`. A refused message throws `WalletSignInError` with `code` set to `invalid-message`, `wrong-domain`, `wrong-nonce`, `expired`, `not-yet-valid`, `unsupported-chain` or `invalid-signature`; `isWalletSignInError(error)` recognizes it. `maxAge` changes the 10-minute limit, in milliseconds. > > It checks signatures from wallets with their own key. A smart-contract wallet (ERC-1271), such as a Safe, signs through its contract, which this check cannot read, so its sign-in is refused with `invalid-signature`. ## Sign in with Solana [#sign-in-with-solana] Solana wallets that support Sign-In with Solana build and sign the message themselves: call the Wallet Standard `solana:signIn` feature with `domain`, `nonce`, `uri` and `issuedAt`, then send the signed message as text and the signature to the same verify route. Pass the signature's bytes or base58 as `signature`, and the Solana clusters you accept as `chains`, such as `["solana:mainnet"]`. A message without a `Chain ID` line means mainnet. `account` is then `{ kind: "wallet", chain: "solana", address }`. ## Connect Domino [#connect-domino] Mount `createParticipantBackend` in a catch-all route handler under `/relay`. Its `authenticate` reads the wallet from your session, signs the member in under it, and vouches for the wallet so quests that read a member's wallet see it. ```ts // app/relay/[...path]/route.ts import { createParticipantBackend } from "@domino-sdk/relay/portal-proxy" import { appSession } from "./your-app-session" const participant = createParticipantBackend({ baseUrl: "https://relay.domino.run", origin: process.env.APP_ORIGIN!, integrationKey: process.env.APP_IDENTITY_KEY!, async authenticate() { const { wallet } = await appSession() return wallet ? { subject: `${wallet.chain}:${wallet.address}`, accounts: [wallet] } : null }, }) export { participant as GET, participant as POST } ``` In the browser, use `createBrowserRelayClient({ baseUrl: "/relay" })`, or `RelayProvider` with it in React, as [app login](/docs/build/app-login#3-connect-the-browser) describes. After your verify route answers, and after your app signs out, call `useSession().refresh()`. The subject is your app's user ID for this person, here the wallet itself, so the same wallet always signs in as the same member. ## Pay prizes to the wallet [#pay-prizes-to-the-wallet] Domino attaches the wallet your server vouches for as a vouched wallet: quests and trackers count it right away, but a leaderboard's [payout list](/docs/build/leaderboards#export-the-payout-list) leaves it out, because Domino didn't see the wallet sign anything itself. When a prize goes to the wallet, have the member link it once more through Domino. Call `relay.wallets.link(signer)` from the browser client, or `link` from `useWallets()`, with the same wallet. The wallet signs Domino's own message for your page's domain, and the wallet becomes one the member proved, which payout lists include. > [!DEV] A signer for a wagmi or viem wallet > `link` takes a `WalletSigner`. For a viem wallet client, return `{ chain: "eip155", address: async () => address, sign: async (message) => ({ signature: await wallet.signMessage({ account: address, message }) }) }`, or use `discoverEvmWallets()` from `@domino-sdk/relay/browser`. See [link a wallet](/docs/build/wallets#link-a-wallet). ## Develop on localhost [#develop-on-localhost] A **Test** app login key accepts a loopback origin as well as HTTPS: `http://localhost:`, `http://127.0.0.1:` or `http://[::1]:`. Create one for `http://localhost:3000`, set `APP_ORIGIN=http://localhost:3000`, and `next dev` signs members in to your project's **Test** environment against real Domino. * **Live keys need HTTPS.** Console refuses a loopback origin on a **Live** key: "Live app login keys need an HTTPS origin. Loopback origins such as [http://localhost:3000](http://localhost:3000) work only on Test keys." * **Each port is its own origin.** A key for `http://localhost:3000` refuses requests from `http://localhost:3001`. Create one key per address the app runs at. * **Other addresses on plain HTTP are refused.** Console answers "Use an HTTPS origin, or a loopback origin such as [http://localhost:3000](http://localhost:3000) for a Test key." Reach the app through a tunnel with an HTTPS address, and create a key for that address, to test from a phone. When you deploy, create a **Live** key for the deployed HTTPS address and set `APP_ORIGIN` to it. # Simulate a campaign (/docs/build/simulate) A simulation lets you try a campaign before it touches a real project. You paste the campaign's quests and point ledgers into Playground, and Domino builds it and publishes it to a throwaway project of its own. * **Your projects stay as they are.** The simulation is a separate project in Domino's sandbox, in its **Test** environment. Nothing reaches your projects until you choose **Make this real**. * **It's yours alone.** Only you, signed in to the same organization, can open your simulations. Teammates don't see them. * **It's deleted after 24 hours.** Each simulation is deleted 24 hours after it starts, and you can't open it after that. ```mermaid flowchart LR Paste["Paste the campaign"] --> Build["Build"] Build -->|builds| Simulation["Published to the simulation"] Build -->|fails| Error["Error shown"] Simulation --> Run["Run personas"] Run --> Results["Who got paid, rejects, cap hits, leaderboard"] Simulation --> Real["Make this real"] Real --> Test["Your project, in Test"] ``` ## Simulate [#simulate] 1. Open **Developers → Build** with the **Test** environment selected, then choose **Simulate** beside **Map**. You need publish permission in **Test**, as for the rest of [Playground](/docs/build/playground). 2. Edit the campaign in the editor. It starts with a small example: a point ledger and three quests. 3. Choose **Simulate**, or press Ctrl+S (⌘S on a Mac) in the editor. The build can take a few minutes. Its state shows below the editor: **Queued**, **Building**, then **Published** once the campaign is live in the simulation, or **Failed**. The simulation's deletion time is shown beside it. Simulate again after an edit and Playground reuses the same simulation while it has more than 30 minutes left and you haven't [run personas](#run-personas) in it; otherwise it starts a new one. > [!NOTE] > You can start 20 simulations and 60 builds an hour. The editor and your latest build are remembered in this browser, so they're still there when you come back. ## When the campaign doesn't build [#when-the-campaign-doesnt-build] A failed build shows **The campaign didn't build** with the error from the step that failed, such as a type error with its line number. **Build logs** opens the full log. Fix the campaign in the editor and choose **Simulate** again. > [!DEV] > The campaign is one TypeScript module in the campaign starter's authoring style. Each top-level export made with `defineQuest`, `defineQuestType`, `defineLedger`, `defineTracker`, `defineLeaderboard`, `defineReferral`, `defineBadge`, `defineOffer`, `defineEngagementReward` or `defineDashboard` from `@domino-sdk/relay/authoring` becomes part of the campaign; other exports are ignored. The module may import only packages the campaign starter installs: a relative path, an absolute path, a `node:` module or any other package fails the build before anything compiles. A module that exports no definition fails with what to export. The module can be up to 256 KB. ## Run personas [#run-personas] Once the simulation shows **Published**, **Run personas** sends generated members through the campaign, day by day, the way real members would use it. Their actions go through Domino's real pipeline: the same checks, caps, budgets and rewards as in your project, with points paid into the simulation's own ledgers. 1. Set **Days**, from 1 to 30. It starts at 7. 2. Set **Members per persona**. It starts at 5. A run has at most 500 members in total. 3. Choose **Run**. A bar shows the simulated days done. A run takes longer the more members and days it has. | Persona | What its members do | | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | **Power user** | Act on every quest every day, three times a day on quests that can be completed more than once, well past every filter. | | **Casual member** | Act on each quest once, every other day, with just enough to pass its filters. | | **Bot-like spammer** | Flood repeatable quests with the same minimal post or answer, past any daily cap, and repeat one-time quests. | | **Just under / just over a cap** | One persona on each side of a member's daily or lifetime cap: one stays under it, one tries once more than it allows. | | **Just below / at a filter** | One persona on each side of a number a tracker quest's filter compares, such as at least 10 likes. | | **One short of / reaches a goal** | One persona on each side of a progress quest's goal for a day or the whole run. | | **Real X authors** | The real people behind a sample of posts from each X search tracker, posting what they posted. See [Real X posts](#real-x-posts). | The campaign gets a threshold pair for each cap, filter number and goal it has, up to 10 pairs. Each member is enrolled in every quest and campaign that requires enrollment as soon as it's created. If that's refused, for example because the campaign has ended, the run stops and shows why. How members act on each kind of quest: * **Quests members submit** with no answer, a text or a link: they submit, and the quest's own check decides. * **Quests a teammate confirms** are passed as a reviewer would pass them. Caps, budgets and rewards still apply. * **X and Discord tracker quests** get a post, message or reaction from each member. Members get their accounts before their first record, so nothing counts as having happened before they linked. * **Custom tracker quests** whose records name members get a record from each member. * **Quests that complete by themselves** once others are done follow from the other quests. Photo and quiz quests, Telegram, YouTube, TikTok and wallet trackers, and quests that pay an account other than the author are listed under **Not simulated**. At the end of each simulated day, every member's clock moves one day forward, so daily caps and goals start again. ### Real X posts [#real-x-posts] A campaign with an X search tracker is also tried on real posts. Before its members are created, the run reads one sample of each X search tracker's search: up to 100 matching posts from the last 7 days. * **Each author becomes a member.** When a quest pays the authors of a tracker's posts, every author in its sample becomes a **Real X authors** member, named by their handle and linked to their X account before any of their posts is sent, so none counts as having happened before they linked. * **Their posts are replayed in order.** Each post is sent to the tracker as that author's post, with its text and numbers, oldest first. The sample's week is spread over the run's days, and an author sends at most 12 posts a day to one tracker. * **The sample is read once.** Later runs in the same simulation replay the same posts and read nothing more from X. Samples don't count toward your organization's X usage. Authors past the run's 500 members are left out, and Playground says how many. If a sample can't be read, for example because X search is busy, **Not simulated** says so and the run goes on without it; the next run tries again. The results add a **Real X posts** section: each sample and its query, how many authors got paid, the replayed posts a quest's filter passed over, or that Domino or the quest rejected or refused, and why, and **Estimated X reads a day**. That estimate is what the trackers would read from X on each plan, against the plan's monthly X budget: how often each tracker searches on that plan (the interval your plan allows, as a deploy warns when a tracker asks for less), the pages a search needs for the posts its sample found, and the re-reads of [posts it already knows](/docs/build/trackers#how-often-domino-reads-x). ### Results [#results] When the run finishes, Playground shows: * **By persona**: points paid, attempts accepted, attempts rejected or refused, and cap hits, with a total row. A campaign with tracker quests also shows records skipped and records a quest's filter passed over. * **Rejects and their reasons**: each reason Domino or the quest gave, the quest, and how many times. Cap and budget hits read **Budget exhausted**. * **Leaderboard**: the top 10 members of each point ledger after any simulated day you choose. * **Real X posts**: what the [sampled X authors](#real-x-posts) earned and the X reads a day the campaign's trackers would make, when it has an X search tracker. * **Not simulated**: the quests the run left out, and why. Each day, members act in rounds, and each round waits for its attempts to be checked before the next. Attempts still being checked two minutes into the wait are counted as undecided, and Playground says so. > [!NOTE] > You can start 10 runs an hour, and one at a time per simulation. A new run first removes the last run's members, so every run starts fresh. If they can't be removed, for example because one of their completions spent a budget the whole project shares, the run stops and says so: choose **Simulate** to start a new simulation and run it there. > [!DEV] > **Run** sends `POST /management/v1/playground/simulations/:simulation/runs` with the published `build`, `days` (1–30, default 7), `membersPerPersona` (default 5) and an optional `actionId`, from a Console session only. It answers `202` with the run; `GET .../runs/:run` reads its `status` (`running`, `completed` or `failed`), `daysDone`, `personas` and, once completed, `results`. A plan with more than 500 members is refused with `validation-failed`, a second run while one is running with `conflict`, and a build that hasn't published to the simulation with `conflict`. A retry with the same `actionId` answers the run it started. Members are test members of the simulation's project, acting through the same test-member operations as `domino test-members`. A sampled author's member is linked with `{ kind: "x", subject }`, their numeric X user ID, as `domino test-members link-account --kind x --subject` does, and their posts are sent as its records. `results.x` holds the samples, `authors`, `authorsPaid`, `posts` and `reads`, the X reads a day per tracker and plan; it's `null` without an X search tracker. The `x-authors` persona's tally is what the replayed posts earned. ## Make this real [#make-this-real] When the simulation shows **Published**, **Make this real** publishes the same definitions to a project of yours. 1. Under **Make this real**, choose the **Project**. The list shows the projects in this organization you can publish to. 2. Choose **Make this real**, check the summary, and choose **Publish to test**. 3. Choose **Open deployments** to see the new deployment in that project. The definitions are published to the project's **Test** environment the same way a deploy publishes them: a quest or ledger with the same key is updated, and the rest of the project stays as it is, including which kinds of quest its app shows and its collections. The simulation's members, points and records stay behind. To take the campaign live, [promote](/docs/concepts/promotion) it from **Deployments** as usual. If the campaign has a quest of a kind the project's app doesn't show, such as a quiz, publishing is refused and nothing changes. Add support for it to the app first, or take that quest out and simulate again. > [!DEV] > Make this real sends `POST /management/v1/playground/simulation-copies` with the simulation and build IDs, selecting the target project and the `test` environment. It needs a Console session and publish permission in that project's **Test**; a live environment is refused with `test-environment-required`. It answers the new deployment's ID. It publishes like `domino deploy --only`: the project keeps its own `supportedInteractions` and collections, and a quest whose interaction they leave out is refused with `validation-failed`. A retry with the same `actionId` answers the first publication. # Stage and share (/docs/build/staging) Staging is a private copy of your hosted app on test data, at one address that never changes: `https://--staging.domino.page`. Use it to show your team or a client what's coming before it goes live. It stays online when nobody is working on the app, unlike a [preview from your own computer](/docs/build/development). * **Members don't see it.** By default only people with access to the project in Console can open staging. You can add a password for reviewers outside Console. * **It is what goes live.** A [promotion](/docs/concepts/promotion) carries the build serving staging to your live app. * **What you can do in Console.** Stage a [Playground](/docs/build/playground) checkpoint with **Stage**, set a password under **Settings → Staging access**, follow builds under **Deployments**, and try quests as a test member with the test toolbar. Each staging build comes from an exact commit pushed to your project's repository. `domino stage` builds it in Domino's isolated builder and, when the build succeeds, makes staging serve it. A failed build changes nothing: the previous one keeps serving. ```mermaid flowchart LR Push["git push"] --> Stage["domino stage"] Stage --> Build["Install, check, build, pack"] Build --> Deploy["Deploy and activate"] Deploy --> URL["Staging URL"] Build -. "fails" .-> Previous["Previous build keeps serving"] ``` > [!NOTE] Staging needs a pushed commit > Staging requires a hosted repository for the selected project and a pushed commit. A working connected development preview does not create that repository. Member sign-in diagnostics are separate from staging readiness; missing sign-in configuration does not prevent an app build. ## Stage a commit [#stage-a-commit] ### 1. Integrate, check, and push [#1-integrate-check-and-push] For Domino-hosted projects, use `main` for the team's accepted source and a work branch for each change. Fetch the latest `main`, preserve your existing work, and reconcile any newer team changes. Check and build the integrated result, merge through your repository's review process, and push to `main` before updating shared staging. When resuming Playground or a local checkout, compare it with remote `main` and the current staging commit from `domino builds --json`. If previously staged work is absent from `main`, reconcile the histories first. Follow the [source and release workflow](/docs/reference/hosted#source-and-release-workflow) for the complete procedure. This is a workflow convention, not an enforced CLI restriction. `domino stage` builds a pushed commit; it does not commit, push, merge branches, or update Playground. Branch names do not select environments. Development and staging currently share test data and behavior definitions. ### 2. Stage the pushed commit [#2-stage-the-pushed-commit] ```sh pnpm exec domino stage --commit --json ``` In a terminal, `domino stage` shows the target project, test environment, API URL, and full commit SHA before submitting the build. Press Enter to cancel without starting a build. Use `--yes` to skip confirmation, or keep `--json` for scripts. Use the SHA of the integrated commit pushed to `main`. A short SHA works: stage resolves it to the full commit in your checkout and fails with Git's reason when it is unknown or ambiguous. A full SHA is sent as given, so your checkout does not need it. Without `--commit`, stage defaults to local `HEAD` and requires a clean working tree. If `main` advances before submission, reconcile and validate the newer result. Stage builds the exact pushed commit: a commit the project's repository doesn't have, mistyped or not pushed yet, is refused before any build starts (`REJECTED`, `apiCode: "commit-not-found"`). Push it, then stage again. If a submission loses its response, retry with the original full commit SHA and request ID shown by the CLI. Before submitting, stage warns about what the staged app will lack, without blocking the build: required [app secrets](/docs/build/hosted-app#set-app-secrets) that are not set, and [wallet sign-in](/docs/build/wallets#sign-in-with-a-wallet) that is off in test while the app signs members in with a wallet, and [profiles](/docs/build/profiles#turn-profiles-on) that leave every leaderboard row with a generated name such as `Participant 1a2b3c4d`. Each warning names the command that fixes it. With `--json`, the result lists missing secrets in `missingSecrets` and the wallet sign-in and leaderboard name warnings in `warnings` (`wallet-sign-in-off`, `generated-leaderboard-names`). Once the build is ready, stage also prints the [deployment warnings](/docs/build/publishing#1-validate-the-remote-deployment) of the quests and other resources it published to test, and `--json` adds them to `warnings`. For example, quests whose files you deleted stay published until you [archive them](/docs/build/publishing#archive-a-quest), and stage names them under `quests-not-in-bundle`. ### 3. Follow the build [#3-follow-the-build] ```sh pnpm exec domino builds --json pnpm exec domino logs BUILD_ID --json ``` * **Wait or not.** `domino stage` waits for the build and fails with `STAGE_BUILD_FAILED` if it fails. `--no-wait` returns the build ID right after submission. * **Pick a build.** Run `domino logs` to select a recent build from its status, time, commit, and ID. Explicit `domino logs BUILD_ID` skips the build picker. * **Pick a project.** Without a configured project, `builds` and `logs` offer a project picker first. * **Output.** Logs print as text by default; `--json` returns the build ID and log text together. ### 4. Share the staging URL [#4-share-the-staging-url] Staged builds use `https://--staging.domino.page`. The address stays the same across builds. Addresses from before the move to `domino.page`, `https://staging-.domino.run`, no longer serve: they answer `410 Gone` with a page naming the new address, and do not redirect. Share the URL returned by the successful build. A failed build leaves the previous preview active. Staging uses test data and remains online after local development stops. Without `--json`, `domino stage` prints the URL with a reminder of who can open it. > [!WARNING] Don't print staging links > Staging is for review: only visitors with staging access can open it, and a slug change moves its address. For QR codes, stickers, and anything else you print or publish, use the [live app's address](/docs/concepts/hosting#what-domino-hosts) or its [custom domain](/docs/concepts/hosting#custom-domains), and route short paths such as `/r/` inside your app. ## Control who can open staging [#control-who-can-open-staging] Staging is never public. By default, visitors sign in with Domino Console and need access to the project in **Test**. Console's **Open app** links sign you in directly. The same holds for the app's member API: requests that name the staging address answer only for browsers that signed in to staging and call through the staging address, so a script calling the API directly with the staging origin gets `staging-access-required`. To share with reviewers outside Console, open **Settings → Staging access**, choose **Password**, and save a password of at least 8 characters. Visitors then enter it on the staging page; Console members can still sign in instead. | Staging access | Who can open staging | Ends when | | ----------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------- | | **Console sign-in** (default) | People with read access to the project in Test. | The session expires after 12 hours. | | **Password** | Anyone with the password, plus Console members. | The session expires after 12 hours, or the password changes or is removed. | > [!DEV] Set the staging password from the CLI > `PUT /management/v1/staging/access` sets the same policy as Console and needs `publish`: write `{"mode":"password","password":"<8-128 characters>"}` to a file such as `access.json`, which keeps the password out of shell history, and run `domino api PUT /staging/access access.json --environment test`. A body of `{"mode":"console"}` returns to Console sign-in only, and `domino api GET /staging/access --environment test` reads the current mode. Changing staging access requires publish permission. Password attempts are limited to 10 per IP address (an IPv6 address counts by its /64) and 100 per project in 5 minutes, right or wrong; past that, attempts are refused for a few minutes. A staging session belongs to the address that issued it and does not carry over to another one, such as after a slug change. ### Try quests with the test toolbar [#try-quests-with-the-test-toolbar] People who signed in with Console and hold publish permission see the [test toolbar](/docs/use/quests#try-quests-on-your-staging-site) on staging. With it you can act as a test member, move them to the next day, pass their quests, follow their activity, send tracker records, and link wallets and X or Discord accounts to them. Everyone else sees nothing new, including visitors who signed in with the staging password. Your coding agent can use the toolbar too, to check the app's screens as a test member. It opens staging with a one-time staging link minted by your `domino login` session, your API key, or an organization automation key, and acts as you, or as the automation key. > [!DEV] Staging links for agents > `POST /management/v1/staging/link` (`domino api POST /staging/link body.json` with `{ "path": "/" }`) returns a URL that opens staging for one minute. The link names an operator when a Console or CLI session minted it, or an API key or organization automation key that itself holds `publish`. On every toolbar request, Domino checks again that the operator holds `publish` in **Test** and that the key behind the link is still active, so revoking the key ends the toolbar at once. Toolbar actions are audited as the person or as `automation:`. Hosted app keys, service keys, and Claude or ChatGPT connections get a link that only views staging. With `preview`, the same route opens a running `domino dev` preview instead ([Act as a test member on the preview](/docs/build/development#act-as-a-test-member-on-the-preview)). The agent steps are under "Check participant flows on staging" in the Domino skill's campaign testing reference (`references/campaign-testing.md`). > [!DEV] Keep bottom bars clear of the toolbar > The toolbar's collapsed bar sits in the bottom-right corner, over your app. While it is on the page, `` carries the `data-domino-staging-toolbar` attribute and the `--domino-staging-toolbar-inset-bottom` custom property: how far the collapsed bar reaches up from the bottom of the screen, in px. It follows the bar's size and the screen's, and opening the toolbar doesn't change it. **Hide toolbar** in the toolbar's `⋯` menu hides it for the browser tab, leaving a small square marker in the corner to bring it back; while it is hidden, the property is `0px` and the attribute is gone, so the page lays out as on live. Use the property to lift a fixed bottom bar, such as a phone tab bar, above the toolbar. Live has no toolbar, so the `0px` fallback applies there: > > ```css > nav.tab-bar { > padding-bottom: calc(env(safe-area-inset-bottom) + var(--domino-staging-toolbar-inset-bottom, 0px)); > } > ``` > [!DEV] Reserved paths > Staging reserves the `/__domino/staging-access` path for sign-in and removes its session cookie before requests reach your app. It also adds a script, `/__domino/toolbar.js`, to every HTML page it serves, and reserves that script and the `/__domino/toolbar/` paths it calls, and `/__domino/environment`, which tells your app it is running on staging ([Tell test from live](/docs/build/hosted-app#tell-test-from-live)). ## What a build does [#what-a-build-does] Each build runs in a fresh, isolated sandbox with no Domino or Cloudflare credentials: | Phase | What happens | | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Install | `pnpm install --frozen-lockfile`; `packageManager` must pin an exact pnpm 10 version, such as `pnpm@10.18.0` | | Check | Your `checkScript` | | Build | Your `buildScript`, which emits the app and the `.domino-build` resource artifacts | | Pack | Collects the output, enforces the [output limits](/docs/build/hosted-app#build-outputs), and stages your quests and catalog | | Deploy | Binds [secrets](/docs/build/hosted-app#set-app-secrets), the [management key](/docs/build/hosted-app#call-the-management-api-from-your-server), and [storage](/docs/build/storage), applies SQL migrations, and uploads the app | | Activate | Switches staging to the new build and activates its quests, catalog, trackers, engagement, leaderboards, and referrals in test | * **Build environment.** Scripts see only `CI=true`. Live serves the same build, so nothing in the build says test or live; check at runtime instead ([Tell test from live](/docs/build/hosted-app#tell-test-from-live)). App secrets, the management key, and storage reach the deployed Worker, never the build. * **Time limits.** Install has 5 minutes, and check, build, and pack 3 minutes each. The whole build step has 15 minutes and is retried once. The pushed source may be at most 20 MB. * **One build at a time.** A project accepts a new build only after the running one finishes, or after 30 minutes. * **All or nothing.** A failed build changes nothing that serves: the previous build, its routing, and its resources stay active. SQL migrations are the exception; see [Write migrations](/docs/build/storage#write-migrations). * **Immutable.** Every build is fixed at its commit. Changing secrets, settings, or `relay.json` takes effect on the next stage. ## Follow builds in Console [#follow-builds-in-console] Select your project and the **Test** environment, then open **Deployments** under Developers. The page shows the latest 30 hosted app builds, including failed attempts. | Element | What it shows or does | | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **All**, **Ready**, **Building**, **Failed** | Filter the list by build status. | | Screenshot | A capture of the app after a successful build, when one could be taken. | | **Current** | Marks the build actually serving your test app, even when a newer build fails. | | A build | Its commit message when available, short commit SHA, publisher, creation time, total duration, and build steps. Progress and logs update automatically while commands run. Failed builds open their logs automatically. | | **Copy** | Copies the build output for troubleshooting. Logs retain the latest 500,000 characters, including automatic build retries. **Find in logs** searches them. | | **Open app** | Opens the current test app. Older builds do not have separate preview links. | | **Reconnect** | Appears if updates disconnect. Console keeps the last received data visible. | Creating a project from a starter usually stages its first build automatically: the untouched starter, with its sample resources in test. If no build appears, run `domino stage`. Start every later build with `domino stage`. Console does not retry, cancel, roll back, or publish apps to production. Quest Studio publications are separate from this hosted app build history. ## Where next [#where-next] - [Configure a hosted app](/docs/build/hosted-app) — app settings, routing, secrets, and build outputs. - [Publish changes and go live](/docs/build/publishing) — publish quests and other resources to test and live. - [Troubleshooting](/docs/reference/troubleshooting) — build and configuration failures. # Store app data (/docs/build/storage) When Domino hosts your app, the app can keep data of its own: giveaway entries, a list of event check-ins, a cache of rendered pages. Your developer names the stores the app needs, and Domino creates them in test and in live. Your team can browse what's in them from Console. * **Test and live are separate.** Test data never reaches live, and live gets its own empty stores when you go live. * **Your team can look, and clear test data.** Console shows the data read-only. In test, your developer can empty tables or delete keys to start a test run over. Live data changes only through your app's code. > [!DEV] > A hosted Worker app can keep its own data in Cloudflare KV namespaces and D1 SQL databases. You declare stores by name in `relay.json`; Domino creates them in each environment that runs the app and binds them to every build there. Staged builds get test's stores, and the live app gets live's. Your server code uses Cloudflare's native binding APIs, so there is no Domino wrapper to learn. ```mermaid flowchart LR Stage["domino stage"] --> Provision["Create or reuse stores"] Provision --> Migrate["Apply new SQL migrations"] Migrate --> Upload["Upload the Worker with bindings"] Upload --> Active["Staging serves the new build"] ``` > [!NOTE] Separate data per environment, Worker apps only > Test and live each have their own stores. Live's are created when a build that declares them is promoted to live, and data never copies between environments. Static apps cannot declare stores. Storage needs `@domino-sdk/relay` and `@domino-sdk/relay-cli` 1.2.0 or later: older versions reject `app.storage`. ## Add a store [#add-a-store] ### 1. Declare it in relay.json [#1-declare-it-in-relayjson] Add a `storage` property to the `app` object of a Worker app. `kv` declares KV namespaces and `sql` declares D1 databases, each by its binding name. ```json { "app": { "kind": "worker", "storage": { "kv": { "CACHE": { "description": "Rendered page cache" } }, "sql": { "DB": { "description": "Campaign data", "migrations": "migrations" } } } } } ``` * **Names, not IDs.** Domino creates and tracks the Cloudflare resources. Never put a Cloudflare resource ID in `relay.json`. * **Migrations are optional.** `migrations` names a directory of `.sql` files, relative to the repository root. Only SQL stores have one. * **Descriptions show in Console** and in `domino storage list`. ### 2. Type the bindings [#2-type-the-bindings] Your Worker receives `env.CACHE` as a `KVNamespace` and `env.DB` as a `D1Database`. Add `@cloudflare/workers-types` as a dev dependency and import the types into `server/env.ts`: ```ts import type { D1Database, KVNamespace } from "@cloudflare/workers-types/index.ts" export interface Env { CACHE: KVNamespace DB: D1Database } ``` Import from `index.ts` instead of adding the package to `types` in `tsconfig.json`. The starters include the DOM library, which conflicts with the package's global declarations, and the build runs your check script before it stages anything. If your tests build `Env` objects, as the API starter's do, they stop type-checking once these bindings are required. Give the tests stub bindings, or declare the bindings optional (`CACHE?: KVNamespace`) and answer 503 from routes that need them when they are missing. ### 3. Mirror the bindings for local development [#3-mirror-the-bindings-for-local-development] `domino dev` runs your app on your device, where the Cloudflare Vite plugin reads `wrangler.jsonc`, not `relay.json`. Declare the same binding names there: ```jsonc { "kv_namespaces": [{ "binding": "CACHE", "id": "local-cache" }], "d1_databases": [{ "binding": "DB", "database_name": "local-db", "database_id": "local-db", "migrations_dir": "migrations" }] } ``` The IDs are local placeholders, and local data stays on your device, separate from hosted test data. `domino dev` does not apply migrations, so run them yourself after adding a file: ```sh pnpm exec wrangler d1 migrations apply DB --local ``` `domino dev` and `domino stage` warn when `wrangler.json` or `wrangler.jsonc` and `relay.json` disagree on binding names or migration directories, or when Wrangler uses `migrations_pattern`. The warnings never stop the command. ### 4. Stage [#4-stage] Commit, push, and [stage](/docs/build/staging) the app. The build creates any new store, applies new migrations, and binds every declared store before staging serves the new build. The store appears in `domino storage list` and Console once that build is active. When a promotion carrying the build is approved, the live deployment does the same against live: it creates live's stores, applies migrations to live's databases, and only then switches the live app to the build. See [How test and live hosting relate](/docs/concepts/hosting#how-test-and-live-hosting-relate). ## Write migrations [#write-migrations] Hosted builds track migrations the way Wrangler does: in a `d1_migrations` table, ordered by numeric filename prefix and then filename. The same files therefore work with `wrangler d1 migrations apply --local`. * **One file per change.** Never edit or rename a file that has been applied; add a new one. * **Flat directory.** Put `.sql` files directly in the migrations directory. Nested layouts such as Drizzle's `0000_name/migration.sql` fail the build; point Drizzle at a flat output directory. * **Each file is atomic.** A file runs as one D1 request together with its tracking row, so a failing file rolls back on its own and files before it stay applied. There is no whole-build rollback. > [!WARNING] Migrations run while the previous build still serves traffic > The builder applies migrations before the new Worker activates. Every migration must work with the build that is serving now. Dropping a column or changing what it means can break the serving staging or live app before the new build replaces it. ### When a migration fails [#when-a-migration-fails] A failed migration fails the build without retrying, and the previous build keeps serving. In live, it fails the live deployment and the previous live build keeps serving; fix the file, then stage and promote again. The error names the store, the file, and D1's own message: ```text SQL store DB migration 0003_add_score.sql failed: near "CREAT": syntax error at offset 0: SQLITE_ERROR ``` Fix the file and stage again. If D1 itself was unavailable, the build retries on its own, and a file whose response was lost after it committed is never applied twice. | Message | Cause | Fix | | ---------------------------------------------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | `… failed: ` | The file's SQL is invalid or conflicts with the database | Fix the file and stage again | | `… ran but did not record itself` | The file ends inside an unterminated `/* …` comment, which swallowed the tracking row | Close the comment and stage again | | `Migrations for DB must be .sql files directly in …` | The directory holds folders instead of `.sql` files | Flatten the directory | | `Migrations directory … does not exist` | `migrations` names a missing path | Fix the path in `relay.json` | | `This project can own at most 10 SQL stores …` | The project already owns 10 stores of that kind | Reuse a name, or [delete an unused store](#remove-or-delete-a-store) | ## Browse your data [#browse-your-data] Open **Settings → Storage** in Console with **Test** or **Live** selected. The list shows every store in that environment, its description, and whether the serving build still declares it. * **SQL stores.** **Browse** opens the first app table 50 rows at a time. Pick another table or view from the picker, and select a row to see every column. Under a cut value, **Show full value** reads it in full, 100,000 characters at a time. * **KV stores.** **Browse** lists keys 100 at a time. Search by key prefix, and select a key to see its value. Browsing is read-only and needs `publish`, because an app's data can hold what its members told it. People with only `read` see the list of stores. Rows and KV values cut text longer than 2,000 characters; read a cut SQL value in full with a cell read. BLOBs and binary values show only their size. The CLI and management API offer the same reads. `domino storage` uses test unless you pass `--environment live`; it ignores `RELAY_ENVIRONMENT` and the environment in `relay.json`, and warns when either names live. Its `--json` result says which environment it used in `environment`. | Read | CLI | Management API | | --------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------- | | Stores | `domino storage list` | `GET /management/v1/storage` (`read`) | | SQL tables and views | `domino storage tables DB` | `GET /management/v1/storage/sql/DB/tables` | | 50 rows | `domino storage rows DB entries --cursor ` | `GET /management/v1/storage/sql/DB/rows?table=entries&cursor=…` | | One SQL value in full | `domino storage cell DB entries 51 body --all` | `GET /management/v1/storage/sql/DB/cell?table=entries&row=51&column=body&cursor=…` | | 100 KV keys | `domino storage keys CACHE --prefix page:` | `GET /management/v1/storage/kv/CACHE/keys?prefix=page:&cursor=…` | | A KV value | `domino storage get CACHE page:/` | `GET /management/v1/storage/kv/CACHE/value?key=page:/` | > [!DEV] Reading from code > `createManagementClient` from `@domino-sdk/relay/server` has the same reads under `storage`: `list()`, `tables(name)`, `rows(name, table, { cursor })`, `cell(name, table, { row, column, cursor })`, `keys(name, { prefix, cursor })`, and `value(name, key)`. Each page of rows, keys or a value's characters has a `nextCursor`: pass it back as `cursor` (`--cursor` in the CLI) for the next page, until it is `null`. Leave `cursor` out for the first page. A cursor is opaque: pass back the one Domino returned rather than building your own. > [!DEV] Reading one value in full > A cell read names the row by its position in the table's storage order: the rows page's `offset` plus the row's index on that page, so row 51 is the second row of the page at offset 50. Rows written in between can shift that order, as they shift pages. Each page holds up to 100,000 characters from `offset`, with `type` (SQLite's `text`, `integer`, `real`, `blob` or `null`) and `length` in characters. A BLOB answers with its `length` in bytes and `text: null`. `--all` follows `nextCursor` and prints the whole value as one page. Every row a read touches counts toward the database's D1 usage, including the row count of the open table. Apart from [clearing test data](#clear-test-data), Domino has no write or export: change data with migrations or your own server code. ## Clear test data [#clear-test-data] Resetting or erasing test members removes only Domino's records of them. What your app stored about them stays: a ticket mapped to a member, an uploaded photo, a check-in. When a test run leaves such data behind, clear it in the test environment so the next run starts clean. Clearing never changes the schema, and live data can't be cleared. 1. **Count first.** `domino storage clear sql DB --table tickets --dry-run` answers how many rows each table holds, and changes nothing. 2. **Clear.** Run the same command without `--dry-run`. In a terminal it shows the counts and asks first. Unattended or with `--json`, it needs `--yes`. | Clear | CLI | | --------------------------- | -------------------------------------------------------------- | | Named SQL tables | `domino storage clear sql DB --table tickets --table progress` | | Every SQL table except some | `domino storage clear sql DB --all-tables --keep settings` | | KV keys under a prefix | `domino storage clear kv PHOTOS --prefix photo:` | | Every KV key | `domino storage clear kv PHOTOS --all-keys` | * **Rows, not tables.** A SQL clear deletes the rows of each table and keeps its columns, indexes, triggers and views. `d1_migrations`, which records your applied migrations, and SQLite's and D1's own tables (`sqlite_*`, `_cf_*`) are never cleared, even with `--all-tables`. A virtual table, such as an FTS5 full-text index, is emptied through itself, which keeps its internal shadow tables consistent; the shadow tables can't be named. Name a table exactly as it is spelled; an unknown table, including one given to `--keep`, answers 404 and nothing is cleared. * **All or nothing.** The tables of one SQL clear empty in one transaction. If a table you keep still references rows you clear, the whole clear fails and nothing changes; a foreign key with `ON DELETE CASCADE` deletes the referencing rows instead, as any delete would. * **Large KV stores.** The CLI deletes keys in batches of 10,000 until none remain under the prefix. A dry run counts up to 100,000 keys and says "at least" beyond that. * **Test only.** In live, every clear, dry runs included, is refused with `test-environment-required`. It needs `publish`, like deleting a store, and works on a store the serving build still declares. Each clear is recorded in the project's audit history with the tables and row counts, or the prefix and key count; a dry run is not. > [!DEV] > The management API is `POST /management/v1/storage/sql/DB/clear` with `{ "tables": ["tickets"] }`, or `{ "allTables": true, "keepTables": ["settings"] }`, and `POST /management/v1/storage/kv/PHOTOS/clear` with `{ "prefix": "photo:" }` or `{ "all": true }`. Add `"dryRun": true` to count only. A SQL clear answers `{ environment, kind, store, dryRun, tables: [{ name, rows }] }`, the row counts before the clear. A KV clear answers `{ environment, kind, store, dryRun, prefix, keys, complete }`: one call deletes at most 10,000 keys, so call again until `complete` is `true`. On a dry run, `complete: false` means `keys` stopped counting at 100,000. In code, `createManagementClient` has the same operation as `storage.clear("sql", "DB", { tables: ["tickets"] })` and `storage.clear("kv", "PHOTOS", { prefix: "photo:" })`. A refused name or flag combination answers 400, live 422 `test-environment-required`, and a missing store or table 404. Every row a clear counts or deletes counts toward D1 usage. ## Remove or delete a store [#remove-or-delete-a-store] * **Remove a declaration** from `relay.json` and stage. The next build no longer binds the store, but its data stays, and declaring the same kind and name again reuses it. Live keeps binding it until a build without it is promoted. * **Rename** by declaring a new name. That creates an empty store; the old one stays until you delete it. * **Delete** a store the serving build no longer declares, with all of its data, from Console's **Delete** action, `domino storage delete sql OLD_DB`, or `DELETE /management/v1/storage/sql/OLD_DB`. It needs `publish`. Add `--environment live` to delete a live store; a CLI sign-in session cannot delete in live, so use Console or an API key there. A declared store, or one in a project with a build still running, answers 409. Declaring a deleted name again creates an empty store. Organization transfers keep stores. Purging a deleted project deletes them; deleting a project only starts its restore window. ## Limits [#limits] | Rule | Limit or behavior | | ------------------- | -------------------------------------------------------------------------------------------------------------------------- | | Stores per build | Up to 5 KV namespaces and 5 SQL databases | | Stores per project | Up to 10 of each kind, counting stores kept after their declaration was removed | | Names | 1–64 uppercase letters, digits, or underscores, not starting with a digit | | Reserved names | `ASSETS` and every name beginning with `DOMINO_` | | Uniqueness | A name appears once across `vars`, declared secrets, KV, and SQL; a stored secret with the same name also blocks the build | | Migration directory | Relative to the repository root; no hidden segments, parent traversal, or `node_modules` | | Migration files | Up to 100 per store, 90,000 UTF-8 bytes each, 1,000,000 bytes across the build | | Statement duration | D1 stops any single statement after 30 seconds, including a migration's | Every build ships every migration file, so the file and size limits cover a store's whole history. When a long-lived store nears them, squash applied migrations into one file. Every hosted and local database must already have applied the files you squash. ## Where next [#where-next] - [Configure a hosted app](/docs/build/hosted-app) — routing, variables, secrets, and the management key your server code receives. - [Stage and share](/docs/build/staging) — build a pushed commit into the staging app. - [Hosted apps](/docs/concepts/hosting) — how previews, staging, and the live app fit together. # Telegram sign-in and rewards (/docs/build/telegram) If your community lives on Telegram, members can sign in with their Telegram account and earn rewards for what they do in your groups. For example: * **"Join our Telegram group"**, checked automatically. * **"Get a ❤ from an admin"** on a message you posted in the group. * **"Join the conversation"**, a daily reward for posting in the group. * **"@ana just received +10 XP by getting a ⚡ from @sam"**, posted in the group whenever an admin tips. * **Sign in without a password** inside your own Telegram Mini App. Everything runs through Domino's own Telegram bot. You never create a bot for your groups or handle a token. ## What members see [#what-members-see] * **A "Continue with Telegram" button** in your app on the web, called **Telegram Login**. * **Instant sign-in inside your Mini App.** A member who opens your Mini App in Telegram is signed in, or signed up, without a button. * **One account, either way.** Both reach the same Telegram account, so a member who signs in once each way is still one member. * **A "Connect Telegram" button** for members who signed in another way. * **Rewards for group activity**, such as joining a group, posting in it, or getting a reaction from an admin. * **A message in the group when someone earns**, if the quest announces it. Domino stores the member's numeric Telegram ID, name, @username and profile photo. It never asks for their phone number, and it never needs your bot's token. | | Telegram Login | Mini App sign-in | | -------------- | --------------------------------- | -------------------------------------------------- | | Where | Your app in a browser | Your Mini App, opened inside Telegram | | Bot | Domino's own bot | Your bot, the one your Mini App runs on | | Turned on by | Domino, for the platform | You, per project and environment, in Console | | What it proves | Telegram's OpenID Connect sign-in | Telegram's signature on the Mini App's launch data | ## What you can do in Console [#what-you-can-do-in-console] | Without code | Where | | ---------------------------------------------------------- | --------------------------------------------- | | Turn on sign-in inside your Mini App | **Settings → Sign-in**, **Telegram Mini App** | | Connect groups and channels, see their status, remove them | **Integrations**, **Telegram** | | Pick the group a quest announces in | The quest's **Settings** | | See a member's announcements, retry one that failed | The member's page, **Announcements** | | Unlink or relink a member's Telegram account | The member's page, **Identities** | The sign-in buttons, the join quest and reaction rewards come from code. The starters already show the Telegram Login button. > [!DEV] Telegram is not an `AuthProvider` in SDK 1.x > `AuthProvider` and the lists typed with it (`relay.auth.accounts()`, `useSignInOptions().options`, `useAccounts()`) stay as they were, so code that switches over providers keeps working. The open lists beside them include Telegram: `useSignInOptions().signInProviders`, `relay.auth.providers().signInProviders`, `relay.auth.signInAccounts()`, `useAccount("telegram")` and, for operators, `management.members.accounts(member)`. `relay.auth.start({ provider: "telegram", mode })` and `relay.auth.disconnect("telegram")` work too. What only Telegram does, Mini App sign-in, is `relay.telegram.miniApp` from `@domino-sdk/relay/telegram`, so apps that don't import it don't carry it. > > Telegram's older members still work through SDK 1.x and are removed in 2.0.0: `relay.auth.telegram.start`, `.account`, `.disconnect` and `.miniApp`, `telegramMiniAppInitData()` from `@domino-sdk/relay/browser` and `@domino-sdk/relay/react`, `useTelegramAccount()`, `useSignIn().withTelegram()`, `useSignInOptions().telegram`, `providers().telegram.login`, `management.participantSignIn().telegram.login`, and `management.members.telegramAccount` and `unlinkTelegramAccount`. Each one's documentation names its replacement. ## Offer Telegram Login [#offer-telegram-login] Telegram Login is available once Domino has turned it on for the platform. The starters' sign-in components already show the button, with the other providers'; in your own app, render one button per sign-in provider: ```tsx import { useSignInOptions } from "@domino-sdk/relay/react" export function ProviderButtons() { const signIn = useSignInOptions() return (signIn.signInProviders ?? []).map((provider) => ( )) } ``` The button takes the member to Telegram's consent screen, and Telegram sends them back signed in, or signed up. > [!DEV] > `useSignInOptions().signInProviders` lists every sign-in provider Domino configured, in Relay's order, with an entry `{ kind: "provider", id: "telegram", label: "Telegram" }` when Telegram Login is configured; it is `null` until the methods are read. A newer Relay can list a provider this SDK does not know, labelled with its name. Without React, `signInOptions(relay)` from `@domino-sdk/relay/browser` returns the same `signInProviders`; `relay.auth.start({ provider: "telegram", mode: "sign-in" })` returns the URL to navigate to. ## Sign in inside your Mini App [#sign-in-inside-your-mini-app] ### 1. Name your bot in Console [#1-name-your-bot-in-console] Your Mini App must be your app: in @BotFather, point the bot's Mini App URL at the address Domino serves your app on. Domino refuses sign-in from any other address. Open **Settings → Sign-in**, enter your bot's ID under **Telegram Mini App**, and save. The ID is the number before the colon in the bot's token, such as `123456789` in `123456789:AA…`. Test and Live each have their own setting. Clear the field to turn Mini App sign-in off. > [!IMPORTANT] The bot you name vouches for its users > Domino can't check that the bot is yours. A bot's owner receives the launch data of everyone who opens its Mini App, and that data signs them in to this project. Name only a bot your organization controls. ### 2. Sign in when the Mini App opens [#2-sign-in-when-the-mini-app-opens] Telegram opens your Mini App with signed launch data. Your app reads it and signs the member in, or up: ```ts import { createBrowserRelayClient } from "@domino-sdk/relay/browser" import { telegramMiniAppInitData, withTelegram, } from "@domino-sdk/relay/telegram" const relay = withTelegram(createBrowserRelayClient({ baseUrl: "/relay" })) const initData = telegramMiniAppInitData() if (initData && !(await relay.auth.session())) { await relay.telegram.miniApp.signIn(initData) } ``` Domino checks Telegram's signature for the bot you named, so launch data from another bot, or edited data, is refused. It accepts launch data at most five minutes old, and each launch signs in once. Check for a session first, as above, so a reload doesn't try again. > [!DEV] > `@domino-sdk/relay/telegram` is experimental. `withTelegram(relay)` adds `relay.telegram` to the client and answers the same client, so calling it again, for example on `useRelay()`'s client in React, is safe. `telegramMiniAppInitData()` reads the launch data, and `relay.telegram.miniApp.signIn(initData)` signs in. In React, `useSignIn().withTelegramMiniApp(initData)` does the same, with failures in its `error`. > > | Error code | Why | What to do | > | ------------------------- | ------------------------------------------------------ | ---------------------------------------------------- | > | `sign-in-method-disabled` | The project has no Mini App bot in this environment | Name the bot in Console | > | `invalid-signature` | Telegram didn't sign the launch data for that bot | Check the bot ID in Console | > | `challenge-expired` | The launch data is too old, or already signed in once | Ask the member to close and reopen the Mini App | > | `forbidden` | The request didn't come from your app's address | Point the bot's Mini App URL at your app | > [!WARNING] Telegram's rules for crypto Mini Apps > Telegram's [developer terms](https://telegram.org/tos/bot-developers#7-blockchain-integration) require Mini Apps with crypto features to be based only on TON. Check whether they apply to your project before you launch a Mini App. Telegram Login on the web is not a Mini App. ## Connect Telegram to a signed-in member [#connect-telegram-to-a-signed-in-member] A member who signed in another way connects Telegram from your app: ```tsx import { useAccount } from "@domino-sdk/relay/react" export function TelegramAccount() { const telegram = useAccount("telegram") if (telegram.isConnected) { return ( ) } return ( ) } ``` A Telegram account another member already uses can't be connected. > [!DEV] > `connect()` leaves for Telegram Login. Inside your Mini App, `relay.telegram.miniApp.connect(initData)` connects the user who opened it instead; then call `invalidate([relayKeys.accounts, relayKeys.experience])` from `useInvalidate()`, so the account and the quests that need it read again. `data` is the account: `status`, and `subject`, the numeric Telegram user ID. Without React, the account is the `"telegram"` entry of `relay.auth.signInAccounts()`; connect with `relay.auth.start({ provider: "telegram", mode: "connect" })` and disconnect with `relay.auth.disconnect("telegram")`. ## Connect a group or channel [#connect-a-group-or-channel] Domino's bot reads your chats as an administrator. ### 1. Start in Console [#1-start-in-console] Open **Integrations**, open the **Telegram** card, and choose **Add chat**. Console shows a one-time link that works for ten minutes. ### 2. Choose the chat in Telegram [#2-choose-the-chat-in-telegram] Open the link and start the bot. It first says which organization and project the chat will be connected to: your organization's name, the project's name, its slug and the environment. Check it, then choose **Choose a group** or **Choose a channel**. Telegram lists only chats you administer, and adds Domino's bot to the one you pick as an administrator. The bot confirms the chat it connected and the project again. Whoever opens the link connects the chat, so if you send it to another chat administrator, tell them which project to expect. ### 3. Return to Console [#3-return-to-console] The chat appears in the list once Domino has checked that you and the bot are both administrators. Connect as many chats as the project needs; Test and Live each have their own. Every Domino organization shares the same bot. If the bot already has an administrator right in the chat that you don't, such as posting messages in a channel, the bot replies with the rights you're missing and connects nothing. The chat's owner, or an administrator with those rights, connects it instead. | Status | Meaning | | ------------------- | ------------------------------------------------------------------------------- | | **Ready** | The bot is an administrator and reads the chat | | **Needs attention** | The bot was removed or is no longer an administrator; the issue says what to do | **Remove chat** disconnects a chat from the project and environment. The bot leaves the chat once no project uses it. > [!DEV] > The management client has the same actions: `management.telegram.chats()`, `connect()` and `remove(chat)`. Telegram is also a [plugin connection](/docs/plugins/telegram): `management.connections.get("telegram")` and `domino connections get telegram` say whether the environment has a chat (`ready`), has one that needs attention (`attention`, with the chat's issue), or has none (`disconnected`). Chats connect only through the bot, so `domino connections connect telegram` answers 409 `plugin-managed`. ## Check membership in a quest [#check-membership-in-a-quest] A join quest checks that the member is in one of your connected groups or channels. You pick the chat in Console from the ones you connected. The `telegram-membership` snippet is a complete join quest; install it as described in [Your first quest](/docs/build/first-quest). The member needs their Telegram account in your app, by signing in with it or connecting it. ```ts import { decision, defineQuest, manual, once, settings, } from "@domino-sdk/relay/authoring" export default defineQuest({ key: "join-telegram", title: "Join our Telegram group", settings: settings.object({ chat: settings.telegramChat({ label: "Telegram group" }), }), trigger: manual(), completion: once(), rewards: [], async evaluate(ctx) { // No chat chosen yet: nothing to check. Relay supplies ctx.telegram to every quest. const member = ctx.settings.chat ? await ctx.telegram!.member({ chat: ctx.settings.chat }) : null return member?.isMember ? decision.accept() : decision.reject({ reason: "Link your Telegram account and join the group, then try again.", }) }, }) ``` > [!DEV] > - **The reader.** `ctx.telegram.member({ chat })` reads whether the member is in a connected chat, with their linked Telegram account. `settings.telegramChat()` names the chat; Console fills it from the connected chats. > - **No `integrations` entry needed.** Relay provides `ctx.telegram` to every quest, declared or not; declaring `{ provider: "telegram" }` also publishes. It is typed optional only so that a test context written by hand before it keeps compiling, so call it as `ctx.telegram!.member(...)`; in a context a test builds, `fakeTelegramReader()` from `@domino-sdk/relay/testing` supplies it. > - **The result.** `{ userId, username, status, isMember, isAdmin, untilDate }`, or `null` when the member isn't in the chat or hasn't linked Telegram. `status` is Telegram's: `creator`, `administrator`, `member` or `restricted`. > - **Pinned once.** The member's Telegram account is pinned at the attempt's first Telegram read, so a later link or unlink doesn't change the attempt. > - **Errors.** A chat the project hasn't connected, or one whose bot lost access, throws a `CapabilityError` with code `integration-required`; a Telegram outage throws `provider-unavailable`. ### Require a linked Telegram account [#require-a-linked-telegram-account] A quest can also ask only that the member linked Telegram, such as an onboarding quest that wants a wallet, X and Telegram connected. It needs no connected chat. An account your app vouched for at sign-in, or one a member import brought in, counts like one the member connected. > [!DEV] > - **The reader.** `ctx.telegram.connection()` returns `{ userId }`, the numeric Telegram user ID as a string, or `null` when the member hasn't linked Telegram. It reads the same pinned account as `member()`, and asks neither Telegram nor Domino's bot. > - **In a quest.** `const telegram = await ctx.telegram!.connection!()` and accept when it isn't `null`: Relay supplies both to every quest, and the types are optional only for test contexts written by hand. In the testing kit, answer it with `facts.telegram.connection`. ## Reward reactions [#reward-reactions] Reward members for reactions in your connected groups, such as paying the author of a message when an admin reacts to it with a heart. * **Linked accounts only.** Domino records a reaction only when the person reacting, or the author of the message, has linked Telegram in the project. Reacting to your own message doesn't count. * **What the bot saw.** Domino knows who wrote a message only if it was posted while the bot was in the chat. Telegram sends no reactions from before the bot became an administrator. * **Groups, not channels.** Telegram doesn't say who reacted to a channel post, so channel reactions are never recorded. * **Removing a reaction** marks it removed. Adding the same reaction again later doesn't pay twice. Your developer sets this up as a tracker, Domino's running record of activity, and a quest that pays from it: ```ts import { automatic, decision, defineQuest, defineTracker, settings, telegram, } from "@domino-sdk/relay/authoring" export const reactions = defineTracker({ key: "telegram-reactions", title: "Telegram reactions", source: telegram.reactions(), window: { start: Date.parse("2026-10-01T00:00:00Z") }, }) export default defineQuest({ key: "admin-heart", title: "Get a ❤ from an admin", settings: settings.object({}), trigger: automatic({ tracker: reactions, beneficiary: "messageAuthor", where: [ { path: "reactorIsAdmin", equals: true }, { path: "emoji", equals: "❤" }, ], }), completion: ({ keyed }) => keyed(({ tracker }) => String(tracker.record.data.messageId)), rewards: [], async evaluate() { return decision.accept() }, }) ``` This example pays a message's author once per message when an administrator reacts to it with a heart. > [!DEV] > `telegram.reactions()` is a tracker source of the reactions members add to messages in the project's connected groups and supergroups. Each record is one added reaction: its `author` is the reactor, and its `messageAuthor` account is the author of the message. Define the tracker in its own file and list it under `trackers` in `relay.json`, like any tracker. Each record's `data` has scalar fields, so a trigger's `where` can select on them: > > | Field | Value | > | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | > | `chatId`, `chatType`, `messageId` | The message reacted to | > | `reactionType` | `emoji`, `custom_emoji` or `paid` | > | `emoji`, `customEmojiId` | The emoji, such as `"❤"`, or the custom emoji's ID; null for other types | > | `reactorStatus`, `reactorIsAdmin` | `creator` or `administrator` for the chat's administrators, read at most ten minutes earlier; `member` for anyone else | > > - **One chat or all.** A tracker records every connected group; select one with `{ path: "chatId", equals: "-100…" }` in `where`. > - **Once per reaction.** A quest on a Telegram tracker runs on each record's first observation (`on: "new-record"`) unless it sets `on`, so a reaction removed and added again is the same record and doesn't run it again. Quests built with SDK 1.62 or earlier ran on every observation (`"new-observation"`) unless they set `on`, and keep that mode until republished with a newer SDK. > - **Self-reactions.** Relay skips a reaction to one's own message with `beneficiary-is-author`. > - **Relay writes these records.** In SDK 1.x the source is a custom source on the reserved namespace `telegram-reactions`; sending records to it is refused. ### Tip with reactions, capped per giver and per receiver [#tip-with-reactions-capped-per-giver-and-per-receiver] A tip pays a message's author each time an admin reacts with ⚡ or ❤. Two daily limits keep it fair: * **Per receiver.** A member earns at most 20 tips a day, from everyone together. * **Per giver.** Each admin gives at most 10 tips a day, to everyone together. This holds whether or not the admin has linked Telegram: Domino counts the Telegram account that reacted. After either limit is reached, the next tip that day pays nothing, and that reaction doesn't pay later either. A new day starts at midnight in your [project calendar](/docs/concepts/calendar). Each tip that pays is also [announced in the group](#announce-tips-in-the-group), replying to the tipped message: "@ana just received +10 XP by getting a tip from @sam". Pick the group in the quest's **Settings**. ```ts import { automatic, budget, decision, defineQuest, defineTracker, points, settings, telegram, } from "@domino-sdk/relay/authoring" export const reactions = defineTracker({ key: "telegram-tip-reactions", title: "Telegram reactions", source: telegram.reactions(), window: { start: Date.parse("2026-10-01T00:00:00Z") }, }) export default defineQuest({ key: "admin-tip", title: "Get tipped by an admin", settings: settings.object({ balance: settings.pointLedger({ label: "Point ledger" }), chat: settings.telegramChat({ label: "Telegram group" }), }), trigger: automatic({ tracker: reactions, beneficiary: "messageAuthor", where: { path: "reactorIsAdmin", equals: true }, }), // Every tip pays: one completion per reaction. completion: ({ keyed }) => keyed(({ tracker }) => tracker.record.id), budgets: [ budget({ key: "tips-received", scope: "member", period: "day", limit: 20, cost: 1 }), budget({ key: "tips-given", scope: "author", period: "day", limit: 10, cost: 1 }), ], review: () => ({ enabled: false }), rewards: ({ settings }) => (settings.balance ? [points(settings.balance, 10)] : []), // Posted after the points are saved; none until a group is picked. announcements: ({ settings }) => settings.chat ? [ telegram.postMessage({ chat: settings.chat, text: "{beneficiary} just received +{points} XP by getting a tip from {author}", replyToTriggeringMessage: true, }), ] : [], async evaluate({ tracker }) { return ["⚡", "❤"].includes(String(tracker.record.data.emoji)) ? decision.accept() : decision.reject({ reason: "Tip with ⚡ or ❤." }) }, }) ``` > [!DEV] > `scope: "member"` counts per member paid, here the message's author. `scope: "author"` counts per record author, the account that caused the record: for a reaction, the reactor's Telegram account, whether or not a member linked it. The same works on `discord.reactions()`, keyed by the reactor's Discord account. An author budget needs a tracker trigger; `domino check`, `domino build` and `domino deploy` refuse it on other quests with `AUTHOR_BUDGET_TRACKER_REQUIRED`. A reaction over either limit is rejected with code `budget-exhausted`. Author budgets don't appear in a member's quest `budgets`, since they cap someone else's giving. ## Reward messages [#reward-messages] Reward members for posting in your connected groups, such as a daily reward for joining the conversation or a streak of active days. * **Linked accounts only.** Domino records a message only when its poster has linked Telegram in the project. * **People, not bots.** Bots' messages, service messages such as joins and pins, and messages sent on behalf of a chat are never recorded. * **Groups, not channels.** A channel post is authored by the channel, so channel posts are never recorded. * **No text.** Domino records that a message was posted, never what it says. * **Edits and deletes.** An edited message is the same message. Telegram doesn't tell bots when a message is deleted, so a deleted message stays recorded. The `telegram-contribution` snippet pays a member at most once per day for posting; install it as described in [Your first quest](/docs/build/first-quest). Days follow the [project calendar](/docs/concepts/calendar). In the quest's settings, pick the point ledger and, optionally, one group; with no group picked, posts in any connected group count. > [!DEV] > `telegram.messages()` is a tracker source of the messages members post in the project's connected groups and supergroups, one record per message, keyed `chatId:messageId`. Its `author` is the poster. Define the tracker in its own file and list it under `trackers` in `relay.json`, like any tracker. Each record's `data` has scalar fields, so a trigger's `where` can select on them: > > | Field | Value | > | --------------------------------- | --------------------------------------------------------------------------------------------------------- | > | `chatId`, `chatType`, `messageId` | The message: the chat's numeric ID as a string, `group` or `supergroup`, and the message's ID | > | `replyToMessageId` | The message it replies to; null when it isn't a reply | > | `threadId` | The forum topic, or the reply thread outside forums; null when Telegram names none | > > ```ts > import { defineTracker, telegram } from "@domino-sdk/relay/authoring" > > export default defineTracker({ > key: "telegram-messages", > title: "Telegram messages", > source: telegram.messages(), > window: { start: Date.parse("2026-10-01T00:00:00Z") }, > }) > ``` > > - **One chat or all.** A tracker records every connected group; select one with `{ path: "chatId", equals: "-100…" }` in `where`, or compare `ctx.tracker.record.data.chatId` with a `settings.telegramChat()` setting, as the snippet does. > - **Messages from before the chat was connected** are not recorded. > - **A streak instead of a daily reward.** Replace the snippet's keyed completion with `streak({ days: 7 })` and remove its daily budget. See [Calendar-day streaks](/docs/reference/quest-rules#calendar-day-streaks). > - **Relay writes these records.** In SDK 1.x the source is a custom source on the reserved namespace `telegram-messages`; sending records to it is refused. ## Announce tips in the group [#announce-tips-in-the-group] A quest can post a message in one of your connected groups each time it pays, such as "@ana just received +10 XP by getting a ⚡ from @sam" when an admin tips a message. The message is what makes tipping visible to everyone in the group. 1. **Install or write the quest.** Your developer adds the announcement to the quest; see below. 2. **Pick the group.** In the quest's **Settings**, choose the group from your connected chats. The picker says when Domino's bot can't post in a chat. 3. **Publish.** Publishing checks that the chat is connected in this environment and that the bot can post there. How it behaves: * **Points first.** The message is posted after the points are saved. If Telegram is down or refuses the message, the member keeps the points. * **It keeps trying.** A failed post is retried with backoff for about a day. If Telegram refuses it for good, for example because the bot was removed, the member's **Announcements** in Console show why, and **Retry delivery** posts it once you've fixed the cause. * **Paced, never dropped.** Telegram lets a bot send about 20 messages a minute in one group, so Domino posts at most one message every 3 seconds per chat. A burst of tips waits in line, oldest first. If Telegram asks Domino to slow down, the whole chat waits. * **People are mentioned.** The member who earned is mentioned by their @username, or by a link to their Telegram account that shows their name. The same goes for whoever triggered it, such as the admin who tipped. A member with no Telegram account in the project is named by their Domino display name, or as "someone" while their profile is hidden. * **Replies, if you want.** The message can reply to the tipped message. A message quest that doesn't reply still posts in the forum topic the message was in. * **Test stays in Test.** A Test environment posts only in chats connected to Test, and never in a chat any project connected to Live. Publication refuses such a chat too. * **Revoking.** Revoking the completion cancels a message not yet posted. A message already posted stays in the chat, and restoring the completion doesn't post a cancelled one. > [!DEV] > Add `announcements` beside `rewards`. `telegram.postMessage({ chat, text, replyToTriggeringMessage })` builds one; a quest posts at most three. Like `rewards`, `announcements` can be a function of the settings, so it can return none until a chat is picked. [Tip with reactions](#tip-with-reactions-capped-per-giver-and-per-receiver) is a complete quest that announces each tip: > > ```ts > import { telegram } from "@domino-sdk/relay/authoring" > > // In defineQuest({ … }): `chat` is a settings.telegramChat() setting, "" until a group is picked. > const announcements = ({ settings }: { settings: { chat: string } }) => > settings.chat > ? [ > telegram.postMessage({ > chat: settings.chat, > text: "{beneficiary} just received +{points} XP by getting a tip from {author}", > replyToTriggeringMessage: true, > }), > ] > : [] > ``` > > The text is a template, not code. These placeholders are the only ones; `{{` and `}}` write a literal brace: > > | Placeholder | Becomes | > | --------------- | --------------------------------------------------------------------------------------------- | > | `{beneficiary}` | The member the quest paid | > | `{author}` | The triggering record's `author`, such as the reactor. Only with an automatic tracker trigger | > | `{points}` | The amount of the first points reward the completion paid, such as `10` | > | `{rewards}` | Every points reward paid, with its ledger's name, such as `10 XP and 2 Gems` | > | `{quest}` | The quest's title | > > - **Escaping.** Relay posts with Telegram's HTML formatting and escapes everything, the template included, so names can't add formatting or links. > - **Replies.** `replyToTriggeringMessage` replies to the record's message when it is in the same chat, and posts a plain message otherwise. It, and `{author}`, need an automatic tracker trigger; `describe()` refuses them on other quests. > - **Unknown placeholders** fail when the quest is built, never when it posts. > - **Delivery records.** Each post is a reward delivery with `provider: "telegram"`, `action: "post-message"` (`RewardDeliveryAction.PostMessage`) and an empty `roleId`. `management.rewardDeliveries(member)` lists it and `management.retryRewardDelivery(id)` retries a failed one; the `reward.delivery.updated` and `reward.delivery.failed` activity events report it. An announcement that can't post at all, such as one for a chat not connected in this environment, creates no delivery: the attempt records why, and the points stand. ## Operators [#operators] Console lists the member's Telegram account under **Identities** on the member's page, beside X and Discord. Reviewers unlink it, or relink one the member proved, with a reason, as they do the others. The member keeps their sign-in and can connect again. An imported or vouched Telegram account the member never used is released instead, so its owner can connect it. See [operator identity changes](/docs/concepts/participants#operator-identity-changes). > [!DEV] > The management client reads it with `management.members.identityStates.list(member)` and changes it with `identityStates.unlink` or `relink(member, { identity: { kind: "telegram", subject }, expectedRevision, reason })`. `management.members.accounts(member)` reads the current selection beside X's and Discord's, and `unlinkAccount(member, { provider: "telegram", subject, expectedRevision, reason })` unlinks it. The older `management.members.telegramAccount(member)` and `unlinkTelegramAccount(member, { subject, revision, reason })` are deprecated. # Test your quests (/docs/build/testing) Before a quest reaches your members, your developer can check that it decides the way you expect. A good submission earns its reward, a bad one is rejected, and an unclear one goes to your team for review. These checks run on their computer, without your project, a sign-in or any member's data. * **Catch mistakes early.** A test fails when a quest stops deciding the way the test expects. * **Rerun a real attempt.** Take an attempt from the test environment and see whether new quest code would decide it differently. * **Check your app's screens.** Your developer can run your app against made-up members, quests and points, so a screen test shows the same thing every time. * **Try it by hand instead.** To click through a quest yourself, use [test members](/docs/use/quests#try-quests-with-test-members) in Console. No code needed. * **Rehearse the whole campaign.** Your developer or agent can play several test members over several days with the CLI; see [test a campaign with test members](/docs/reference/campaign-testing). The rest of this page is for your developer. `@domino-sdk/relay/testing` runs a quest's evaluator in your test runner the way Relay runs it when a member submits. You choose the evidence, the settings and what each capability answers; the kit returns the decision. No project, sign-in or network is involved. Every starter with quests includes `tests/quests.test.ts`, which tests them with the kit. Run it with `pnpm test`. ## Write a quest test [#write-a-quest-test] ### 1. Import the quest and the kit [#1-import-the-quest-and-the-kit] Import the quest's default export and the kit in a test file. Keep tests out of the `quests/` discovery patterns, such as in `tests/`. The kit works in any runner: the starters use Vitest, and this page uses `node:test`. ### 2. Run the quest and assert the decision [#2-run-the-quest-and-assert-the-decision] `runQuest` evaluates the quest and resolves with its decision. `facts` answers the capabilities the evaluator reads, here an AI photo review: ```ts import assert from "node:assert/strict" import { test } from "node:test" import { defineLedger, defineQuest, evaluators, manual, once, points, settings, } from "@domino-sdk/relay/authoring" import { expectAccept, expectReject, expectReview, runQuest, } from "@domino-sdk/relay/testing" const xp = defineLedger({ key: "xp", name: "XP" }) const showYourDesk = defineQuest({ key: "show-your-desk", title: "Show us your desk", settings: settings.object({ humanReview: settings.boolean({ label: "Send unclear photos to staff", default: true }), }), trigger: manual({ input: "photo" }), completion: once(), review: ({ settings }) => ({ enabled: settings.humanReview }), rewards: [points(xp, 30)], evaluate: evaluators.photo(({ settings }) => ({ criteria: "A desk with a monitor or laptop is visible.", unclear: settings.humanReview ? "human-review" : "reject", })), }) test("pays for a photo the review passes", async () => { const run = await runQuest(showYourDesk, { input: { photo: "evidence_desk" }, facts: { review: { photo: { kind: "pass", reason: "A desk and a laptop" } } }, }) assert.deepEqual(expectAccept(run).rewards, [ { kind: "points", balance: "xp", amount: 30 }, ]) }) test("sends unclear photos to staff unless review is off", async () => { const input = { photo: "evidence_desk" } const facts = { review: { photo: { kind: "unclear" as const, reason: "Too dark" } } } expectReview(await runQuest(showYourDesk, { input, facts }), "Too dark") expectReject( await runQuest(showYourDesk, { input, facts, settings: { humanReview: false } }), "Too dark" ) }) ``` ### 3. Run your tests [#3-run-your-tests] ```sh pnpm test ``` A failed assertion names what the quest decided, such as `Expected the quest to accept, but it rejected: "Too dark"`. ## What runQuest does like Relay [#what-runquest-does-like-relay] A check is only useful if the kit decides the way Relay would. This table lists what it copies from a hosted evaluation. For the hosted behavior itself, see [Evaluation time and randomness](/docs/reference/quest-rules#evaluation-time-and-randomness). | Hosted evaluation | `runQuest` | | ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | The release's setting values, rewards and review switch | Computed from the quest with `settings`, as publication does. Omitted settings take their defaults. | | `ctx.now`, `Date.now()`, `new Date()` frozen at the run's start; `performance.now()` returns 0 | Frozen at `now`, 2026-01-01T00:00:00Z unless you set it, and restored when the run ends. | | `Math.random()`, `crypto.getRandomValues()`, `crypto.randomUUID()` seeded by the attempt | Seeded by `seed` with the sandbox's generators, so one seed always draws the same values. | | `ctx.calendar` counts days in the project's time zone | Counts days in `timeZone`, UTC unless you set it, or in the `calendar` time-zone changes you pass. | | Capability requests and results are validated | The same checks: an invalid request throws `CapabilityError` with code `author-error`, as hosted. | | `ctx.x` and `ctx.discord` exist only for declared integrations | The same. An image check of an X image needs the `x` integration declared. | | At most 8 capability calls per evaluation run ([the limit](/docs/reference/verification#evaluator-readers-null-errors-and-pinning)) | The same: the ninth call throws `CapabilityError` with code `capability-limit`, and `limits.capabilityCalls` exports the limit. | | Text and URL submissions are checked against the trigger's limits | The same: a submission Relay refuses fails the run with a `TestSetupError`. | | A review becomes a reject with `requestedReview: true` when human review is off | The same. | | A custom reward selection can only reduce the published rewards | The same: a larger or unpublished reward fails the run with Relay's message, such as `Evaluator exceeded published reward bounds`. | Because the clock and randomness are process-wide, `runQuest` runs one evaluation at a time, even when your runner runs tests concurrently, and fails an evaluation that does not finish within 15 seconds, as Relay does. Code running beside it in the same process sees the frozen clock too. `runQuest` also refuses contexts Relay never builds: `input` for a quest that takes no evidence, and `eligibleAt` for a tracker-triggered quest, whose attempt is eligible when its record occurred. > [!NOTE] What the kit does not run > `runQuest` evaluates one attempt. It does not check prerequisites, availability windows, budgets or completion limits, and it does not record completions or deliver rewards. It does not know which chains and tokens Relay reads balances on, and module-scope code in your quest file runs at import, with the real clock. Test those with test members in the [test environment](/docs/use/quests#try-quests-with-test-members). ## Run options [#run-options] | Option | Sets | | ------------- | -------------------------------------------------------------------------------------------------------------------- | | `settings` | Setting values as an operator enters them in Console | | `input` | The submitted evidence: `{ value }` for text and URL quests, `{ photo }` (an evidence ID), `{ answers }` for quizzes | | `tracker` | The pinned observation of a tracker-triggered quest; build it with `trackerObservation` | | `performance` | A content quest's performance stage: `{ metrics, observedAt?, rewards? }` | | `facts` | Fake capability answers (below) | | `member` | The member ID; defaults to the observation's member, else `member_test` | | `now` | The run's start time, `ctx.now` | | `eligibleAt` | When a manual quest's attempt was admitted; defaults to `now`. A tracker quest takes the observation's `occurredAt` | | `timeZone` | The project time zone `ctx.calendar` uses | | `calendar` | Project time-zone changes as `{ effectiveAt, timeZone }`, oldest first | | `seed` | The randomness seed; a hosted run uses the attempt ID | `runQuest` resolves with `{ decision, capabilityCalls, calls, release }`. `capabilityCalls` lists every capability call with its sequence number, request and outcome, in order: `{ sequence, request, outcome }`, where `request.kind` names the reader, such as `"wallet-events"` or `"telegram"`, and the rest is the request as Relay received it. A later release can add kinds, so compare a request whole or read the fields of the kinds you expect. `calls` is deprecated: it leaves out Telegram, NFT, contract call, delegation, wallet event and transaction reads, and the member's state. `release` holds the rewards and review switch the run used, so a test can check what an accepted attempt grants beyond points, such as the group membership reward in the loyalty starter's tests. ## Fake capability answers [#fake-capability-answers] `facts` is keyed like the evaluation context. Each answer is a value, or a function of the call's arguments: ```ts import { CapabilityError, type TestFacts } from "@domino-sdk/relay/testing" export const facts: TestFacts = { x: { connection: { userId: "7" }, follows: ({ userId }) => userId === "42", post: () => { throw new CapabilityError("provider-unavailable", "X is unavailable") }, }, review: { text: { kind: "pass", reason: "Mentions the launch" } }, } ``` | Reader | Without an answer | | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | | `member.identities`, `wallet.list`, `discord.member` | `null`: the member has linked nothing | | `x.connection` | `null`: the member has not linked X | | `x.profile`, `x.follows`, `x.reposted` | `null` while `x.connection` is unset; required once you link an X account | | `wallet.balance` | `null` while `wallet.list` is unset; required once you link a wallet on the read's chain | | `member.balances`, `member.completions`, `member.groups`, `member.campaigns`, `member.referrals` | Required | | `x.post`, `discord.message`, `trackers`, `images`, `review` | Required | The linked accounts are pinned for the run, as in a hosted run ([Evaluator readers: null, errors and pinning](/docs/reference/verification#evaluator-readers-null-errors-and-pinning) has the hosted rules): * **`member.identities`, `wallet.list` and `x.connection` answer once per run**, even when you answer with a function. * **Reads of the member's own accounts follow those pins.** An answer for `x.follows` without `x.connection`, or for `wallet.balance` without `wallet.list`, fails the run: the member it describes has nothing linked. A balance read of an `address` the member never linked throws `CapabilityError` with code `author-error`, and an image check of `{ x: "profile" }` without a linked X account throws one with code `not-linked`. * **`member.balances` and `member.completions` hold the member's whole state, answered once per run.** Give `member.balances` the member's balance in each ledger, as `relay.me.balances()` lists them, and `member.completions` every completion as `{ quest, key, completedAt }` with the quest's key. `ctx.member.balance(ledger)` picks the ledger by key or ID and throws `CapabilityError` with code `author-error` for one you did not list. `ctx.member.completions()` filters by `quest` and `since`, puts the newest first and applies `limit`, as Relay does. * **`member.groups`, `member.campaigns` and `member.referrals` are answered once per run the same way.** Give `member.groups` the keys of the member's unarchived groups; `ctx.member.groups()` returns them in byte order. Give `member.campaigns` each campaign the project knows as `{ key, enrolled }`; `ctx.member.enrolled(campaign)` returns its `enrolled` and throws `CapabilityError` with code `author-error` for a campaign you did not list. Give `member.referrals` the member's progress in each program, as `relay.profiles.referral()` returns it; `ctx.member.referrals(program)` picks the program by key or ID and throws `author-error` for one you did not list. * **A call with no answer fails the run** with a `TestSetupError` naming the fact to set, even if quest code catches errors. * **Answers are checked** against the result shapes Relay delivers, and quest code reads them as JSON. Relay never returns an empty wallet list, for example, so `wallet: { list: [] }` fails; use `null`. * **Throw a `CapabilityError` to fail a call**, as a provider outage does. If quest code lets it escape, `runQuest` rejects with it, where Relay would retry a retryable one later. * **Anything else quest code throws rejects with an `EvaluatorError`**: code `evaluator-error`, the thrown error's name and message, such as `TypeError: Cannot read properties of undefined`, and what quest code threw as `cause`. Relay fails the attempt once with the same code and message, without retrying. A failure you received and rethrow, or wrap with `{ cause: error }`, still rejects as itself; `throw new Error(error.message)` does not. See [quest code that fails on its own](/docs/reference/verification#evaluator-readers-null-errors-and-pinning). * **Functions receive the request as Relay does**: a wallet balance query's chain is canonical (`"solana"` arrives as its CAIP-2 ID), and a tracker read has its defaults and `asOf` filled. ## Tracker-triggered quests [#tracker-triggered-quests] Pass the observation that admitted the attempt. `trackerObservation` types `data` from the tracker, and `trackerPage` builds an answer for `ctx.trackers.records`: ```ts import { test } from "node:test" import { automatic, customSource, decision, defineLedger, defineQuest, defineTracker, points, settings, z, } from "@domino-sdk/relay/authoring" import { expectAccept, runQuest, trackerObservation, trackerPage, } from "@domino-sdk/relay/testing" const deposits = defineTracker({ key: "deposits", title: "Deposits", source: customSource("bank", { identities: ["member"], data: z.object({ amount: z.number() }), }), window: { start: Date.parse("2026-01-01T00:00:00Z") }, }) const saver = defineQuest({ key: "saver", title: "Save 100", settings: settings.object({}), trigger: automatic({ tracker: deposits }), completion: ({ keyed }) => keyed((ctx) => ctx.tracker.record.id), rewards: [points(defineLedger({ key: "xp", name: "XP" }), 10)], async evaluate(ctx) { const earlier = await ctx.trackers.records(deposits, { member: ctx.member.id }) const total = earlier.items.reduce((sum, item) => sum + item.data.amount, 0) return total + ctx.tracker.record.data.amount >= 100 ? decision.accept() : decision.reject({ reason: `Saved ${total} so far` }) }, }) test("pays once the deposits reach 100", async () => { const run = await runQuest(saver, { tracker: trackerObservation(deposits, { data: { amount: 60 }, member: "member_1" }), facts: { trackers: { deposits: { records: trackerPage(deposits, [{ data: { amount: 40 } }]) } }, }, }) expectAccept(run) }) ``` A fixed `trackerPage` answers every query. To check the query the evaluator sends, answer with a function and inspect its argument. ## Assert decisions [#assert-decisions] | Helper | Passes when the run | Returns | | ------------------------------ | ----------------------------------------- | ------------------------------------------------ | | `expectAccept(run)` | Accepted | The decision, with `rewards` and `completionKey` | | `expectReject(run, reason?)` | Rejected, with a matching reason if given | The decision | | `expectReview(run, reason?)` | Went to human review | The decision | | `expectDefer(run, reason?)` | Deferred | The decision | | `expectProgress(run, reason?)` | Recorded progress | The decision | `reason` is an exact string or a regular expression. The helpers throw a `DecisionAssertionError`, which every test runner reports as a failure. A reject's [code](/docs/reference/quest-rules#reject-with-a-code) is on the returned decision: `assert.equal(expectReject(run).code, "wrong-answers")`. A code Relay would refuse, such as one with uppercase letters or one Domino reserves, rejects the run with an error naming the rule, so a test catches it before a deployment. ## Read diagnostics [#read-diagnostics] `run.diagnostics` lists what the evaluation did that had no effect, without failing the run. Each entry has a `code` and a `message`: | Code | What happened | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `completion-key-ignored` | The evaluator returned a `completionKey` that the quest's completion policy ignores. Only `keyed(...)` uses it; `once()`, `streak(...)`, `progress(...)` and content quests key completions themselves. | To fail a test on any diagnostic, assert `run.diagnostics` is empty. Relay does not report diagnostics for hosted attempts, so tests are where they show up. ## Replay a captured attempt [#replay-a-captured-attempt] Relay captures what each evaluation run read: the release's settings and rewards, the attempt's input, frozen clock, seed and calendar, and every capability answer. `replayAttempt` runs your current quest code against such a capture, so you can check whether a change alters an outcome a member already got. > [!IMPORTANT] Test environment only > Captures are served from the test environment only, including a staging project's test data. A live attempt holds a real member's data: the API refuses it with `422` and code `test-environment-required`, and the CLI refuses `--environment live` before sending a request. ### 1. Fetch the capture [#1-fetch-the-capture] ```sh pnpm exec domino quests replay ATTEMPT_ID --environment test --out captures/ATTEMPT_ID.json --json ``` The command fetches the attempt's latest evaluation run, finds the quest among your discovered quests by its key, replays it and prints the result. `--out` also saves the capture for a test, in a file only you can read (mode 0600). Pass `--quest ` for a quest created in Console or kept outside discovery, and `--capture ` to replay a saved capture without a request. From code, `management.attempts.capture(attempt)` returns the same capture. Captures need `publish` permission, because they hold the providers' raw answers, such as X profiles and Discord messages. An attempt that has not been evaluated yet fails with `409` and code `attempt-not-evaluated`. ### 2. Replay it in a test [#2-replay-it-in-a-test] ```ts import assert from "node:assert/strict" import { readFile } from "node:fs/promises" import { test } from "node:test" import { defineQuest, decision, manual, once, settings } from "@domino-sdk/relay/authoring" import { replayAttempt } from "@domino-sdk/relay/testing" const welcome = defineQuest({ key: "welcome", title: "Welcome", settings: settings.object({}), trigger: manual(), completion: once(), rewards: [], evaluate: async () => decision.accept(), }) test("the welcome rewrite keeps the decision of attempt_123", async () => { const capture = JSON.parse(await readFile("captures/attempt_123.json", "utf8")) const replay = await replayAttempt(welcome, capture) assert.equal(replay.changed, false) }) ``` `replayAttempt` resolves with: | Field | Holds | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `decision` | What the current code decides; `null` when the replay stopped at a divergence | | `recorded` | What quest code decided in the captured run, before Relay acted on it; `null` before it decided, and for runs decided before Relay kept decisions | | `changed` | Whether the decision differs from `recorded`: kind, reason, rewards or completion key; `null` when either is missing | | `capabilityDivergence` | The first call the capture could not answer, of any reader: its `sequence`, `reason`, the `recorded` request and the `replayed` one; `null` when it answered every call | | `capabilityCalls` | Every capability call of the replay, with its answer, as in `runQuest` | | `divergence`, `calls` | Deprecated: the same, for the readers 1.0 had only. `divergence` is `null` when the first divergence is a call of a newer reader | A capture answers a call by its position, as a retry of the run does. A call is a divergence when the captured run made no such call (`new`), sent another request (`changed`), or got a retryable failure Relay did not keep (`uncaptured`). The replay stops there unless you pass `facts`, which answer those calls; `capabilityDivergence` still names the first one. A capture of another quest key fails with `TestSetupError`. The capture's answers are checked as Relay delivered them, but a field or a value a later Relay added to a result passes as captured, so a capture keeps replaying after Relay's results grow. The answers you pass in `facts` are checked strictly, as in `runQuest`. ## Call evaluate yourself [#call-evaluate-yourself] `createTestContext(quest, options)` builds the host context `quest.evaluate` takes, with the same fake readers, without freezing the clock or checking rewards. Use it to test helpers that take readers, or to drive `quest.evaluate` step by step. A test that assembles its own context needs a `ctx.member`. Build it with `fakeEvaluationMember`, passing the methods your code reads: ```ts import { fakeEvaluationMember } from "@domino-sdk/relay/testing" const member = fakeEvaluationMember({ id: "member_1", groups: async () => ["vip"], }) ``` The methods you leave out answer for a member with no linked accounts, completions, groups or enrollments; `balance` and `referrals` fail with a `CapabilityError` of code `author-error`, as Relay does for an unknown ledger or program. A minor release can give `EvaluationMember` a method, which this helper answers too, so a member written by hand can stop compiling while one from `fakeEvaluationMember` does not. For `ctx.wallet` and `ctx.telegram`, `fakeWalletReader(facts)` and `fakeTelegramReader(facts)` build readers with every method Relay supplies, answered from `facts.wallet` and `facts.telegram` as `runQuest` answers them: ```ts import { fakeEvaluationMember, fakeTelegramReader, fakeWalletReader, } from "@domino-sdk/relay/testing" // Readers for a context you pass to quest.evaluate yourself. export const readers = { member: fakeEvaluationMember(), wallet: fakeWalletReader(), telegram: fakeTelegramReader({ telegram: { connection: { userId: "42" } } }), } ``` > [!NOTE] Readers added after 1.0 are optional in their types > `ctx.telegram`, `ctx.telegram.connection` and the wallet's `nfts`, `call`, `delegations`, `events` and `transactions` are optional in the reader types only so a reader written by hand before them keeps compiling. Relay and `runQuest` supply them to every evaluation, so quest code calls them with a non-null assertion, as the snippets do: `await ctx.wallet.nfts!({ collection })`, `await ctx.telegram!.connection!()`. In a context you build, use the fakes above, which have every method. > [!WARNING] Quest code cannot import the testing kit > Building a quest that imports `@domino-sdk/relay/testing` fails with `Quest code cannot import @domino-sdk/relay/testing`. Import the kit only from test files. ## Test your app's UI [#test-your-apps-ui] `createFakeRelay` answers the participant API from data you give it, so a UI test of an app built on `@domino-sdk/relay/react` sees the same member, quests and points on every run, without a project or a sign-in. > [!NOTE] Experimental > `createFakeRelay` and its `FakeRelay` types are experimental: a minor release can change their options, the routes the fake answers and its answers. Pin the SDK version your UI tests run against. ### 1. Seed the fake [#1-seed-the-fake] ```ts import { createFakeRelay } from "@domino-sdk/relay/testing" const xp = { id: "ledger_xp", key: "xp", name: "XP" } const relay = createFakeRelay({ member: "member_ada", balances: [{ ledger: xp, balance: 120, available: 120 }], }) ``` | Option | Answers | | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `member` | `GET /v1/auth/session`: a session for this member ID, or `null` (the default) for a signed-out visitor | | `scope` | The session's `organization`, `project` and `environment`; defaults to `org_test`, `project_test` and `test` | | `quests`, `collections`, `entitlements` | `GET /v1/me/experience`, as the client's `quests.experience()` reads it | | `balances` | `GET /v1/me/points` | | `ledgers` | `GET /v1/point-ledgers`; defaults to the ledgers in `balances` | | `pointHistory` | `GET /v1/me/points/:ledger/history`, keyed by ledger key or ID, in one page | | `activity` | `GET /v1/me/activity`, oldest event first, paged by `cursor` and `limit` | | `leaderboards` | `GET /v1/leaderboards/:board`, keyed by board key or ID | | `badges` | `GET /v1/badges/catalog` as `{ items }` and `GET /v1/badges` as an array, signed in or out | | `routes` | Any other route, or a replacement for one above, keyed `"METHOD /v1/path"`; a `:name` segment matches any one segment | An omitted seed answers empty: no quests, no balances, no events. The fake also answers `POST /v1/auth/logout`, which signs the member out, and `POST /v1/auth/exchange` with a server integration key (`di_...`) as the bearer token, which signs in the seeded member, or a member whose ID is the `subject` when none is seeded. * **Signed out, member reads answer `401`** with code `not-signed-in`, as Relay does, and the session reads `null`. * **Every answer is checked** against the shape the SDK reads, so a seed Relay could never send, such as a balance of `1.5`, throws a `TestSetupError` naming the route when you create the fake. * **A participant route the fake does not answer fails loudly**: `501` with code `not-faked` and the method and path in the message, so a test cannot pass on an empty answer. Answer it with `routes`, as for a submission. A path that is not a participant route answers `404` with code `route-not-found`, as the [participant proxy](/docs/build/hosted-app) does. * **`relay.requests`** lists the method and path of every request the fake answered, in order, so a test can check what the app read. Paths answer the same with or without the `/relay` prefix that `RelayProvider` uses by default. ### 2. Route the app's requests to it [#2-route-the-apps-requests-to-it] `relay.handle(request)` takes a `Request` and resolves with a `Response`. In Playwright, route the app's `/relay/v1/` requests to it: ```ts import { expect, test } from "@playwright/test" import { createFakeRelay } from "@domino-sdk/relay/testing" test("shows the member's points", async ({ page }) => { const relay = createFakeRelay({ member: "member_ada", balances: [ { ledger: { id: "ledger_xp", key: "xp", name: "XP" }, balance: 120, available: 120 }, ], }) await page.route("**/relay/v1/**", async (route) => { const request = route.request() const response = await relay.handle( new Request(request.url(), { method: request.method(), headers: request.headers(), body: request.postData() ?? undefined, }) ) await route.fulfill({ status: response.status, headers: Object.fromEntries(response.headers), body: Buffer.from(await response.arrayBuffer()), }) }) await page.goto("/") await expect(page.getByText("120 XP")).toBeVisible() }) ``` If your app uses its own login, run your server's `createParticipantBackend` handler against `relay.fetch` instead, at a local origin such as `http://127.0.0.1`: see [test the login in a browser](/docs/build/app-login#test-the-login-in-a-browser). In a component test with Vitest or `node:test`, pass `relay.fetch` to the client instead: ``. With MSW, return `relay.handle(request)` from a handler. ### What RelayProvider reads [#what-relayprovider-reads] `RelayProvider` reads nothing until it mounts. Then: | When | Requests | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | On mount | `GET /v1/auth/session`, twice: once directly and once before the experience | | On mount, signed in | `GET /v1/me/experience`, and `GET /v1/me/activity?start=latest` to find the newest event | | Every 5 seconds, signed in | `GET /v1/me/activity?cursor=...` for events after the last one (`polling.activityMs`) | | Every 30 seconds | `GET /v1/me/experience` when signed in, `GET /v1/auth/session` when signed out (`polling.snapshotMs`). A member read refused with `not-signed-in` reads the session again at once | | On each activity event | The reads of the hooks the event changes, such as `GET /v1/me/points` after `points.awarded` | | On window focus | The activity feed again, or the session for a signed-out visitor, unless either was read in the last 2 seconds; queries with `refetchOnFocus` read again too | Each hook adds its own read when a component first uses it, such as `useBalances` (`GET /v1/me/points`), `useActivity` (`GET /v1/me/activity`) and `useLeaderboard` (`GET /v1/leaderboards/:board`). Reads pause while the tab is hidden. `useActivity` reads the feed from its first event, so seeded `activity` reaches its `data.items` and `onEvent`. The provider's own feed starts at the newest event, so seeded events do not make it read hooks again. To test how the app reacts to a new event, answer `GET /v1/me/activity` with `routes`, returning the event once the cursor the first answer gave comes back. ## Where next [#where-next] - [Your first quest](/docs/build/first-quest) — Write and publish the quest you are testing. - [Quests](/docs/concepts/quests) — How Relay runs evaluations, retries and replays. - [Authoring](/docs/reference/authoring) — Evaluator readers, errors and their pinning. # Track posts and activity (/docs/build/trackers) A tracker watches one place for activity and keeps a record of everything it finds. For example: * **Posts on X** that match a search, such as `#OurLaunch` or replies from your account. * **Messages and reactions** in your Discord server. * **Messages and reactions** in your Telegram groups. * **YouTube Shorts** your members post on the channels they linked. * **TikTok videos** your members post on the accounts they linked. * **Token balances** of the wallets your members linked, sampled by Domino once a day. * **Events your own systems report**, such as a token balance, a game win, or kudos someone gives a member. Collecting never pays anyone. A quest decides what counts: it reads the tracker, checks each new post or event, and rewards the member behind it. One tracker can feed several quests, and each quest can judge the same post differently. > [!IMPORTANT] Reward X posts only for approved creators > X does not allow apps that reward users for posting. Domino rewards X posts for creators you approve: give every quest that pays for X posts an `inGroup(group)` prerequisite, and add approved creators to that group in Console **Groups**. A deployment that publishes a tracker-triggered or content quest that pays for X posts without one still publishes, with the warning `x-post-rewards-ungated`; see [publishing](publishing.md#1-validate-the-remote-deployment). ## What members see [#what-members-see] Members link their X, Discord, Telegram or wallet account, or their YouTube channel or TikTok account, in your app. When a tracker collects their activity, the quests that tracker triggers check it for them automatically, with nothing to submit. A quest can also ask members to enroll first, and let them claim posts they made before they linked their account. ## What your team sees [#what-your-team-sees] In Console, **Community → Trackers** lists each tracker with its records and how fresh they are. Your team can pause and resume collection, ask for a sync, and open any record to see which quests admitted it, which skipped it and why. Anyone with review permission can retry a skipped record that should count. ## What gets collected [#what-gets-collected] * **X trackers keep every match.** That includes unverified authors and people who have not joined your project. Quests and reads filter them later. * **Discord and Telegram trackers keep only members' activity.** A message or reaction is stored only when it involves an account a member of your project linked; see [Reward Discord messages and reactions](#reward-discord-messages-and-reactions). * **YouTube trackers read only members' channels.** Domino reads the Shorts on channels members of your project linked; see [Reward YouTube Shorts](#reward-youtube-shorts). * **TikTok trackers read only members' accounts.** Domino reads the public videos on TikTok accounts members of your project linked, with each member's own permission; see [Reward TikTok videos](#reward-tiktok-videos). * **Wallet balance trackers read only members' wallets.** Domino samples the wallets members of your project linked, once a day; see [Reward holding tokens](#reward-holding-tokens). * **Custom trackers keep what your server sends.** Your own servers can feed records from any system, including a blockchain indexer. For a creator program, add a content quest to qualify posts and award once, then an optional engagement rule to credit measured growth daily. For the model behind this guide, read [Trackers and datasets](/docs/concepts/trackers) and [UGC programs](/docs/concepts/ugc). | To | Start here | | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | Collect and inspect X posts | [Publish an X tracker](#publish-an-x-tracker). | | Let operators add a tracker without a deployment | [Create a tracker without a deployment](#create-a-tracker-without-a-deployment). | | Qualify member posts and award once | [Admit X posts automatically](#admit-x-posts-automatically). | | Require a metric threshold for that award | [Add a performance stage](#require-an-engagement-threshold-before-awarding). | | Credit ongoing engagement | [Settle engagement points daily](#settle-engagement-points-daily). | | Update future scoring | [Change a formula](#change-a-formula-without-repricing-past-engagement). | | Reward a value your server measures, such as a balance | [Trigger a quest from tracker records](#trigger-a-quest-from-tracker-records). | | Reward members when an account replies to them | [Reward members when an account replies to them](#reward-members-when-an-account-replies-to-them). | | Reward members when an account quotes or reposts them | [Reward members when an account quotes or reposts them](#reward-members-when-an-account-quotes-or-reposts-them). | | Reward the member a record names, such as a kudos recipient | [Reward the account a record names](#reward-the-account-a-record-names). | | Reward Discord messages or reactions | [Reward Discord messages and reactions](#reward-discord-messages-and-reactions). | | Reward members' YouTube Shorts | [Reward YouTube Shorts](#reward-youtube-shorts). | | Reward members' TikTok videos | [Reward TikTok videos](#reward-tiktok-videos). | | Reward holding a token for days | [Reward holding tokens](#reward-holding-tokens). | New and changed trackers go to test first. The CLI publishes directly to test; for live, `domino promote` prepares a promotion of test that a person with publish permission approves in Console. See [publishing](publishing.md) for the deployment workflow. ```mermaid flowchart LR X["X search"] --> T["Tracker"] G["Discord server"] --> T C["Your indexer (custom source)"] --> T T --> D["Dataset"] D --> S["Your server (management SDK)"] D --> Q["Quest evaluator"] Q --> R["Accept, reject, or review"] ``` If the project has no trackers yet, **Community → Trackers** in Console offers example requests for a coding agent. Select **Copy prompt** on one and paste it into your agent, or select **Send to Build** to [review it in the playground](../start/new-app.md#send-a-request-from-another-page). The prompt includes the project, the environment, and the steps to define, preview, and publish the tracker. A copied prompt names the selected environment; one sent to the playground names **Test**. The exact options, read filters, trigger rules and outcome codes are in the [trackers reference](/docs/reference/trackers). ## Preview an X search [#preview-an-x-search] Try a search in Console before anyone writes code, to see what it would collect. > [!NOTE] A preview is a bounded sample, not the dataset > It reads at most 100 matches from 3 search pages, up to 30 days back, and says when X had more. It never publishes a tracker, starts collection, or changes stored records. * **Freshness.** Engagement is as of the fetch time. After 15 minutes, Console suggests searching again. * **Nothing is stored.** The sample exists only in that Console view. It is not saved, and leaving the view discards it. * **Access and limits.** Previews require `publish` permission and allow 6 a minute per person and 60 an hour per organization. * **Credentials stay on the server.** A preview uses the platform's X credential on the server; the browser never receives it. ### 1. Search a sample [#1-search-a-sample] In **Community → Trackers**, select **Preview X search**. Enter the query and a **From (UTC)** day, then select **Search**. Console shows a sample of matching posts with authors, verification, and engagement. ### 2. Compare read filters [#2-compare-read-filters] Switch between **All authors**, **Verified**, and **Unverified** to see how a read filter would change the results. The sample keeps unverified posts and posts with unknown verification. ### 3. Copy the result [#3-copy-the-result] | Action | Copies | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | **Copy tracker definition** | A `defineTracker` file to paste into your project. | | **Copy prompt** | The query, the start day, what the sample showed, and your verification choice, for a coding agent. | | **Send to Build** | The same prompt, for **Test**, as a new [playground](../start/new-app.md#send-a-request-from-another-page) thread to review and send. | Both use the query and day that were searched, even if you edited the form afterwards. Verification stays a read filter: the definition collects every match. Then [publish the definition](#publish-an-x-tracker). ## Publish an X tracker [#publish-an-x-tracker] Your developer defines the tracker in code, and it starts collecting once it is published. ### 1. Define the tracker [#1-define-the-tracker] Create `trackers/launch.ts`: ```ts import { defineTracker, x } from "@domino-sdk/relay/authoring"; export default defineTracker({ key: "launch-posts", title: "Launch posts", source: x.search({ query: '"Domino" OR #DominoLaunch' }), window: { start: Date.parse("2026-09-01T00:00:00Z") }, }); ``` ### 2. Add it to the manifest [#2-add-it-to-the-manifest] Add the entry to your existing `relay.json`: ```json { "trackers": [{ "entry": "trackers/launch.ts" }] } ``` ### 3. Deploy to test [#3-deploy-to-test] Run `domino deploy` in the project's test environment, review the preview, and publish. Definitions can ship alongside quests, ledgers, referrals, and leaderboards. ### 4. Monitor collection [#4-monitor-collection] Open **Community → Trackers** in Console to inspect records and freshness, filter verified accounts, or pause and resume collection. Editors can request a sync while collection is running. **Audit history** records each change. When Domino pauses a tracker itself, for example because the project used its monthly X budget, Console says why and when collection resumes. See [How often Domino reads X](#how-often-domino-reads-x). X posts show the author's avatar when available, linked mentions and hashtags, attached photos, and reported engagement counts. Direct video files play inline; other media opens through an attachment link. Lists use a narrow feed with the author and publication date on one line. Photos fill a 4:3 preview frame; select a photo to open its full image. Select **Show more** to expand a long post, or **Show less** to collapse it. The quest-outcomes drawer shows the full post. Counts are read-only; missing counts stay hidden. Select **Quest outcomes** at the top right of a stored post to open its detail drawer. In the drawer, select the X icon to open the original post or expand **Collection details** to inspect observation times and provider data. > [!DEV] > A server can pause, resume or sync with `management.trackers.pause(reference, { actionId, reason })`, `resume` or `refresh`, using a key with `publish` permission, in test or live. The reason is optional and goes to **Audit history**; Console asks for one when you pause or resume. A tracker's status carries `pauseReason` and `resumesAt`. ### How often it collects [#how-often-it-collects] By default, an X tracker searches every six hours and reads up to 60 pages of results each time, within the start and end dates you give it. Each search covers the whole date range again, and posts the tracker already knows are read again on a schedule. Your project's plan sets how often a tracker can search: every six hours on every plan but Enterprise. > [!IMPORTANT] Complete coverage is not a guarantee > X search returns only what X makes available. Coverage **complete** means a cycle read every page X offered and refreshed known posts; it does not promise every matching post is in the dataset. When a long date range reaches the page limit, the tracker reports **partial** coverage. Narrow the search or the dates, or raise the page limit. > [!DEV] > Set `refreshSeconds` (300–86400) and `maxPages` (1–1000) on `defineTracker`; `window` is required. A `refreshSeconds` below the plan's minimum still publishes, runs at the minimum (`effectiveRefreshSeconds` on the status), and the deployment warns `tracker-refresh-below-plan-minimum`. Removing a definition from the manifest neither deletes its data nor stops collection: pause or archive it. See [collection options](/docs/reference/trackers#collection-options). ### How often Domino reads X [#how-often-domino-reads-x] Domino reads X on a best-effort basis, through a shared provider, and limits how much each project reads. Every search finds new posts in the tracker's dates and updates the numbers on each post it returns. Posts the tracker already knows but the search didn't return are read again by their age: | Post age | Read again | | -------------- | ------------- | | Under 48 hours | Every 6 hours | | 2 to 7 days | Once a day | | Over 7 days | Never | A post that's already more than 7 days old when Domino first finds it is read once. A leaderboard closing its scoring period asks for fresh numbers on the posts it scores, whatever their age. Every X read counts toward the project's monthly X usage: tracker searches and refreshes, quest checks on X, tracker previews and handle lookups. Usage resets at the start of each month, in UTC. * **Live budget.** Each plan includes a monthly X budget for live: 25,000 reads on Launch, 65,000 on Growth, 160,000 on Scale, and by agreement on Enterprise. Where plan limits apply, a project that uses its budget has its live X trackers paused until the next month, or until it moves to a plan with budget left. Quest checks are never paused. * **Test budget.** An organization's test X trackers share 5,000 reads a month across its projects. When they're used, test X trackers pause until next month. * **Test window.** A test X tracker collects for 14 days from its first sync, then pauses. Resume it to collect for another 14 days. To see what a campaign's X trackers would read a day on each plan before you publish it, [simulate it](/docs/build/simulate#real-x-posts): the results estimate it from a sample of each tracker's search. A tracker paused on its budget can't be resumed while the budget is still used. If you pause it yourself, it stays paused until you resume it. A resumed tracker searches its whole date range again, so it still finds posts made while it was paused, if its dates cover them. > [!DEV] > Quest checks that count are `x.post`, `x.profile`, `x.follows`, `x.reposted` and image checks on X. A budget pause sets `pauseReason: "x-budget-exhausted"` and `resumesAt` to the start of the next month; the test window sets `"test-window-ended"`. Resuming while the budget is spent is refused with 409 `x-budget-exhausted`. Pausing such a tracker yourself makes it an operator pause (`pauseReason: "operator"`). ### Change a tracker [#change-a-tracker] Changing an X query or window keeps collected records and starts new discovery from publication time. It does not silently backfill the expanded scope. > [!WARNING] Use a new key to backfill > Use a new tracker key for a deliberate historical collection. Changing the source kind or custom namespace also requires a new key. A new key's first publish searches from `window.start`, even when it is in the past, as far back as the provider's history reaches. Republishing an existing key with a wider query or an earlier window never searches the past: discovery for the changed scope starts at publication time. ## Create a tracker without a deployment [#create-a-tracker-without-a-deployment] A tracker of any source Domino collects itself can be data instead of code, so an operator or your own server adds a campaign without a code change. That covers X searches, Discord and Telegram messages and reactions, YouTube Shorts, TikTok videos and wallet balances. Trackers of your own custom sources stay in code. Data trackers are created and changed in test, and reach live through a promotion, like other definitions. See [test and live](/docs/concepts/environments). ### 1. Create the tracker in test [#1-create-the-tracker-in-test] 1. In Console, open **Community → Trackers** in the test environment and select **New tracker**. 2. Pick the **Source**, then enter a title and the window. The key follows the title until you type your own. 3. Fill in the source's own fields, if it has any: * **X search:** the search query. The sync interval and pages per sync sit under **Advanced**, which shows their values (every 6 hours, up to 60 pages, unless you change them). * **Discord messages:** whether to **Store message text**. * **Wallet balances:** the **Chain** and, unless you sample the chain's native coin, the **Token**: the mint on Solana, the ERC-20 contract on an EVM chain, or the denomination on a Cosmos chain. Neither changes later. * Every other source has none. 4. Select **Create tracker**. The key is permanent and stays the same in live. An X tracker's first collection searches from the window's start. > [!NOTE] Connect the source first > When a source is not ready in the environment, the form says what to set up first: a Discord server or Telegram chats in **Integrations**, or creator account linking for YouTube and TikTok, which a Domino operator turns on. You can still create the tracker; it records nothing until the source is ready. A server does the same with the management client and a key with `publish` permission: ```ts import { createManagementClient } from "@domino-sdk/relay/server"; const management = createManagementClient({ baseUrl: "https://api.example.com", organization: "my-organization", project: "community", environment: "test", getToken: async () => "SERVER_SIDE_MANAGEMENT_KEY", }); const tracker = await management.trackers.create({ key: "spring-campaign", title: "Spring campaign posts", query: "#springcampaign", window: { start: Date.parse("2026-04-01T00:00:00Z") }, maxPages: 20, }) const reactions = await management.trackers.create({ key: "server-reactions", title: "Server reactions", source: { kind: "discord-reactions" }, window: { start: Date.parse("2026-04-01T00:00:00Z") }, }) ``` From the CLI, run `domino trackers create spring-campaign --title "Spring campaign posts" --query "#springcampaign" --start 1775001600000`, or name another source with `--source`, such as `domino trackers create server-chat --title "Server chat" --source discord-messages --text on --start 1775001600000`. A wallet balances tracker takes `--chain` and `--token`: `domino trackers create usdc --title "USDC on Base" --source wallet-balances --chain eip155:8453 --token 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 --start 1775001600000`. > [!DEV] > The fields and their limits are the same as `defineTracker`, with `source` named by kind: `{ kind: "x-search", query }` (or `query` alone), `{ kind: "discord-messages", text? }`, `{ kind: "discord-reactions" }`, `{ kind: "telegram-messages" }`, `{ kind: "telegram-reactions" }`, `{ kind: "youtube-shorts" }`, `{ kind: "tiktok-videos" }` or `{ kind: "wallet-balances", chain, token }`, where `chain` is a CAIP-2 ID (Solana mainnet's for Solana) and `token` is null for a native coin. Each is stored as the source its builder makes, so quests read it exactly like the same tracker in code. See [collection options](/docs/reference/trackers#collection-options). The answer is the tracker's status, with `origin: "data"`. ### 2. Change it [#2-change-it] Select the tracker in Console, then **Edit**. From a server, call `management.trackers.update(tracker, changes)` with only the fields to change. From the CLI, run `domino trackers update spring-campaign --query "#springcampaign OR #spring26"`. The key and the source never change; to track another source, create another tracker. A new query or window collects from now on and never searches the past, as when [changing a tracker in code](#change-a-tracker). A title, sync interval or page limit change leaves discovery where it was. > [!WARNING] Turning message text off erases it > Clearing **Store message text** on a Discord messages tracker erases the text it already stored, and Console asks before saving. Turning it back on does not restore erased text. > [!DEV] > Change a source's settings with `source`, such as `{ source: { kind: "discord-messages", text: false } }`, or `--text off` from the CLI. A different kind, or `query` on a tracker that is not an X search, fails with 422 `validation-failed`. ### 3. Reaching live [#3-reaching-live] Live does not accept new or changed trackers directly, from Console or from API keys. A data tracker reaches live under the same key, still owned by data, through a [promotion](/docs/concepts/promotion) from test. See [test and live](/docs/concepts/environments). Pause, resume and sync work in both environments, as they do on trackers in code. Archive and unarchive are definition changes, so make them in test. A promotion copies the tracker, not the connections it reads. A Discord or Telegram tracker records nothing in live until live has its own Discord server or Telegram chats connected in **Integrations**, and the tracker says so in live until then. > [!DEV] > Live refuses `create` and `update` with 422 `promotion-required`. ### Code or data owns each key [#code-or-data-owns-each-key] A key belongs either to code or to data, and only its owner changes the definition. Deployments never change or remove data trackers. To move a tracker between code and data, create one with a new key. > [!DEV] > Tracker status shows the owner as `origin`: `code` or `data`. Deploying a `defineTracker` over a data key fails with 409 `tracker-owned-by-data`; `create` or `update` on a code key fails with 409 `tracker-owned-by-code`; creating a data key twice fails with 409 `already-exists`. See [code or data owns each key](/docs/reference/trackers#code-or-data-owns-each-key). ## Inspect a post’s quest outcomes [#inspect-a-posts-quest-outcomes] Open **Community → Trackers**, select a tracker, and choose **Quest outcomes** on a stored record. Content quests on X and custom content sources show qualification, performance and engagement alongside shared admission outcomes. Search previews have no quest outcomes. | Section | What it explains | | ------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | Admission | Whether the quest admitted the post, or what blocks it. | | Qualification | The current content decision, original reward outcome, and evaluation history. | | Performance | The separate metric gate, sampled counters, and evaluation history when the quest has a performance stage. | | Engagement | Paid, pending, excluded, and earned points for each qualifying rule, plus counter baselines, settlement receipts, and formula history. | Choose **Open quest** to use the existing quest controls, or **Review in Activity** to open an attempt awaiting review. **Retry admission** releases eligible historical content into evaluation and does not approve its content or guarantee a reward. Relay derives the author’s member from its verified account; retry requires review permission. Use **Reload outcomes** to refresh the post’s decisions and accounting. Formula-transition history distinguishes the provider observation time from the time points were credited. Budget-excluded points stay excluded; losing qualification stops future accrual while prior awards remain. ## Read and filter the dataset [#read-and-filter-the-dataset] Your server can read everything a tracker collected and filter it, for example to verified authors with at least ten likes, or to one member's posts. Use the management SDK on your server with a management credential that has `read` permission. Keep the credential out of browser bundles. ```ts import { createManagementClient } from "@domino-sdk/relay/server"; import { xTweetSchema } from "@domino-sdk/relay/schema"; const management = createManagementClient({ baseUrl: "https://api.example.com", organization: "my-organization", project: "community", environment: "test", getToken: async () => "SERVER_SIDE_MANAGEMENT_KEY", }); const page = await management.trackers.records("launch-posts", { verified: true, minLikes: 10, limit: 25, }); const tweets = page.items.map((record) => xTweetSchema.parse(record.data)); const totals = await management.trackers.summary("launch-posts", { member: "member_existing", verified: true, }); ``` Missing engagement counts stay unknown rather than zero, and posts X stops returning are still refreshed. Check freshness and coverage before treating a total as enough evidence. > [!DEV] > The reference covers the details: > > - [Record filters](/docs/reference/trackers#record-filters): `verified`, `member`, `minLikes`, `minViews`, `author`, `since`, `until`, `removed` and `cursor`. > - [Content revisions and observations](/docs/reference/trackers#content-revisions-and-observations): `includeEvidence: true` and when `contentRevision` and `observation` change. > - [Captured posts and account attribution](/docs/reference/trackers#captured-posts-and-account-attribution): `trackers.content` and its `linked`, `unlinked` and `different-author` results. > - [What a record contains](/docs/reference/trackers#what-a-record-contains): fields, missing metrics, overlapping trackers, pause, and synthetic test records. ## Use tracker evidence in a quest [#use-tracker-evidence-in-a-quest] A quest can look through a tracker when it checks a member's submission. This example accepts one claim when the connected member has at least one verified post with ten likes: ```text import { defineQuest, settings, manual, once, points, decision, inGroup } from "@domino-sdk/relay/authoring"; import { community } from "./ledgers"; import { creators } from "./groups"; import { launchPosts } from "./trackers/launch-posts"; export default defineQuest({ key: "launch-contribution", title: "Share the launch", settings: settings.object({}), trigger: manual(), completion: once(), // Only approved creators can be rewarded for posting on X. requires: [inGroup(creators)], rewards: [points(community, 50)], async evaluate({ trackers, member }) { const result = await trackers.summary(launchPosts, { member: member.id, verified: true, minLikes: 10, }); return result.records > 0 ? decision.accept() : decision.reject({ reason: "No qualifying post has been collected yet." }); }, }); ``` Publish the imported `community` ledger and `launchPosts` tracker in the same project. `creators` is `defineGroup({ key: "approved-creators", title: "Approved creators" })`; create the group in Console **Groups** and add the creators you approve. Use `trackers.records` when your code needs individual content, or `decision.review` when a person should decide. * **Quest rules still apply.** Normal quest reward bounds, completion deduplication, review, and leaderboard scoring still apply. * **Reads are point in time.** A quest attempt sees the records stored when it first read the tracker, and its retries see the same. See [point-in-time reads](/docs/reference/trackers#point-in-time-reads-in-a-quest). * **Each quest decides.** Different quests can qualify the same post differently. > [!NOTE] Trackers and rewards are separate > A tracker has no reward policy. Attach a content quest below to evaluate new X matches, or a [tracker trigger](#trigger-a-quest-from-tracker-records) to evaluate new wallet records or newly collected X posts. Reward reversals remain separate future work. ## Admit X posts automatically [#admit-x-posts-automatically] A content quest checks each collected post on its own and rewards it once, with nothing for the member to submit. It suits "post about our launch" campaigns: every qualifying post from an approved creator earns the reward, and a post that is edited or keeps collecting likes never pays twice. Use `content({ tracker, ...policy })` to evaluate one collected content item at a time. Content quests complete once per item automatically, so omit `completion`. X search provides a normalized post body and six counters; custom sources can [declare their own body and metrics](#declare-custom-content). Publish the tracker and quest in the same project deployment, or publish the tracker first. ```ts import { defineQuest, defineTracker, defineGroup, inGroup, x, settings, content, decision } from "@domino-sdk/relay/authoring"; const posts = defineTracker({ key: "launch-posts", title: "Launch posts", source: x.search({ query: "#ourlaunch" }), window: { start: Date.parse("2026-09-01T00:00:00Z") }, }); // Operators add the creators they approve in Console Groups. const creators = defineGroup({ key: "approved-creators", title: "Approved creators" }); export default defineQuest({ key: "launch-post", title: "Share a launch post", settings: settings.object({}), trigger: content({ tracker: posts, enrollment: "required", since: "window", historical: "claim", }), requires: [inGroup(creators)], rewards: [], async evaluate({ tracker }) { const post = tracker.content.body; if (post.isRetweet) { return decision.reject({ reason: "Publish an original post." }); } return decision.accept(); }, }); ``` The tracker must also have its own published tracker entry. Import that definition into the quest in your project to keep its window consistent. Add your declared rewards to `rewards` when ready. The `inGroup(creators)` prerequisite admits only posts by members of the approved-creators group; create it and add members in Console **Groups**, with `domino groups`, or with `management.groups`. See [groups](/docs/concepts/groups). A post counts only once Domino can tie it to a member: through a verified X account a member of your project linked, never through a matching username or a verification badge. Choose who takes part and from when: | Choice | Option | What it means | | ----------------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Members sign up first | `enrollment` | `"required"` counts only members who enrolled in this quest. The default, `"none"`, counts everyone. | | Which posts count | `since` | `"activation"` (the default): posts since the quest was published. `"window"`: any post within the tracker's dates. `"enrollment"`: posts after each member enrolled; it needs `enrollment: "required"`. | | Posts from before they linked | `historical` | `"claim"` (the default): the member claims them. `"automatic"`: they count. `"staff"`: a reviewer releases them. | Posts must be published within the tracker's dates. A claim cannot get around dates, enrollment, ownership, prerequisites, or a paused quest. See [content quest admission](/docs/reference/trackers#content-quest-admission) for how admission pins a post's rules. ### Enroll and release historical posts [#enroll-and-release-historical-posts] Content quests use the same enrollment, outcome and historical-claim APIs as other tracker quests. See [member outcomes and claims](#let-members-enroll-read-their-outcomes-and-claim) for a complete example. Operators can retry a skipped post in Console under **Quest outcomes → Admission**. A retry releases historical activity but cannot bypass other admission rules. > [!DEV] > Members call `relay.quests.enroll`, `relay.quests.outcomes` and `relay.quests.claim`; no claim input names a member. Relay uses the session's member and the outcome's `tracker`, `record` and `observedAt`. Operators read `management.trackers.triggerOutcomes(tracker, { quest, record?, cursor? })`, passing a page's `nextCursor` back as `cursor`, and call `management.trackers.retryOutcome(tracker, { actionId, quest, record, observedAt })` with `review` permission. Enroll a member with `management.quests.enroll({ actionId, quest, member })` and `enroll` permission. > [!IMPORTANT] Current scope > Existing tracker records are not backfilled when a content quest is published. Pausing the quest blocks admission and new content evaluations. Removal does not reverse an existing award. Attempt retries keep their pinned content. Use a new quest key to change its tracker or switch between content and another trigger. ### Follow qualification after an edit [#follow-qualification-after-an-edit] When a member edits an admitted post, Domino checks the new version again. The post keeps the reward it already earned: a later qualifying edit pays nothing more, and a post that stops qualifying keeps its earlier award, because automatic reversals are not implemented. Disconnecting the account, relinking it to another member, or removing the post blocks rewards that have not been paid yet. Add an independent [engagement rule](#settle-engagement-points-daily) for daily growth payouts. > [!DEV] > Read the evaluation history with `relay.content.qualifications({ quest, content? })` in a participant app, or `management.content.qualifications({ quest, member?, content?, cursor? })` with `read` permission. See [qualification after an edit](/docs/reference/trackers#qualification-after-an-edit) for the states, fields and re-evaluation rules. ### Require an engagement threshold before awarding [#require-an-engagement-threshold-before-awarding] Add a `performance` stage when the reward should wait for a post to perform, such as reaching 100 likes: ```ts import { defineQuest, defineTracker, defineGroup, inGroup, x, content, settings, decision } from "@domino-sdk/relay/authoring"; const posts = defineTracker({ key: "launch-posts", title: "Launch posts", source: x.search({ query: "#launch" }), window: { start: Date.parse("2026-09-01T00:00:00Z") }, }); const creators = defineGroup({ key: "approved-creators", title: "Approved creators" }); export default defineQuest({ key: "popular-launch-post", title: "Publish a launch post that reaches 100 likes", settings: settings.object({}), trigger: content({ tracker: posts, historical: "automatic" }), requires: [inGroup(creators)], rewards: [], evaluate: async ({ tracker }) => tracker.content.body.text.includes("#launch") ? decision.accept() : decision.reject({ reason: "Include #launch." }), performance: { metrics: ["likes"], maxAgeMs: 3_600_000, evaluate: ({ metrics }) => metrics.likes >= 100 ? decision.accept() : decision.reject({ reason: "Reach 100 likes." }), }, }); ``` Publish the tracker as its own entry or in the same deployment, then add the desired rewards. The content check and the performance check are separate, and both must pass before the reward pays. At 20 likes, performance can reject while the content stays qualified. A later sample at 150 likes runs performance again and can issue the reward. If the likes later drop, the reward stays; if they recover, it does not pay again. > [!DEV] > X provides the metrics `likes`, `replies`, `reposts`, `quotes`, `views`, and `bookmarks`; a custom source provides its own names. `maxAgeMs` defaults to one hour and accepts up to 24 hours. A performance decision is `accept`, `review` or `reject` and cannot carry `rewards`. Read status and history with `relay.content.performance` or `management.content.performance`. See [performance stage](/docs/reference/trackers#performance-stage). ### Declare custom content [#declare-custom-content] Use `customSource(namespace, { content })` when each record represents a creator's content item, such as a video on another platform. Declare a Zod object for `body` and the counter names that performance and engagement may read. This replaces the source's `data` option. ```ts import { z, customSource, defineTracker, defineQuest, content, settings, decision } from "@domino-sdk/relay/authoring"; const videos = defineTracker({ key: "creator-videos", title: "Creator videos", source: customSource("videos", { identities: ["member"], content: { body: z.object({ title: z.string(), url: z.string() }), metrics: ["plays", "likes"], }, }), window: { start: Date.parse("2026-09-01T00:00:00Z") }, }); export default defineQuest({ key: "launch-video", title: "Publish a launch video", settings: settings.object({}), trigger: content({ tracker: videos, historical: "automatic" }), rewards: [], evaluate: async ({ tracker }) => tracker.content.body.title.includes("Domino") ? decision.accept() : decision.reject({ reason: "Mention Domino in the title." }), performance: { metrics: ["plays"], evaluate: ({ metrics }) => metrics.plays >= 100 ? decision.accept() : decision.reject({ reason: "Reach 100 plays." }), }, }); ``` Ingest records with `data: { body: { title, url }, metrics: { plays, likes } }`. The record ID identifies the content item, `author` identifies its creator through the declared identity kinds, and `occurredAt` is its publication time. Update the same record with a later `observedAt` for edits and new counter samples. Ingestion enforces the body schema and declared metric names. Omit an unmeasured counter; a missing value is unknown, while zero is a real measurement. Metrics are nonnegative whole numbers. A plain custom source without a content declaration and Discord sources cannot use `content()`. ## Feed another source, including a blockchain indexer [#feed-another-source-including-a-blockchain-indexer] When the activity happens somewhere Domino doesn't watch, such as your shop, your game or a blockchain, your server can send it to a custom tracker. Declare a custom source and the shape of each record's `data`, as a zod object schema: ```ts import { defineTracker, customSource, z } from "@domino-sdk/relay/authoring"; export default defineTracker({ key: "transfers", title: "Token transfers", source: customSource("chain-transfers", { data: z.object({ chain: z.string(), // Base-unit amounts as decimal strings: large amounts overflow JavaScript numbers. amount: z.string(), confirmations: z.number().int().min(0), }), }), window: { start: Date.parse("2026-09-01T00:00:00Z") }, }); ``` A server sends records through `management.trackers.ingest(reference, { actionId, records })` with a management key that has `ingest` permission. `ingest` allows custom tracker records and nothing else, so it suits an indexer: in Console, open **Settings → API keys**, and under **Create an API key** select only **Ingest**. Editors hold `ingest`, and a key never exceeds its creator's permissions. A hosted app's own key (`hostedManagementClient(env)`) has `ingest`. Declare a [scheduled job](/docs/build/schedules#feed-a-custom-tracker) when external records need refreshing independently of member visits. Verify Relay's signed call before collecting data, then use the run ID as ingestion's `actionId`. For example code, open the tracker in **Community → Trackers** and select **Send records**. Console opens it on its own while a custom tracker has no records and is not paused. The example uses the selected project and environment, your tracker's key, one record with an author and named accounts of the source's identity kinds, and `data` that matches its schema. Select **Copy code**, or **Copy prompt** or **Send to Build** to hand it to a coding agent. Replace the placeholder IDs and data with your own. A tracker without a data schema shows that it refuses records instead. Under **Ingestion trackers**, choose **Selected trackers only** to restrict the key to specific custom trackers. **Your active keys** lists the stored tracker IDs; **Edit tracker scope** changes the restriction without rotating the secret. No selection means all custom trackers in the key's project and environment. A denied tracker stores no records. The restriction applies to both tracker-key and tracker-ID ingestion calls. Hosted app keys keep project-wide ingestion. > [!DEV] > A denied tracker returns 403 `TRACKER_SCOPE_DENIED`. The management client accepts `ingestTrackers` on `tokens.create` and `tokens.scope(id, { ingestTrackers })`. Use up to 100 custom tracker keys or IDs; Relay resolves them to immutable IDs. Pass `null` to remove a restriction. An empty list is invalid. Keys cannot exceed the owner's current permissions or cross project/environment boundaries. A personal API key expires after 30 days by default, and **Your active keys** shows when. `tokens.create` takes `days` from 1 to 90 (default 30), as does `domino tokens create --days`. A backend whose key must not expire uses a [service key](/docs/start/existing-app#5-call-domino-from-your-backend), which an organization admin creates in Console. See [key commands](/docs/reference/management#restrict-an-ingestion-key) for human CLI sign-ins, which issue test keys only (create a live key in Console), and uncertain-response recovery. | Record field | Required | Notes | | ------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------- | | `id` | Yes | | | `occurredAt` | Yes | When the event happened. | | `observedAt` | Yes | Orders updates to the same record. | | `data` | Yes | A JSON object of at most 128 KB that matches the source's [data schema](#declare-record-data). | | `author` | No | `{ id, kind? }`. Required when the source declares `identities`. | | `accounts` | No | Named accounts by role, such as `{ recipient: { kind: "x", id: "…" } }`. Only roles the source declares; `kind` as for `author`. | | `removed` | No | Marks the record removed. | * **Batches.** Batches accept up to 100 records, each with at most 128 KB of data, within the tracker window. Batches into one tracker are stored one at a time, so concurrent calls into the same tracker wait for each other: send many records in one batch rather than one call per record or member. * **Retries.** Reuse an action ID only when retrying exactly the same request. An action ID is 1–100 letters, digits, `_` or `-`, so the management client refuses `ingest:member:2026-10-20` before sending it, with an `invalid-input` error that names `actionId`. For a retry-stable ID, hash what makes the batch itself to hex, such as each record's `id` and `observedAt`: `` `ingest-${createHash("sha256").update(JSON.stringify(records.map((r) => [r.id, r.observedAt]))).digest("hex")}` ``. An ID from one record's `id` alone comes back with a newer observation of that record, which is a different request and is refused with `action-id-reused`. For a one-off write, use `crypto.randomUUID()`. * **Newer observations win.** A newer `observedAt` updates the current record while preserving the prior snapshot. Older observations cannot overwrite newer values; conflicting data at the same observation time is rejected. * **Custom records stay tracker-local.** Reusing a namespace or record ID in another custom tracker does not share or overwrite its records. ### Declare record data [#declare-record-data] `customSource(namespace, { data })` requires a schema, and Domino holds every record to it. A record that doesn't match is refused with its batch, quests read the data with its declared types, and trigger filters are checked against it. > [!DEV] > `z` is zod, exported by `@domino-sdk/relay/authoring`. A refused batch answers 422 with the code `invalid-record-data`, the `record` ID, the failing `path` and a `reason`; nothing in it is stored, so resend the corrected batch with the same action ID. Relay stores the schema as JSON Schema, so string formats, `.refine()`, `.transform()` and some other zod features are refused when you define the source. See [record data schemas](/docs/reference/trackers#record-data-schemas) for what is supported, the size and complexity limits, changing a schema, and `tracker-schema-required`. ### Blockchain events [#blockchain-events] Use a stable chain/transaction/log identity as the record ID. Your indexer owns chain access, decoding, confirmations, and reorganization detection. It can mark an event `removed: true` with a newer observation, and reads exclude removed records by default. The tracker does not operate a blockchain node or reverse rewards automatically. ## Attribute records to members [#attribute-records-to-members] For a quest to pay someone, each record has to point at a member. Declare the identity kinds a custom source's accounts can be when records belong to members, so quests can [trigger from them](#trigger-a-quest-from-tracker-records): | Kind | Account ID | Attributed to | | -------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `wallet` | The wallet's canonical CAIP-10 account ID | The member who currently links that wallet in the project | | `x` | The stable numeric X user ID, not the handle | The member whose selected, verified X account has that ID | | `discord` | The stable numeric Discord user ID | The member whose selected, verified Discord account has that ID | | `member` | A Domino member ID | That member | | `app:`, such as `app:game` | The stable user ID your app signs the user in with | The member your [app login](/docs/build/app-login#link-your-apps-user-ids) integration of that kind signed in with that ID in the project | A record has an `author`, and can name other accounts in roles the source declares with `accounts`. Each account states its `kind` unless the source declares exactly one: ```ts import { defineTracker, customSource, z } from "@domino-sdk/relay/authoring"; // Each record's author is a Discord or X account; a record can also name a recipient. export const kudos = defineTracker({ key: "kudos", title: "Kudos", source: customSource("kudos", { identities: ["discord", "x"], accounts: ["recipient"], data: z.object({ message: z.string() }), }), window: { start: Date.parse("2026-10-01T00:00:00Z") }, }); // Sent with management.trackers.ingest("kudos", { actionId, records: [record] }). const record = { id: "kudos-1842", author: { kind: "discord", id: "1180234567890123456" }, accounts: { recipient: { kind: "x", id: "1492837465" } }, occurredAt: Date.now(), observedAt: Date.now(), data: { message: "Thanks for the walkthrough" }, }; ``` > [!WARNING] Your server names the accounts > Relay checks that each account is one a member verified, not that the account took part in the event: a record can name any member ID (`member` kind) or any linked account, and a quest pays that member. Give `ingest` keys only to servers you trust with that, such as your own indexer. A published custom tracker's namespace, identities and accounts cannot change; use a new key. Records with an undeclared kind or role, or an ID in the wrong form, are refused. See [source and identity kinds](/docs/reference/trackers#source-and-identity-kinds) for ingestion checks and reading kinds as an open set. ### Find an X account's user ID [#find-an-x-accounts-user-id] Trackers, the `--author` read filter, custom `x` identities and `ctx.x.follows({ userId })` take an X account's stable numeric user ID, not its handle. Resolve a handle with a credential that has `read` permission: ```sh domino x lookup @XDevelopers --json ``` ```ts import { createManagementClient } from "@domino-sdk/relay/server"; const management = createManagementClient({ baseUrl: "https://api.example.com", organization: "my-organization", project: "community", environment: "test", getToken: async () => "SERVER_SIDE_MANAGEMENT_KEY", }); // { id, username, name } for the account that holds the handle. const account = await management.x.lookup({ username: "@XDevelopers" }); console.log(account.id); ``` The handle can start with `@`; X ignores its letter case. `username` comes back as X shows it now. * **A lookup resolves the handle, nothing more.** It tells you which account held the handle when Domino's X provider last read it, at most a day ago. It does not show who owns or controls that account, and a handle can pass to another account later. Store the `id`, and attribute records to members only through their verified links. * **Lookups share Domino's X quota.** Each user can make 30 lookups a minute, and an organization 300 an hour. Resolve a campaign's list once and keep the IDs. * **X has to be configured.** Lookups need Domino's X provider. > [!DEV] > A handle no X account holds fails with 404 `not-found`, for 30 minutes after the first miss. Past the quota a lookup fails with 429 `rate-limited`. Without Domino's X provider it fails with 422 `not-configured`; when X is busy it returns 429 `rate-limited`, and when X fails, 422 `upstream-failed`. ### Your app's user IDs [#your-apps-user-ids] When your app knows its users by its own IDs, such as a game account or a Telegram user ID, create its [app login](/docs/build/app-login#link-your-apps-user-ids) integration with an identity kind such as `app:game`, and declare that kind. Records then name your app's user IDs, and Relay resolves each to the member your app signed in with it: ```ts import { defineTracker, customSource, z } from "@domino-sdk/relay/authoring"; export const wins = defineTracker({ key: "game-wins", title: "Game wins", source: customSource("game-wins", { identities: ["app:game"], data: z.object({ score: z.number() }), }), window: { start: Date.parse("2026-10-01T00:00:00Z") }, }); // The author is the user ID your server passes to the app login integration. const record = { id: "match-90311", author: { id: "player-4417" }, occurredAt: Date.now(), observedAt: Date.now(), data: { score: 3 }, }; ``` A user ID is linked when the user signs in through your app. A record for a user ID no member has signed in with yet, or one an operator [unlinked](/docs/build/app-login#unlink-an-app-identity), is skipped with `unlinked-account`. Disconnecting the app login key keeps its links. The rules for naming an app kind are in [source and identity kinds](/docs/reference/trackers#source-and-identity-kinds). ### Wallet accounts [#wallet-accounts] For records that belong to wallets, such as balance samples, declare `customSource("token-balance", { identities: ["wallet"], data })`. Each wallet account's ID must be its canonical CAIP-10 account ID, the form Domino stores for [linked wallets](wallets.md): `cosmos:cosmoshub-4:cosmos1…` (any Cosmos chain ID, lowercase bech32), `eip155:1:0xAbC…` (EIP-55 checksum), or `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:…` (base58). `caip10(wallet)` from `@domino-sdk/relay/schema` builds it from a linked wallet, such as an entry of `(await management.members.get(id)).wallets`. For a value sampled per wallet, use the account ID as the record ID too, so the tracker holds one current sample per wallet: ```ts import { createHash } from "node:crypto"; import { createManagementClient } from "@domino-sdk/relay/server"; const management = createManagementClient({ baseUrl: "https://api.example.com", organization: "my-organization", project: "community", environment: "test", getToken: async () => "SERVER_SIDE_MANAGEMENT_KEY", }); const account = "cosmos:cosmoshub-4:cosmos19rl4cm2hmr8afy4kldpxz3fka4jguq0auqdal4"; const sampledAt = Date.now(); await management.trackers.ingest("token-balance", { // Hash the external account ID, which contains colons, into identifier-safe text. actionId: `token-balance-${createHash("sha256").update(account).digest("hex")}-${sampledAt}`, records: [ { id: account, author: { id: account }, occurredAt: sampledAt, observedAt: sampledAt, // Base-unit amounts as decimal strings: large balances overflow JavaScript numbers. data: { amount: "1523400000", decimals: 6 }, }, ], }); ``` * **Wallet IDs are canonical.** A wallet account in any other form is refused with 422, and the error names the expected canonical ID. * **A Cosmos wallet is its address.** A Cosmos account ID is attributed to the member who links that bech32 address, whichever chain ID it names. `caip10(wallet)` uses the chain the wallet was linked on. * **A smart-contract wallet is its chain's account.** An EVM account ID is attributed to a smart-contract wallet's member only when it names the chain that proved the wallet (`provedOn`); on another chain it is unlinked. `caip10(wallet)` uses that chain. Read one member's current samples with `management.trackers.records("token-balance", { member })`. It returns records for every wallet the member currently links, with a Cosmos wallet's records under any chain ID and a smart-contract wallet's only under the chain that proved it; an unlinked wallet's records drop out. [Rank an external score](leaderboards.md#rank-an-external-score) samples every linked wallet this way through a `readBalance(account)` function your app supplies. ## Trigger a quest from tracker records [#trigger-a-quest-from-tracker-records] A tracker trigger runs a quest by itself each time new activity arrives, for the member behind it. Use it to reward a balance that crosses a threshold, a reply from your account, posting several days in a row, or kudos a member receives. You choose three things: | Choice | Option | Default | | ------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | When it runs | `on` | Each new record on X, Discord and Telegram trackers (`"new-record"`); each new sample on other custom trackers (`"new-observation"`), which in SDK 1.x include YouTube Shorts, TikTok and wallet balance trackers, so each refresh or daily sample runs it again. `"data-changed"` runs only when a record's data changes. A published quest keeps the mode it was published with. | | Which records count | `where` | Every record. A filter on the record's data, such as a balance at or above a threshold operators can change. | | Who it pays | `beneficiary` | The record's author. Or the author of the post a reply answers, the author of a message someone reacted to, or an account the record names. | A record never pays its own author through another role, so nobody earns from replying to themselves or reacting to their own message. Quest code cannot choose another member: Domino finds the member through verified account links. This quest completes once, when a wallet sample first reaches a threshold operators can change: ```text import { automatic, customSource, decision, defineQuest, defineTracker, once, points, settings, z, } from "@domino-sdk/relay/authoring"; import { pointsLedger } from "./ledgers"; const tokenBalance = defineTracker({ key: "token-balance", title: "Token balance", source: customSource("token-balance", { identities: ["wallet"], data: z.object({ amount: z.string(), decimals: z.number().int() }), }), window: { start: Date.parse("2026-10-01T00:00:00Z") }, }); export default defineQuest({ key: "balance-threshold", title: "Reach a balance threshold", settings: settings.object({ // Base units, exact at any scale; Console shows and edits whole tokens. threshold: settings.amount({ label: "Threshold", default: "1000000000000000000", decimals: 18 }), }), // Only changed samples at or above the setting run the quest; the rest cost nothing. trigger: automatic({ tracker: tokenBalance, on: "data-changed", where: { path: "amount", gte: { setting: "threshold" } }, }), completion: once(), review: () => ({ enabled: false }), rewards: [points(pointsLedger, 1000)], async evaluate() { return decision.accept(); }, }); ``` A quest that totals several accounts reads them in its evaluator instead, such as `trackers.records(tokenBalance, { member: member.id })` for every wallet the member links, because a filter judges one record. `packages/sdk/examples/hold-balance-quest.ts` totals linked wallets that way. In a project, keep the tracker in its own module listed under `trackers` in `relay.json` and import it from the quest. Publish both in the same deployment, or the tracker first. > [!TIP] Let unchanged samples cost nothing > With `on: "new-observation"`, every sample admits an attempt until a `once()` quest completes. Use `"data-changed"` and a `where` filter so unchanged or unqualifying samples cost nothing, or send samples when values change. > [!DEV] > The call is `automatic({ tracker, on, where, beneficiary })`. A filter that never matches records no outcome; check `admissions.filtered` in the `ingest` answer when a quest does not run. See [tracker trigger options](/docs/reference/trackers#tracker-trigger-options) for every `on` mode, `where` matching and type-checking, `beneficiary` roles, what `ctx.tracker` holds, enrollment and historical options, completions, and publication rules. ### Reward members when an account replies to them [#reward-members-when-an-account-replies-to-them] An X tracker that collects an account's replies can pay the member each reply answers, with `beneficiary: "inReplyTo"`. This quest pays for each reply from `@examplelabs`, at most seven a week per member. Operators choose in Console what a reply must contain, such as a keyword or an emoji: ```text import { automatic, budget, decision, defineQuest, defineTracker, points, settings, x, } from "@domino-sdk/relay/authoring"; import { pointsLedger } from "./ledgers"; const replies = defineTracker({ key: "examplelabs-replies", title: "Replies from @examplelabs", // Collect every reply; the quest checks the text, so changing it needs no new tracker. source: x.search({ query: "(from:examplelabs) filter:replies" }), window: { start: Date.parse("2026-10-01T00:00:00Z") }, refreshSeconds: 900, }); export default defineQuest({ key: "account-reply", title: "Get a reply from @examplelabs on X", settings: settings.object({ requiredText: settings.text({ label: "Required text", description: "Text a reply must contain. Empty: every reply counts.", default: "", minLength: 0, maxLength: 100, }), }), trigger: automatic({ tracker: replies, beneficiary: "inReplyTo" }), completion: ({ keyed }) => keyed(({ tracker }) => `x-reply:${tracker.record.id}`), budgets: [budget({ key: "replies", scope: "member", period: "week", limit: 7, cost: 1 })], review: () => ({ enabled: false }), rewards: [points(pointsLedger, 500)], async evaluate({ tracker, settings }) { const reply = tracker.content!.body; const required = settings.requiredText.trim().replaceAll("️", "").toLowerCase(); if (!reply.isReply) { return decision.reject({ reason: "The post is not a reply." }); } return !required || reply.text.replaceAll("️", "").toLowerCase().includes(required) ? decision.accept() : decision.reject({ reason: `The reply does not contain "${settings.requiredText.trim()}".` }); }, }); ``` * **Each reply pays once.** The completion key is the reply's post ID, so a second reply to the same member post pays again. Key on `tracker.content.body.references.replyId` instead to pay once per member post. * **The weekly budget caps it.** `period: "week"` counts per ISO week of the [project calendar](/docs/concepts/calendar), starting Monday at midnight in the project timezone, by `eligibleAt` (when the reply was published). The eighth accepted reply in a week is rejected with "Budget exhausted: replies"; the next Monday starts a new week. * **Identity is the stable ID.** The search matches handles, which can change owner. To pin the replying account, compare `reply.authorId` with the account's stable X user ID, which [`domino x lookup HANDLE`](#find-an-x-accounts-user-id) returns. * **Self-replies never pay.** A reply to the replying account's own post, or to another account of the same member, is skipped with `beneficiary-is-author`. * **Members link X first.** A reply to someone with no selected, verified X account is skipped with "No member has linked this X account". When they link it, the reply is decided again; it was published before the link, so it is `historical` and counts once a reviewer retries it. Set `historical: "claim"` to let the member claim it, or `"automatic"` to admit it. `packages/sdk/examples/account-reply-quest.ts` is a complete version: operators also set the replying accounts' stable IDs and the points per reply. ### Reward members when an account quotes or reposts them [#reward-members-when-an-account-quotes-or-reposts-them] An X tracker that collects an account's reposts or quotes can pay the member whose post it picked up: `beneficiary: "reposted"` pays the author of the reposted post, and `beneficiary: "quoted"` the author of the quoted post. This quest pays 100 points each time `@examplelabs` reposts a member's post: ```text import { automatic, decision, defineQuest, defineTracker, points, x } from "@domino-sdk/relay/authoring"; import { pointsLedger } from "./ledgers"; const reposts = defineTracker({ key: "examplelabs-reposts", title: "Reposts by @examplelabs", source: x.search({ query: "from:examplelabs filter:nativeretweets" }), window: { start: Date.parse("2026-10-01T00:00:00Z") }, }); export default defineQuest({ key: "get-noticed", title: "Get reposted by @examplelabs on X", trigger: automatic({ tracker: reposts, beneficiary: "reposted" }), completion: ({ keyed }) => keyed(({ tracker }) => `x-repost:${tracker.record.id}`), review: () => ({ enabled: false }), rewards: [points(pointsLedger, 100)], async evaluate() { return decision.accept(); }, }); ``` * **Quotes work the same way.** Collect `from:examplelabs filter:quote` and pay `beneficiary: "quoted"`. To reward both, publish one quest per role; each post the tracker collects pays each quest at most once. * **This isn't a reward for posting.** Like reply quests, it pays the member whose post an account you choose picked up, so it needs no approved-creators group and doesn't raise `x-post-rewards-ungated`. * **Self-quotes and self-reposts never pay.** A quote or repost of the account's own post, or of another account of the same member, is skipped with `beneficiary-is-author`. A post that is no quote or repost is skipped with "Post is not a quote" or "Post is not a repost". * **Members link X first.** A quote or repost of someone with no selected, verified X account is skipped with "No member has linked this X account" (`unlinked-account`), and is decided again when they link it. As with replies, a quote or repost published before they linked it is `historical`; the original post's date doesn't count. ### Reward consecutive days [#reward-consecutive-days] `streak(...)` on a tracker trigger rewards days of activity in a row, such as posting on X five days running or holding a balance for a week. Each accepted observation marks the [calendar day](/docs/concepts/quests#calendar-day-streaks) of its record's `occurredAt`, and the milestone pays once per member. This quest pays approved creators who post about `@examplelabs` on five consecutive days: ```text import { automatic, defineGroup, defineQuest, defineTracker, inGroup, points, settings, streak, x } from "@domino-sdk/relay/authoring"; import { pointsLedger } from "./ledgers"; // X does not allow rewarding everyone who posts: operators add approved creators in Console Groups. const creators = defineGroup({ key: "approved-creators", title: "Approved creators" }); const mentions = defineTracker({ key: "examplelabs-mentions", title: "Posts mentioning @examplelabs", source: x.search({ query: "@examplelabs -filter:replies" }), window: { start: Date.parse("2026-10-01T00:00:00Z") }, refreshSeconds: 900, }); export default defineQuest({ key: "posting-streak", title: "Post about @examplelabs five days in a row", settings: settings.object({}), // Runs once for each newly collected post, for the member whose verified X account wrote it. trigger: automatic({ tracker: mentions }), // Posts by members outside the group are skipped with prerequisites-incomplete. requires: [inGroup(creators)], completion: streak({ days: 5 }), review: () => ({ enabled: false }), rewards: [points(pointsLedger, 2500)], }); ``` * **A day is when the activity happened.** For a post, that is when it was published, not when the collector found it, so a post collected late fills its own day and can join two runs. * **Days are the project's.** A day ends at midnight in the [project calendar](/docs/concepts/calendar)'s timezone, UTC unless you choose another. * **One day counts once.** Several posts, or several samples of one record, on the same calendar day occupy one day. * **Activity before publication does not count.** Observations that occurred before the quest was published are skipped with `before-activation`. * **The milestone ends the quest for that member.** After it pays, the trigger admits nothing more for that member and skips their later observations with `already-completed`, as for a completed `once()` quest. For "hold 1,000 tokens for 7 days", use a [wallet balances tracker](#reward-holding-tokens): Domino samples every linked wallet once a day, so your app runs no balance job. A custom tracker can feed a streak too, for a value your server measures. Use the default `on: "new-observation"`, never `"data-changed"`, since an unchanged value still has to fill its day, and give every sample its own `occurredAt`, the time it was measured: the streak counts the day of `occurredAt`, so samples that keep reusing the record's first time all land on that one day. > [!WARNING] Your server sets a custom record's time > Relay trusts the `occurredAt` your server sends for a custom record. A key holder that can ingest into the tracker can backdate records to fill missed days, back to the quest's publication and within the tracker window. Keep `ingest` keys on servers you trust with that. `packages/sdk/examples/posting-streak-quest.ts` is the complete posting example. ### Reward the account a record names [#reward-the-account-a-record-names] A custom record can name accounts besides its author, in roles the source declares, and a quest can pay any of them. Kudos are one use: your Discord bot or app server records who thanked whom, and the quest pays the recipient. ```text import { automatic, budget, customSource, decision, defineQuest, defineTracker, points, settings, z, } from "@domino-sdk/relay/authoring"; import { pointsLedger } from "./ledgers"; const kudos = defineTracker({ key: "kudos", title: "Kudos", // The giver is the author, the recipient a named account: a Discord user ID or a member ID. source: customSource("kudos", { identities: ["discord", "member"], accounts: ["recipient"], data: z.object({ message: z.string().max(2000) }), }), window: { start: Date.parse("2026-10-01T00:00:00Z") }, }); export default defineQuest({ key: "kudos-received", title: "Receive kudos", settings: settings.object({}), // First sight of each kudos only: an edited message does not pay again. trigger: automatic({ tracker: kudos, on: "new-record", beneficiary: "recipient" }), completion: ({ keyed }) => keyed(({ tracker }) => `kudos:${tracker.record.id}`), budgets: [budget({ key: "kudos", scope: "member", period: "week", limit: 5, cost: 1 })], review: () => ({ enabled: false }), rewards: [points(pointsLedger, 50)], async evaluate() { return decision.accept(); }, }); ``` Each kudos your server sends is one record, with a stable ID such as the Discord interaction ID: ```ts const record = { id: "interaction-1842", author: { kind: "discord", id: "1180234567890123456" }, accounts: { recipient: { kind: "member", id: "member_existing" } }, occurredAt: Date.now(), observedAt: Date.now(), data: { message: "Thanks for the walkthrough" }, }; ``` * **Givers never pay themselves.** Kudos to the giver's own account are skipped with `beneficiary-is-author`, and so are kudos to another account of the giver's member once the giver's account belongs to exactly one member. * **The giver need not be a member.** Kudos from an account no member has linked still pay the recipient. If only members should give kudos, have your bot send kudos only from members. To limit what each giver gives, add a [per-giver budget](#cap-what-each-giver-gives). * **A rejected kudos stays rejected.** With `on: "new-record"`, only the first observation of a record runs the quest, so a kudos rejected once, such as for a message too short, and edited later never pays. * **The server vouches for the event.** Relay checks that the recipient is a verified identity, not that the kudos happened. Send records only from code you trust, with a key limited to **Ingest**. `packages/sdk/examples/kudos-quest.ts` is a complete version with a record helper and a minimum message length operators can change. ### Cap what each giver gives [#cap-what-each-giver-gives] When a quest pays someone other than the record's author, such as a reaction's message author, a kudos recipient or the member an account replied to, a `"member"` budget caps what each receiver gets. A budget with `scope: "author"` caps what each giver gives: it counts per record author, the account that caused the record. This tipping quest pays a message's author 10 points for each ⚡ reaction, at most 20 tips a day per receiver and 10 a day per reactor: ```text import { automatic, budget, decision, defineQuest, defineTracker, discord, points, settings } from "@domino-sdk/relay/authoring"; import { pointsLedger } from "./ledgers"; const reactions = defineTracker({ key: "discord-reactions", title: "Discord reactions", source: discord.reactions(), window: { start: Date.parse("2026-10-01T00:00:00Z") }, }); export default defineQuest({ key: "tip", title: "Get tipped", settings: settings.object({}), trigger: automatic({ tracker: reactions, beneficiary: "messageAuthor" }), // Every tip pays: one completion per reaction. completion: ({ keyed }) => keyed(({ tracker }) => tracker.record.id), budgets: [ budget({ key: "tips-received", scope: "member", period: "day", limit: 20, cost: 1 }), budget({ key: "tips-given", scope: "author", period: "day", limit: 10, cost: 1 }), ], review: () => ({ enabled: false }), rewards: [points(pointsLedger, 10)], async evaluate({ tracker }) { return tracker.record.data.emoji.name === "⚡" ? decision.accept() : decision.reject({ reason: "Tip with ⚡." }); }, }); ``` * **Givers are counted by account.** The cap counts the reactor's Discord account, whether or not a member linked it, so it holds for admins and moderators who never joined the project. A record with no author pays nothing from a quest with an author budget. * **Over either cap, the tip is refused.** The reactor's eleventh tip of the day is rejected with "Budget exhausted: tips-given", and it doesn't pay later; other reactors' tips keep paying. Days follow the [project calendar](/docs/concepts/calendar). * **Only for tracker triggers.** `domino check`, `domino build` and `domino deploy` refuse an author budget on a quest without a tracker trigger; `check` reports `AUTHOR_BUDGET_TRACKER_REQUIRED`. Quests that share an author budget's key share each giver's count, like any [shared budget](/docs/concepts/campaigns#campaign-wide-budgets-and-rankings). * **Members don't see it.** A member's quest `budgets` list leaves author budgets out, since they cap someone else's giving. A quest's published description lists them in `authorBudgets`, apart from `budgets`. ### Explain trigger outcomes [#explain-trigger-outcomes] Every record a quest's trigger selects ends up **Admitted**, with an attempt to check, or **Skipped**, with the first rule that stopped it and what to do about it. The most common skips: | Skip | Usually means | What happens next | | ------------------------------------------ | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | Not linked (`unlinked-account`) | No member has linked the account yet | Linking it decides the skip again. | | Not joined (`not-joined`) | The account's owner hasn't joined this project yet | Signing in to your app decides it again. | | Not enrolled (`not-enrolled`) | The quest, or the campaign it belongs to, requires enrollment; the outcome's `enrollment` says which | Enrolling decides it again, or an operator enrolls them. | | Historical (`historical`) | The activity came before the member linked the account or enrolled | A reviewer retries it, or the member claims it when the quest allows claims. | | Prerequisites (`prerequisites-incomplete`) | The member wasn't in a required group or hadn't finished a required quest | Adding them later does not decide it again; retry it if it should count. | | Already completed (`already-completed`) | A one-time quest or streak already paid | Nothing. | A quest budget doesn't skip a record: the record is admitted, and its attempt is rejected when the budget can't cover another completion. When a member links an account, removes a conflicting link, joins the project, or enrolls, Domino decides their skips with the matching code again, shortly after, under each quest's current rules. Activity from before the link or enrollment then meets the `historical` rule: see [Historical activity](/docs/concepts/trackers#historical-activity). The full list of codes, in the order Domino applies them, is in [trigger outcome codes](/docs/reference/trackers#trigger-outcome-codes). In Console, open **Community → Trackers**, select the tracker, and select **Quest outcomes** on a record, or open the quest and find **Trigger outcomes**. Each skip says what to do about its code. **Open attempt** shows the evaluation and its decision. **Retry admission** re-runs the rules for that observation under the quest's current release, links, membership, and enrollment, and releases historical activity; it needs `review` permission. When it still cannot admit, it shows the current reason and the outcome stays skipped. On a `not-enrolled` skip, **Enroll the member** enrolls them on their behalf; it needs `enroll` permission. An outcome a member claimed shows **Claimed**. From a server, read outcomes with `read` permission and retry with `review`: ```ts import { createHash } from "node:crypto"; import { createManagementClient } from "@domino-sdk/relay/server"; const management = createManagementClient({ baseUrl: "https://api.example.com", organization: "my-organization", project: "community", environment: "test", getToken: async () => "SERVER_SIDE_MANAGEMENT_KEY", }); const unlinked = await management.trackers.triggerOutcomes("token-balance", { quest: "balance-threshold", code: "unlinked-account", }); for (const outcome of unlinked.items) { await management.trackers.retryOutcome("token-balance", { // External record IDs can contain punctuation. Hash the tuple for a stable action ID. actionId: `retry-${createHash("sha256").update(JSON.stringify([outcome.quest, outcome.record, outcome.observedAt])).digest("hex")}`, quest: outcome.quest, record: outcome.record, observedAt: outcome.observedAt, }).catch(() => undefined); // 409 with the current reason when it still cannot admit } ``` > [!DEV] > Branch on `code`, never on `reason`, and show an unknown code by its `reason`. `triggerOutcomes` filters by `quest`, `record`, `member`, `state` and `code` and pages with `cursor`; a retry that still cannot admit fails with 409 and the skip `code`. See [trigger outcome codes](/docs/reference/trackers#trigger-outcome-codes). ### Let members enroll, read their outcomes, and claim [#let-members-enroll-read-their-outcomes-and-claim] In a signed-in participant app, members enroll in quests that require it, read their own outcomes, and claim historical activity on quests with `historical: "claim"`: ```ts import { createRelayClient } from "@domino-sdk/relay"; const relay = createRelayClient({ baseUrl: "/relay" }); await relay.quests.enroll({ actionId: crypto.randomUUID(), quest: "launch-post" }); const claimable = await relay.quests.outcomes({ quest: "launch-post", code: "historical" }); for (const outcome of claimable.items.filter((item) => item.claimable)) { await relay.quests.claim({ actionId: crypto.randomUUID(), quest: outcome.quest, tracker: outcome.tracker!, record: outcome.record!, observedAt: outcome.observedAt!, }); } ``` * **Members act only for themselves.** No input names a member; Relay uses the session's. Outcomes list only the decisions Relay attributed to the member, newest first, a page of up to 50: pass `cursor: page.nextCursor` while it is not null. * **Enrolling records consent.** Repeating an active enrollment keeps its current time. Re-enrolling after operator withdrawal starts a new consent time. A quest whose trigger does not require enrollment refuses with the code `enrollment-not-required`, and a paused quest with `quest-paused`. * **A claim releases only historical activity.** Relay decides the outcome again under the quest's current rules. A rule that still fails rejects with its skip code, such as `prerequisites-incomplete`; a skip that is not `historical`, or a quest that does not take claims, rejects with `not-claimable`. > [!DEV] > In React, `useQuestOutcomes({ quest, state, code })` from `@domino-sdk/relay/react` pages the same read; after enrolling or claiming, call `invalidate([relayKeys.outcomes])` from `useInvalidate()`. To rank members by the value itself, give it its own point ledger, updated with absolute values: see [Rank an external score](leaderboards.md#rank-an-external-score). ## Reward Discord messages and reactions [#reward-discord-messages-and-reactions] Reward members for taking part in your Discord server: posting in a channel, reacting with a certain emoji, or getting reactions on their own messages. Domino's Discord gateway writes your server's messages and reactions into Discord trackers as they happen. Connect the server to the project first, as described in [Connect Discord to a project](/docs/build/integrations#connect-discord-to-a-project). Without a ready connection, a Discord tracker stays empty. ### 1. Define the trackers [#1-define-the-trackers] Create `trackers/discord.ts` with the sources you need: ```ts import { defineTracker, discord } from "@domino-sdk/relay/authoring"; export const messages = defineTracker({ key: "discord-messages", title: "Discord messages", source: discord.messages(), window: { start: Date.parse("2026-10-01T00:00:00Z") }, }); export const reactions = defineTracker({ key: "discord-reactions", title: "Discord reactions", source: discord.reactions(), window: { start: Date.parse("2026-10-01T00:00:00Z") }, }); ``` ### 2. Add them to the manifest [#2-add-them-to-the-manifest] ```json { "trackers": [ { "entry": "trackers/discord.ts", "exportName": "messages" }, { "entry": "trackers/discord.ts", "exportName": "reactions" } ] } ``` ### 3. Deploy to test [#3-deploy-to-test-1] Run `domino deploy` in the project's test environment. Records arrive as activity happens in the server: Relay does not collect earlier activity. You cannot send records to a Discord tracker or refresh it. To try its quests in test without a Discord account, send a test member's message or reaction with `management.testMembers.record` ([Send X and Discord activity](/docs/use/quests#5-send-x-and-discord-activity)). ### What a Discord tracker stores [#what-a-discord-tracker-stores] * **Only members' activity is kept.** A record is stored only when at least one of its accounts is a Discord account a member of the tracker's project has linked: the poster of a message, or the reactor or the message's author for a reaction. Activity from before the account was linked is not stored and never arrives later. * **Text is opt-in.** `discord.messages({ text: true })` stores each message's text in `data.text`. It is `null` when Domino's bot does not receive message content; see [Read a message from quest code](/docs/build/integrations#read-a-message-from-quest-code). Publishing the tracker without the option erases stored text. * **Bots are left out.** Messages from bots and webhooks, and reactions added by bots, are not recorded. * **Threads count toward their channel.** A message or reaction in a thread, including a forum post, names both the thread and the channel it belongs to, so a quest can count a channel's threads. * **Edits and deletes are followed.** On a tracker that keeps text, an edit updates it. Deleting a message marks its record removed and erases its text. Removing a reaction changes nothing. Discord can leave gaps, such as reactions replayed after a reconnect; see [Coverage limits](/docs/build/integrations#coverage-limits). > [!DEV] > Each record's `id`, `author`, `accounts`, `occurredAt` and `data` fields, how edits and deletes reach triggers, and which trackers receive a record are in [Discord record fields](/docs/reference/trackers#discord-record-fields). In a thread, `data.channelId` is the thread's ID and `data.parentChannelId` the channel it belongs to; outside a thread `parentChannelId` is `null`. It is absent when Relay could not read the channel from Discord, and on records stored before Relay recorded it, so treat a missing field as unknown, not as outside a thread. `where: { path: "parentChannelId", equals: "…" }` selects a channel's threads only. A `where` cannot match either of two fields, so to count a channel and its threads, compare both in the evaluator, as in the example below. ### Telegram reactions [#telegram-reactions] `telegram.reactions()` records the reactions members add to messages in the project's connected Telegram groups and supergroups, one record per added reaction, with the reactor as `author` and the message's author as the `messageAuthor` account. Its `data` names the emoji and whether the reactor is a chat administrator, for a trigger's `where`. Like a Discord tracker, it keeps only reactions that involve a member's linked Telegram account, and you cannot send records to it. See [Reward reactions](/docs/build/telegram#reward-reactions). ### Telegram messages [#telegram-messages] `telegram.messages()` records the messages members post in the project's connected Telegram groups and supergroups, one record per message, with the poster as `author`. Its `data` names the chat, the message, the message it replies to and its topic thread, never the text, for a trigger's `where`. It keeps only messages whose poster linked Telegram in the project, and leaves out bots' messages, service messages such as joins and pins, and channel posts. An edit is the same message, and a deleted message stays recorded, since Telegram doesn't tell bots about deletions. You cannot send records to it. See [Reward messages](/docs/build/telegram#reward-messages). ### Trigger a quest [#trigger-a-quest] `automatic({ tracker })` on a Discord tracker runs once for each new message or reaction, with the default `on: "new-record"`, for the member whose linked Discord account is the beneficiary. A `where` value is fixed in code; check a value operators choose in Console, such as a channel, in the evaluator. This quest pays for a message in a channel operators choose, or in one of its threads, once per member per project-calendar day: ```ts import { automatic, decision, defineLedger, defineQuest, defineTracker, discord, keyed, points, settings, } from "@domino-sdk/relay/authoring"; const community = defineLedger({ key: "community", name: "Community" }); const messages = defineTracker({ key: "discord-messages", title: "Discord messages", source: discord.messages(), window: { start: Date.parse("2026-10-01T00:00:00Z") }, }); export default defineQuest({ key: "discord-daily-message", title: "Post in the community channel", // A Discord channel setting needs the quest to declare the integration. integrations: [discord], settings: settings.object({ channelId: settings.resource({ label: "Channel", source: discord.channels() }), }), trigger: automatic({ tracker: messages }), // The project day of the message: a second message that day does not pay again. completion: keyed(({ calendar }) => calendar.day().key), review: () => ({ enabled: false }), rewards: [points(community, 10)], async evaluate({ tracker, settings }) { const { channelId, parentChannelId } = tracker.record.data; // In a thread, channelId is the thread and parentChannelId the channel it belongs to. return channelId === settings.channelId || parentChannelId === settings.channelId ? decision.accept() : decision.reject({ reason: "The message is not in the community channel." }); }, }); ``` | To reward | Trigger | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | Posting a message | `automatic({ tracker: messages })` | | Adding a reaction | `automatic({ tracker: reactions })`, optionally with `where: { path: "emoji.name", equals: "🫡" }` | | Receiving a reaction | `automatic({ tracker: reactions, beneficiary: "messageAuthor" })` | | Days in a row | Any of the above with `streak(...)`. Each accepted record fills the [calendar day](/docs/concepts/quests#calendar-day-streaks) of its `occurredAt`. | When a quest pays the message's author: * **Reactions to one's own message never pay.** A reaction from the message's author, or from another Discord account of the same member, is skipped with `beneficiary-is-author`. * **The reactor need not be a member.** The record is kept because the message's author linked Discord. To pay only for reactions from certain people, such as moderators, check `data.reactorRoles` in the evaluator. * **An unnamed author is skipped.** When Discord could not name the message's author, the reaction has no `messageAuthor` account and is skipped with `no-account`. Members link Discord in your app first. A member whose linked account is the beneficiary but who has not joined the project is skipped with `not-joined`; when they sign in to your app, it is decided again. A reaction whose beneficiary no member has linked, such as the reactor when only the message's author is a member, is skipped with `unlinked-account`. Activity from before a member linked Discord is `historical`, and so is all activity of a member whose Discord connection predates account selection and has no link time: they select their Discord account again to give it one. See [Explain trigger outcomes](#explain-trigger-outcomes). ## Reward YouTube Shorts [#reward-youtube-shorts] `youtube.shorts()` collects the Shorts your members post on their own YouTube channels. A member links their channel in your app as a [creator account](/docs/concepts/participants#creator-accounts); from then on, the tracker finds their new Shorts and keeps each one's views, likes and comments up to date. There's no search: only channels your members linked are read, with each member's own permission. > [!NOTE] Available once Domino configures YouTube > Until Domino configures YouTube for the platform, members aren't offered a channel to link and YouTube trackers don't collect. ### 1. Define the tracker and a quest [#1-define-the-tracker-and-a-quest] A Short counts when it's at most three minutes long (one minute for uploads before 2024-10-15) and vertical or square. Use it with a content quest like any other content: here, a Short about the launch earns its reward once it reaches 1,000 views. ```ts import { defineQuest, defineTracker, youtube, content, settings, decision } from "@domino-sdk/relay/authoring"; const shorts = defineTracker({ key: "launch-shorts", title: "Launch Shorts", source: youtube.shorts(), window: { start: Date.parse("2026-11-01T00:00:00Z") }, // How often Domino looks for new Shorts and refreshes their counts, in seconds. // Your plan sets the shortest interval: six hours on every plan but Enterprise. refreshSeconds: 3600, }); export default defineQuest({ key: "launch-short", title: "Post a launch Short that reaches 1,000 views", settings: settings.object({}), trigger: content({ tracker: shorts }), rewards: [], evaluate: async ({ tracker }) => tracker.content.body.tags.includes("launch") ? decision.accept() : decision.reject({ reason: "Tag the Short with #launch." }), performance: { metrics: ["views"], evaluate: ({ metrics }) => metrics.views >= 1000 ? decision.accept() : decision.reject({ reason: "Reach 1,000 views." }), }, }); ``` Each record is one Short, keyed by its video ID. Its author is the channel, so Domino credits the member who linked it, and the usual [admission choices](#admit-x-posts-automatically) apply: a Short published before the member linked the channel is historical. An [engagement rule](#settle-engagement-points-daily) can credit growth in `views`, `likes` or `comments`. ### 2. Deploy and follow collection [#2-deploy-and-follow-collection] Deploy to test as for any tracker. **Community → Trackers** shows each sync. When a sync can't read a channel, for example because its owner revoked access, the tracker reports it and the channel's Shorts are left as they were; the member links the channel again to fix it. | What happens | What Domino does | | ------------------------------------------------ | -------------------------------------------------------------------------------------------- | | A Short is deleted or made private | Removes it, which blocks rewards not yet paid. | | A member unlinks their channel, or links another | Stops reading the old channel. Its Shorts stay. | | A channel needs reconnecting | Skips it until the member links it again. Nothing is removed. | | The platform's daily YouTube allowance runs out | Pauses collection until it resets at midnight Pacific time, then continues where it stopped. | > [!DEV] > The source's body is `YouTubeShortBody`: `title`, `description`, `tags`, `durationSeconds` and `publishedAt` (epoch milliseconds). Its metrics are `views`, `likes` and `comments`; a counter the channel hides is unknown, never zero. A `refreshSeconds` below the plan's minimum still publishes and runs at the minimum (`effectiveRefreshSeconds` on the status); unlike an X tracker, the deployment doesn't warn. A quest on it that runs on every observation, the default, is evaluated again for each Short on every refresh, so `domino check` warns `TRIGGER_REFIRES_ON_REFRESH`: for a views or likes threshold, use a content quest with `performance`, as above. In SDK 1.x it is a custom source on the reserved namespace `youtube-shorts`: you cannot send records to it, and a skipped or failed sync reports `provider-unavailable` in the tracker's error rather than removing anything. YouTube counts every play of a Short as a view from 2025-03-31, and every format's view at playback start from 2026-08-24, so view counts are not comparable across those dates. ## Reward TikTok videos [#reward-tiktok-videos] `tiktok.videos()` collects the public videos your members post on their own TikTok accounts. A member links their TikTok account in your app as a [creator account](/docs/concepts/participants#creator-accounts); from then on, the tracker finds their new videos and keeps each one's views, likes, comments and shares up to date. There's no search: TikTok shares a member's videos only with that member's own permission, so only accounts your members linked are read. > [!NOTE] Available once Domino configures TikTok > Until Domino configures TikTok for the platform, members aren't offered a TikTok account to link and TikTok trackers don't collect. ### 1. Define the tracker and a quest [#1-define-the-tracker-and-a-quest-1] Use it with a content quest like any other content: here, a video that isn't labelled as AI-generated earns its reward once it reaches 10,000 views. ```ts import { defineQuest, defineTracker, tiktok, content, settings, decision } from "@domino-sdk/relay/authoring"; const videos = defineTracker({ key: "launch-tiktoks", title: "Launch TikToks", source: tiktok.videos(), window: { start: Date.parse("2026-11-01T00:00:00Z") }, // How often Domino looks for new videos and refreshes their counts, in seconds. // Your plan sets the shortest interval: six hours on every plan but Enterprise. refreshSeconds: 3600, }); export default defineQuest({ key: "launch-tiktok", title: "Post a launch TikTok that reaches 10,000 views", settings: settings.object({}), trigger: content({ tracker: videos }), rewards: [], evaluate: async ({ tracker }) => tracker.content.body.isAigc ? decision.reject({ reason: "Post a video you filmed yourself." }) : decision.accept(), performance: { metrics: ["views"], evaluate: ({ metrics }) => metrics.views >= 10000 ? decision.accept() : decision.reject({ reason: "Reach 10,000 views." }), }, }); ``` Each record is one video, keyed by its video ID. Its author is the TikTok account, so Domino credits the member who linked it, and the usual [admission choices](#admit-x-posts-automatically) apply: a video posted before the member linked the account is historical. An [engagement rule](#settle-engagement-points-daily) can credit growth in `views`, `likes`, `comments` or `shares`. ### 2. Deploy and follow collection [#2-deploy-and-follow-collection-1] Deploy to test as for any tracker. **Community → Trackers** shows each sync. When a sync can't read an account, for example because its owner removed Domino's access, the tracker reports it and the account's videos are left as they were; the member links the account again to fix it. | What happens | What Domino does | | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The account no longer returns a video, for example because it was deleted | Removes it, which blocks rewards not yet paid. | | A member unlinks their account, or links another | Stops reading the old account. Its videos stay. | | An account needs reconnecting, for example because its owner removed Domino's access | Skips it until the member links it again, and shows **Needs reconnect** on the member as soon as TikTok refuses the access. Nothing is read from it, and nothing is removed. | | Domino reaches TikTok's per-minute request limit | Pauses collection for a minute, then continues where it stopped. | > [!DEV] > The source's body is `TikTokVideoBody`: `title`, `description` (the caption), `durationSeconds`, `width`, `height`, `createdAt` (epoch milliseconds), `shareUrl` and `isAigc`. Its metrics are `views`, `likes`, `comments` and `shares`; a counter TikTok leaves out is unknown, never zero. A `refreshSeconds` below the plan's minimum still publishes and runs at the minimum (`effectiveRefreshSeconds` on the status); unlike an X tracker, the deployment doesn't warn. As for YouTube, `domino check` warns `TRIGGER_REFIRES_ON_REFRESH` about a quest on it that runs on every observation. In SDK 1.x it is a custom source on the reserved namespace `tiktok-videos`: you cannot send records to it. An account Domino can't read, and a sync paused for TikTok's request limit, report `provider-unavailable` in the tracker's error; any other failed sync reports what failed and is retried from where it stopped. None of these removes anything. ## Reward holding tokens [#reward-holding-tokens] `wallet.balances({ chain, token })` samples the balance of one token in every wallet your members linked, once a day. Use it to reward holding, such as "hold 1,000 USDC for 7 days": each daily sample at or above your minimum fills one day of a [streak](#reward-consecutive-days). Your app runs no balance job, and members can't time a transfer between wallets around the sample. ### 1. Install the snippet [#1-install-the-snippet] ```bash pnpm dlx shadcn@4.21.0 add https://snippets.domino.run/r/token-hold-streak.json ``` It adds a tracker for USDC on Base and a quest that pays once a member's wallet holds at least the minimum for 7 days in a row. Set the chain and token in the tracker file before the first deploy, and add it to `trackers` in `relay.json`. The tracker and quest look like this: ```ts import { automatic, defineQuest, defineTracker, points, settings, streak, wallet } from "@domino-sdk/relay/authoring"; const balances = defineTracker({ key: "usdc-balances", title: "USDC balances on Base", source: wallet.balances({ chain: "eip155:8453", token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" }), window: { start: Date.parse("2026-11-01T00:00:00Z") }, }); export default defineQuest({ key: "hold-usdc", title: "Hold 1,000 USDC for 7 days", settings: settings.object({ threshold: settings.amount({ label: "Minimum balance", default: "1000000000", decimals: 6 }), balance: settings.pointLedger({ label: "Point ledger" }), }), trigger: automatic({ tracker: balances, where: { path: "amount", gte: { setting: "threshold" } } }), completion: streak({ days: 7 }), review: () => ({ enabled: false }), rewards: ({ settings }) => (settings.balance ? [points(settings.balance, 500)] : []), }); ``` The chain and token are those of [`ctx.wallet.balance()`](wallets.md#check-token-balances): an EVM chain with its native coin or an ERC-20 contract, Solana with SOL or an SPL or Token-2022 mint, or a Cosmos chain Domino reads with a bank denomination. They can't change once the tracker is published; to sample another token, create another tracker. ### 2. Deploy and follow the samples [#2-deploy-and-follow-the-samples] Deploy to test as for any tracker. **Community → Trackers** shows each wallet as one record whose latest sample is its current balance, and when it next runs: the start of the next day, then that day's sample time. * **Once a day, at a time members can't predict.** Each day of the [project calendar](/docs/concepts/calendar), Domino picks a time from a secret only it holds, in the first three quarters of the day. * **Every wallet at one block.** One sample reads every wallet at the same EVM block or Cosmos height, so tokens moved between two members' wallets can't be counted twice. Solana can't read a past slot: there every read must reach the slot the sample started at. * **Only members' wallets.** A sample reads each wallet of the chain's family linked by a member who joined the project: every EVM wallet for an EVM chain, every Solana wallet for Solana, and Cosmos wallets linked on that exact chain. An unlinked wallet is no longer read; its records stay. * **A wallet judged on its own.** A `where` filter compares one wallet's sample. To total a member's wallets, read them in an evaluator with `trackers.records(balances, { member: member.id })`. | What happens | What Domino does | | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The chain's provider fails | Writes nothing, shows the error on the tracker, and tries again 15 minutes later at the same block. | | Every try of a day fails | That day has no sample, and a streak breaks on it: Domino never writes a balance it didn't read. Grant the quest by hand to a member who lost a day this way. | | A sample doesn't finish before its day ends | Stops it, so a later read never fills the wrong day. The wallets it didn't reach have no sample that day. | | The chain or token isn't one Domino can read | Shows why on the tracker and tries again hourly. A tracker's chain and token can't change: create a tracker with a new key and the right token, point the quest at it, and archive the wrong one. | > [!DEV] > Each record's ID and `author` are the wallet's CAIP-10 account on the sampled chain, such as `eip155:8453:0xAbC…`, and `occurredAt` is the sample time. `data` is `WalletBalanceSample`: `amount` (base units, a decimal integer string), `decimals` (null when the token doesn't report them) and `block` (the EVM block or Cosmos height every wallet was read at, or on Solana the slot this wallet's answer reflects). In SDK 1.x it is a custom source on the reserved namespace `wallet-balances`: sending records to it fails with 403 `forbidden`. A project can have at most 5 `wallet.balances()` trackers in each environment; publishing or unarchiving a sixth fails with 409 `limit-reached`. A test member's [clock](/docs/use/quests#6-advance-the-members-clock) changes when their quests are evaluated, never when wallets are sampled. EVM samples need the platform's dRPC key. ## Settle engagement points daily [#settle-engagement-points-daily] An engagement rule keeps paying members as their posts keep performing: for example one point for every ten likes, settled once a day, with daily and lifetime caps you set. It runs alongside a content quest: the quest decides which posts qualify and pays its one-time reward, and the rule pays for growth after that. Rules apply to posts first discovered after the rule was published. Existing discoveries are not backfilled. Create `engagement.ts`: ```text import { defineEngagementReward } from "@domino-sdk/relay/authoring"; import { pointsLedger } from "./ledgers"; import launchPost from "./quests/launch-post.quest"; export default defineEngagementReward({ key: "launch-growth", qualifiedBy: launchPost, ledger: pointsLedger, baseline: "historical", metrics: ["likes"], budgets: [ { scope: "project", period: "day", limit: 10000 }, { scope: "member", period: "day", limit: 500 }, { scope: "project", period: "lifetime", limit: 100000 }, ], target: ({ metrics }) => Math.floor(metrics.likes / 10), }); ``` The target is cumulative: ten likes earn one point, nineteen still earn one, and twenty earn two. Each budget caps the points paid for the whole project or for each member, per day, week, month or lifetime; growth over a cap is excluded for good, not carried into tomorrow. Days follow the [project calendar](/docs/concepts/calendar). Add an explicit entry to `relay.json`: ```json { "engagement": [{ "entry": "engagement.ts" }] } ``` Keep the existing connection, tracker, quest, and points declarations in the manifest. Preview and publish the complete project to test: ```sh pnpm exec domino deploy --preview --environment test --json pnpm exec domino deploy --resume DEPLOYMENT_ID --environment test --yes ``` Replace `DEPLOYMENT_ID` with the preview's `deployment`. The preview labels each engagement rule `create`, `unchanged`, or `change`. A tracker, content quest, points balance, and engagement rule can publish in one transaction. The CLI publishes only to test, and live refuses a deployment preview with `promotion-required`. You can also publish one rule through the management API. Use a server-side management key with `publish` permission and keep it out of browser code. Management keys can publish in test; live publication follows the existing Console-session approval policy. ```ts import { buildQuest } from "@domino-sdk/relay/build"; import { createManagementClient } from "@domino-sdk/relay/server"; const management = createManagementClient({ baseUrl: "https://relay.domino.run", organization: "your-organization", project: "your-project", environment: "test", getToken: async () => process.env.DOMINO_MANAGEMENT_KEY ?? null, }); const { code } = await buildQuest("./engagement.ts"); const preview = await management.deployments.preview({ engagement: [{ code }] }); await management.deployments.publish(preview.id); ``` Pausing the qualifying quest stops accrual; points already earned stay. Deleted content, changed ownership, pending review and failed qualification block further accounting until resolved, and credited points are never reversed automatically. > [!DEV] > The formula runs in an isolated sandbox with only the declared counters, and must return a nonnegative safe whole number synchronously. A manifest accepts at most 50 engagement entries. See [engagement accounting](/docs/reference/trackers#engagement-accounting) for the formula and publication rules, the account fields (`earned`, `accounted`, `paid`, `excluded`, `pending`), daily settlement timing, budgets, referral bonuses, and reading settlement history with `relay.engagement.page`. ## Change a formula without repricing past engagement [#change-a-formula-without-repricing-past-engagement] Change how future engagement is scored without changing what members already earned. For example, double a rule's rate from `metrics.likes` to `2 * metrics.likes`: points earned under the old rate keep their old price, and only growth after the switch earns at the new rate. Edit the rule's `target`, bundle it, and preview the change. Keep the same rule key, qualifying quest, balance, declared metrics, baseline policy, freshness policy, and budgets. This workflow changes the formula only. For a manifest entry, run `domino deploy --preview --json`. Review its `engagement` entries, including the formula change's accounting summary, then run `domino deploy --resume DEPLOYMENT_ID --yes`. A normal `domino deploy` also previews and publishes these changes, displaying engagement details in its terminal confirmation. Alternatively, use a management key with `publish` permission on your server: ```ts import { buildQuest } from "@domino-sdk/relay/build"; import { createManagementClient } from "@domino-sdk/relay/server"; const management = createManagementClient({ baseUrl: "https://relay.domino.run", organization: "your-organization", project: "your-project", environment: "test", getToken: async () => process.env.DOMINO_MANAGEMENT_KEY ?? null, }); const { code } = await buildQuest("./engagement.ts"); const preview = await management.deployments.preview({ engagement: [{ code }] }); // Review the formula change and accounting summary in preview.engagement. const requested = await management.deployments.publish(preview.id); ``` Each post switches using its latest fresh, qualified measurement, which can be up to the rule's freshness limit old, rather than a measurement taken the moment you publish. At the switch Domino settles what the old formula owes under the existing caps, then starts the new formula from the same counts. Caps and excluded growth don't reset, and a post whose likes dip and recover earns nothing for the recovery. | Change | Result after the switch | | --------------------------------------------------------------------- | --------------------------------------------------- | | Rate changes from one to two at 100 likes; next sample is 110. | Earn 20 new points. | | Likes previously reached 100 but now read 80; then recover to 100. | No reward for recovery; the baseline retains 100. | | A target capped at 100 changes to a cap of 200 when counters are 150. | No top-up for the old 150; 160 earns 10 new points. | > [!DEV] > If the rule, accounting checkpoint or admission set changes before publication, apply returns `409` and publishes nothing: preview again. Retain the deployment ID and publish it again to recover a lost response. See [formula changes](/docs/reference/trackers#formula-changes) for the preview totals, the `available-eligible-observation` boundary, superseded requests, and following a transition with `relay.engagement.boundaries`. # Wallets (/docs/build/wallets) Members can connect a crypto wallet in your app. That opens up quests such as "Link your wallet", "Hold 10 USDC on Base" or "Hold 1,000 tokens for a week", and lets members sign in with a wallet instead of an email address. ## What members see [#what-members-see] A member picks their wallet, such as Phantom or MetaMask, and signs a one-time message in it. The message says it does not authorize any transaction or transfer, so linking never moves funds. Domino checks the signature and adds the wallet to the member's list for your project. Where the project enables it, members can also [sign in with a wallet](#sign-in-with-a-wallet). ## What your team sees [#what-your-team-sees] Each member's page in Console lists their wallets and the balances quests recorded for them. Your team can unlink a wallet, restore one, or stop a wallet from signing in, for example when its key leaks. See [What operators see](#what-operators-see). | Chain family | Wallets | Address Domino stores | | ------------ | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | Solana | Phantom, Solflare, Backpack, Wallet Standard | Base58 | | EVM | MetaMask, Rabby and other injected EVM wallets, and smart-contract wallets such as Base Account | EIP-55 checksummed `0x…` | | Cosmos SDK | Keplr, Leap, Cosmostation | Lowercase bech32 with the chain's prefix (`cosmos1…`, `osmo1…`) | An ordinary EVM wallet links once for every EVM chain: the signature proves control of the key behind the address, not of a network. A smart-contract wallet, such as Base Account or a Privy or Dynamic smart wallet, is a contract rather than a key, so only its chain can check its signature. Domino asks the chain the wallet was on when the member signed, whether the wallet is deployed there yet or not. The wallet links as the account at its address on that chain only, because a contract at the same address on another chain can belong to someone else. Quests read its balances, NFTs, events and contract calls on that chain, tracker records name it there, and payout lists name it with that chain. > [!DEV] > A deployed smart wallet's signature is checked with ERC-1271 `isValidSignature`, and a wallet not deployed yet signs an ERC-6492 wrapper, which Domino checks by simulating the deployment. Both use one `eth_call` on the chain in the message's Chain ID line: the `chainId` the signer reports (`evmSigner` asks the wallet with `eth_chainId`). The linked wallet's `provedOn` is that chain's CAIP-2 ID, such as `eip155:8453`; an ordinary wallet's signature is checked without a network call and has no `provedOn`. A Cosmos wallet links on a chain your app names, such as Cosmos Hub. Domino has no list of Cosmos chains, so your app can offer any of them. > [!DEV] > Name a Cosmos chain by its chain ID, such as `cosmoshub-4`: any chain ID of up to 32 letters, digits, `-` and `_` works, with any bech32 prefix. Chain IDs are CAIP-2 references, so a chain ID with a dot, or longer than 32 characters, cannot link. The wallet's `network` is the chain's CAIP-2 ID, such as `cosmos:cosmoshub-4`. > [!WARNING] A Cosmos wallet's `network` is not proof > `network` is the chain the app asked the wallet to sign for. Domino does not check it: the signature proves control of the address, and any app can link an address under any `cosmos:`. Gate on the address instead; see [Check wallets in a quest](#check-wallets-in-a-quest). ## Link a wallet [#link-a-wallet] ### 1. Find the member's wallets [#1-find-the-members-wallets] `@domino-sdk/relay/browser` discovers installed wallets and wraps each in a signer. It has no wallet SDK dependency. ```ts import { createBrowserRelayClient, discoverCosmosWallets, discoverEvmWallets, discoverSolanaWallets, } from "@domino-sdk/relay/browser" const relay = createBrowserRelayClient({ baseUrl: "/relay" }) const evm = await discoverEvmWallets() const solana = discoverSolanaWallets() const cosmos = discoverCosmosWallets({ chainId: "cosmoshub-4" }) ``` Each entry has a `name`, an optional `icon`, and a `signer`. Show them however your app likes. Cosmos wallets sign on the chain you pass, as `{ chainId, chainInfo? }` or as its CAIP-2 ID (`"cosmos:cosmoshub-4"`); call `discoverCosmosWallets` once per chain your app offers. ### 2. Sign and link [#2-sign-and-link] ```ts import { createBrowserRelayClient, discoverEvmWallets } from "@domino-sdk/relay/browser" const relay = createBrowserRelayClient({ baseUrl: "/relay" }) const [metamask] = await discoverEvmWallets() if (metamask) { const wallet = await relay.wallets.link(metamask.signer) console.log(wallet.address) } ``` The member's wallet shows a sign-in style message for your app's domain. The message states that it does not authorize any transaction or transfer. `link` returns the linked wallet with its canonical address. In React, use the hooks instead: ```tsx import { useWalletDiscovery, useWallets } from "@domino-sdk/relay/react" function LinkWallets() { const { evm, solana, cosmos } = useWalletDiscovery({ cosmos: { chainId: "cosmoshub-4" } }) const { data: wallets, link, unlink, actionError, isPending } = useWallets() return ( <> {[...evm, ...solana, ...cosmos].map((found) => ( ))} {wallets?.map((wallet) => ( ))} {actionError &&

{actionError.message}

} ) } ``` `useWalletDiscovery()` lists Cosmos wallets only for the chains in `cosmos`: one chain or an array of them. > [!NOTE] Chains the wallet does not know > Keplr, Leap and Cosmostation refuse a chain they have not been told about. Pass the chain's Keplr `ChainInfo` as `chainInfo`: when the wallet answers that it does not know the chain, the signer suggests it with `experimentalSuggestChain(chainInfo)`, and the member approves it in the wallet. Without `chainInfo`, connecting fails with "Keplr doesn't know the Cosmos chain example-1. Add the chain to the wallet, or pass its chainInfo so the wallet can suggest it." A wallet without `experimentalSuggestChain` fails with "… doesn't know the Cosmos chain example-1 and cannot add chains." Other refusals, such as the member rejecting the connection, fail as the wallet reports them, and nothing is suggested. ```ts import { discoverCosmosWallets } from "@domino-sdk/relay/browser" /** Keplr's ChainInfo for your chain: RPC and REST endpoints, bech32 prefixes, currencies. */ declare const exampleChainInfo: { chainId: "example-1" } & Record const [keplr] = discoverCosmosWallets({ chainId: "example-1", chainInfo: exampleChainInfo }) ``` ### 3. Show the linked wallets [#3-show-the-linked-wallets] `relay.wallets.list()` returns the member's wallets for this project, with `chain`, `network`, `address` and `verifiedAt`, and `provedOn` for a smart-contract wallet. ## Bring your own wallet provider [#bring-your-own-wallet-provider] Discovery finds only the wallets a browser extension injects into the page. Members who connect through WalletConnect, Reown AppKit, or an embedded wallet such as Privy's, Dynamic's or Para's need your app to hand Domino that wallet itself. Wrap the provider your wallet SDK gives you in a signer, then link or sign in with it as above. | Your wallet SDK gives you | Wrap it with | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | | An EIP-1193 provider, such as WalletConnect's `EthereumProvider`, AppKit's `useAppKitProvider("eip155").walletProvider` or a Privy wallet's `getEthereumProvider()` | `evmSigner(provider, { name })` | | A Solana provider with `connect()`, `publicKey` and `signMessage(bytes)`, as Phantom injects it | `solanaSigner(provider, { name })` | | A Wallet Standard wallet | `walletStandardSolanaSigner(wallet)` | | A Keplr-style Cosmos wallet | `cosmosSigner(provider, chain)` | | Only a function that signs a message | Your own `WalletSigner` | ```ts import { createBrowserRelayClient, evmSigner, type Eip1193Provider, } from "@domino-sdk/relay/browser" // For example WalletConnect's: await EthereumProvider.init({ projectId, ... }) declare const provider: Eip1193Provider const relay = createBrowserRelayClient({ baseUrl: "/relay" }) const wallet = await relay.wallets.link(evmSigner(provider, { name: "WalletConnect" })) ``` `evmSigner` asks the provider for `eth_requestAccounts`, `eth_chainId` and `personal_sign`, and it follows `accountsChanged` when the provider emits it. A Solana provider whose `signMessage` takes only the message fits `solanaSigner` once you wrap it: `solanaSigner({ connect: async () => ({ publicKey: provider.publicKey }), publicKey: provider.publicKey, signMessage: (message) => provider.signMessage(message) })`. > [!DEV] Write your own signer > A `WalletSigner` (a type from `@domino-sdk/relay`) has the wallet's `chain` (`"eip155"`, `"solana"` or `"cosmos"`, with `network` for Cosmos), an optional `name`, `address()`, which connects if needed and returns the address, and `sign(message, address)`, which signs the exact UTF-8 message and returns `{ signature }`. EVM signatures are `0x` and 130 hex digits, Solana signatures base58, and Cosmos signatures base64 with the base64 compressed `publicKey`. The login-provider recipes build one from [Dynamic's](/docs/build/dynamic#3-link-the-embedded-wallet-for-payouts) and [Para's](/docs/build/para#3-link-the-embedded-wallet-for-payouts) `signMessage`. Embedded wallets your app's login vouches for already count for wallet quests, but leaderboard payout lists leave them out until the member signs once. The recipes for [Privy](/docs/build/privy), [Dynamic](/docs/build/dynamic) and [Para](/docs/build/para) show that step, and [Reown AppKit](/docs/build/reown) signs members in with the wallet AppKit connects. ## Limits [#limits] * **Five per chain family.** A member can have five Solana, five EVM and five Cosmos wallets linked to a project; the five Cosmos wallets can be on any chains. Linking a sixth fails until they unlink one. Sign-in wallets do not count. * **One wallet per address.** The same Cosmos key has a different address on chains with different prefixes (`cosmos1…`, `osmo1…`), so it links as separate wallets, each counting toward the five. Chains that share a prefix share the address: linking it again on another chain ID moves the wallet to that chain. * **One member per address.** An address linked by one member cannot be linked by another, even after it is unlinked. * **Linking is not sign-in.** A wallet linked while signed in cannot sign in unless an operator enables it. * **Smart-contract wallets on supported chains.** A smart-contract wallet links or signs in only on an EVM chain Domino reads balances on (`GET /management/v1/wallet-balance-chains`); on another chain it fails with "Domino cannot check smart-wallet signatures on eip155:…". Switch the wallet to a supported chain and connect again. * **A check the chain didn't answer can be retried.** When the chain doesn't answer, linking or signing in fails with "Couldn't check the wallet's signature on Base. Try again." (502 `unavailable`), nothing links, and the same signature can be sent again for the same challenge. A wrong signer fails with "The wallet signature does not match this challenge", as for any wallet. * **Ten smart-wallet checks per member.** Each member, or each client when signing in, gets ten smart-wallet signature checks in 10 minutes, and a project environment 600, because each one calls the chain. Ordinary wallets do not count. * **Accounts that sign only.** 32-byte Cosmos module and contract addresses, and Ethermint-key Cosmos wallets (such as Evmos and Injective), cannot link. * **Per project and environment.** Each project and environment has its own list. ## Unlink a wallet [#unlink-a-wallet] ```ts import { createBrowserRelayClient } from "@domino-sdk/relay/browser" const relay = createBrowserRelayClient({ baseUrl: "/relay" }) const [wallet] = await relay.wallets.list() if (wallet) { await relay.wallets.unlink(wallet) } ``` The wallet leaves the project's list, and new quest attempts no longer see it. The address stays with the member, who can link it again. ## Check wallets in a quest [#check-wallets-in-a-quest] A quest can require a linked wallet, such as a Cosmos Hub wallet, before it pays. > [!DEV] > Evaluators read the list with `ctx.wallet.list()`. The list is fixed at each evaluation run's first wallet read. Links and unlinks do not change that run, and its retries reuse the list. A scheduled run after defer or progress reads the current links. It is `null`, never empty, when the member has linked no wallet. ```text import { defineQuest, settings, manual, once, points, decision } from "@domino-sdk/relay/authoring" import { community } from "./ledgers" export default defineQuest({ key: "link-cosmos-hub-wallet", title: "Link your Cosmos Hub wallet", settings: settings.object({}), trigger: manual(), completion: once(), rewards: [points(community, 25)], async evaluate({ wallet }) { const wallets = await wallet.list() // `network` is the chain the app requested, not proof. The address is: Cosmos Hub accounts start with cosmos1. // null when the member has linked no wallet. return wallets?.some((linked) => linked.chain === "cosmos" && linked.address.startsWith("cosmos1")) ? decision.accept() : decision.reject({ reason: "Link a Cosmos Hub wallet, then try again." }) }, }) ``` Publish the imported `community` ledger in the same project. A `null` list means the member has linked no wallet. Gate a Cosmos wallet on its address, never on its `network`: any app can link an address under any chain ID. The bech32 prefix tells you which chains the address belongs to, and chains that share a prefix share the address. Keep your own list of the chains you accept with their prefixes, check the address against it, and read the chain itself for balances or activity. ## Check token balances [#check-token-balances] Reward members for holding a token, such as 10 USDC on Base or a minimum of your community's own token. Domino reads the balance of the member's linked wallets when the quest checks it. `ctx.wallet.balance()` reads a balance for the member's linked wallets on one chain: | Chain | `chain` | `token` | | ------------------ | ------------------------------------------------------------ | -------------------------------------------------- | | Solana mainnet | `"solana"` | An SPL or Token-2022 mint, or omit it for SOL | | An EVM chain | Its CAIP-2 ID, such as `"eip155:8453"` for Base | An ERC-20 contract, or omit it for the native coin | | A Cosmos SDK chain | Its CAIP-2 ID, such as `"cosmos:cosmoshub-4"` for Cosmos Hub | A bank denomination, such as `"uatom"`; required | Domino reads balances on the chains `/wallet-balance-chains` lists. For EVM, that is the networks, mainnets and testnets alike, on the chain list of Domino's RPC provider, dRPC; a network dRPC adds later is readable only once Domino updates the list. For Cosmos, it is Cosmos Hub, Osmosis and any other Cosmos SDK chain Domino has configured. A Cosmos read covers only the wallets linked on that exact chain ID. List the supported chains, with their names, from a server or the CLI: ```sh domino api GET /wallet-balance-chains ``` The answer is `{ chains: [{ chain, name, testnet }] }`: Solana mainnet first, then the Cosmos chains, then each EVM chain by chain ID. In code, call `management.walletBalanceChains()`; it needs `read` permission. An EVM or Cosmos chain that is not in the list throws a `CapabilityError` with code `author-error`. Domino reads every balance through endpoints it chooses: you can't give it your own RPC or REST URL. If your community's Cosmos chain isn't listed, contact Domino support to add it. For a chain Domino doesn't read, or on-chain state other than a token balance, run your own indexer and send its records to a [custom tracker source](trackers.md#feed-another-source-including-a-blockchain-indexer), keyed by [wallet account](trackers.md#wallet-accounts); quests then read those records. ### 1. Install the snippet [#1-install-the-snippet] ```sh pnpm dlx shadcn@4.21.0 add https://snippets.domino.run/r/token-holder.json ``` It adds `quests/token-holder.quest.ts`, which accepts when the member's linked wallets together hold at least a minimum. Its settings are the chain, the token address and the minimum, which Console shows in whole tokens. The default is 10 USDC on Base. > [!DEV] > The chain is a `settings.chain()` setting and the minimum a `settings.amount()` setting, stored in base units. ### 2. Or write the check yourself [#2-or-write-the-check-yourself] ```text import { defineQuest, settings, manual, once, points, decision, parseUnits } from "@domino-sdk/relay/authoring" import { community } from "./ledgers" export default defineQuest({ key: "hold-usdc-on-base", title: "Hold 10 USDC on Base", settings: settings.object({}), trigger: manual(), completion: once(), rewards: [points(community, 50)], async evaluate({ wallet }) { const held = await wallet.balance({ chain: "eip155:8453", token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", }) // null when the member has linked no wallet of the collection's chain (here, EVM). if (!held) { return decision.reject({ reason: "Link an EVM wallet first." }) } if (held.decimals === null) { return decision.review({ reason: "The token reports no decimals." }) } return BigInt(held.total) >= parseUnits("10", held.decimals) ? decision.accept() : decision.reject({ reason: "Hold at least 10 USDC, then try again." }) }, }) ``` The result gives amounts as integer strings in the token's smallest unit: `total` across the wallets read, and each wallet's `amount`. `parseUnits` and `formatUnits` convert whole-token amounts. To let operators choose the chain and the minimum, use `settings.chain({ label, default })` and `settings.amount({ label, default, decimals })`. A chain setting holds `"solana"`, Solana mainnet's CAIP-2 ID or an EVM chain ID such as `"eip155:8453"`, and is typed as the query's `chain`. An amount setting holds a base-unit integer string, such as `"10000000"` for 10 USDC, so compare it with `BigInt(held.total) >= BigInt(settings.minimum)`; with `decimals`, Console shows and edits it in whole tokens. Publication and Console edits refuse any other value. ### What the read covers [#what-the-read-covers] * **All linked wallets of the chain's family.** An EVM chain reads every linked EVM wallet; Solana reads every linked Solana wallet. A Cosmos chain reads only wallets linked on that exact network. Pass `address` to read one of them. Any other address throws a `CapabilityError` with code `author-error` and the message "Read only the member's linked wallets". * **No wallet is not a zero balance to check.** With no wallet of that family, `ctx.wallet.balance()` returns `null`. Check for it to ask the member to link one. * **Failures are never ineligibility.** A chain Domino does not read, a malformed token identifier, a Solana account that is not a token mint, an EVM address with no contract and an NFT contract (use [`ctx.wallet.nfts()`](#check-nft-holdings) for those) throw a `CapabilityError` with code `author-error`. A provider outage throws one with code `provider-unavailable`, which Domino retries if your code lets it through. None of them reads as a zero balance. See [evaluator readers](/docs/reference/verification#evaluator-readers-null-errors-and-pinning). * **Balances at verification time.** Each read is recorded with the attempt, so a retried verification reuses it. It is one of the evaluation's eight capability calls. It doesn't show the member kept holding: for that, see [Reward holding for days](#reward-holding-for-days). * **Token identifiers come from settings.** Never take a token address or denomination from the member's input: they could point the quest at a token they created. > [!DEV] > EVM reads go through dRPC and need the platform's dRPC key. Without it, they fail with "EVM balance checks are not configured". Solana and Cosmos reads need no project key. Cosmos reads use platform-owned HTTPS REST endpoints, verify the chain ID, and pin every bank and metadata query to one block. Missing denomination metadata returns `decimals: null`; a provider failure never becomes a zero balance. Use `ctx.wallet.balance({ chain: "solana" })` for the total SOL balance of linked Solana wallets; pass `address` to select one. See [wallet balances](/docs/reference/verification#wallet-balances). ## Reward holding for days [#reward-holding-for-days] For "Hold 1,000 tokens for a week", Domino samples the balance of every wallet members linked once a day, at a time members can't predict. On EVM and Cosmos chains every wallet is read at one block, so tokens passed between wallets don't count twice; Solana can't read a past slot, so there each read only has to reach the slot the sample started at. Each day a member's wallet holds the minimum fills one day of a 7-day streak. Your app runs no balance job. ```bash pnpm dlx shadcn@4.21.0 add https://snippets.domino.run/r/token-hold-streak.json ``` The snippet adds a `wallet.balances()` tracker and a streak quest on it. A day whose samples all failed has no sample and breaks the streak, since Domino never writes a balance it didn't read. See [Reward holding tokens](trackers.md#reward-holding-tokens) for how sampling works and what each outcome means. ## Check NFT holdings [#check-nft-holdings] Reward members for holding an NFT from your collection, on an EVM chain or on Solana. Domino counts the NFTs the member's linked wallets of that chain hold when the quest checks it. A collection is written as a [CAIP-19](https://chainagnostic.org/CAIPs/caip-19) asset type: | Collection | Written as | | -------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | ERC-721 | `eip155:/erc721:`, such as `eip155:1/erc721:0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D` | | One token ID of an ERC-1155 contract | `eip155:/erc1155:/`, such as `eip155:8453/erc1155:0x…/1` | | Metaplex verified collection on Solana | `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/nft:`, the mint of the collection NFT | An EVM chain is one of the EVM chains `/wallet-balance-chains` lists. Cosmos NFTs are not read. On Solana, an NFT counts when a linked Solana wallet holds it (a token account with exactly 1 of a mint with no decimals and a supply of 1) and its Metaplex metadata names your collection with `verified: true`. Only the collection's update authority or a delegate it approved can verify an NFT, so a collection field anyone else set is ignored. Compressed NFTs (cNFTs) and Metaplex Core assets are not counted. ### 1. Install the snippet [#1-install-the-snippet-1] ```sh pnpm dlx shadcn@4.21.0 add https://snippets.domino.run/r/nft-holder.json ``` It adds `quests/nft-holder.quest.ts`, which accepts when the member's linked wallets of the collection's chain together hold at least one NFT from the collection. Its setting is the collection; the default is Bored Ape Yacht Club on Ethereum, so set it to yours. ### 2. Set the collection [#2-set-the-collection] When you publish the quest, change the collection in Console, or unarchive the quest, Domino checks the collection on its chain. Make these changes in test; they reach live through a promotion. A wrong collection is refused there with a message that says what it is, so a typo never rejects members who hold the right NFT: * **EVM:** Domino asks the contract which standard it implements. A token contract, or an ERC-1155 contract written as `erc721`, is refused. * **Solana:** the mint must be a Metaplex collection NFT. A fungible token, a mint without Metaplex metadata, a print or limited edition, and an NFT that belongs to a collection (the message names the collection to use instead) are refused. > [!DEV] > The collection is a `settings.nft({ label, default })` setting, typed as the query's `collection`. Publication, Console edits and unarchiving refuse a value that is not a collection, a chain Domino does not read, an EVM contract that does not report the standard through ERC-165 `supportsInterface`, and a Solana mint that is not a Metaplex collection NFT. A Solana collection NFT qualifies as Token Metadata requires to verify members into it: a unique master edition (`max_supply` 0) whose token standard is `NonFungible` or `ProgrammableNonFungible` (or unset, on metadata written before token standards). Its metadata must also have collection details (a sized collection) or, for a legacy unsized collection, not be a verified member of another collection. On a platform without its dRPC key, every EVM collection is refused, because none can be checked. ### 3. Or write the check yourself [#3-or-write-the-check-yourself] ```text import { defineQuest, settings, manual, once, points, decision } from "@domino-sdk/relay/authoring" import { community } from "./ledgers" export default defineQuest({ key: "hold-our-nft", title: "Hold one of our NFTs", settings: settings.object({ collection: settings.nft({ label: "NFT collection", default: "eip155:8453/erc721:0x52908400098527886E0F7030069857D2E4169EE7", }), }), trigger: manual(), completion: once(), rewards: [points(community, 50)], async evaluate({ wallet, settings }) { // Relay supplies nfts() to every evaluation; its type is optional for older test doubles. const held = await wallet.nfts!({ collection: settings.collection }) // null when the member has linked no EVM wallet. if (!held) { return decision.reject({ reason: "Link an EVM wallet first." }) } return BigInt(held.total) >= 1n ? decision.accept() : decision.reject({ reason: "Hold one of our NFTs, then try again." }) }, }) ``` The result is `{ collection, chain, standard, total, wallets: [{ address, count }], at, observedAt }`: `total` is how many the wallets read hold together, and each wallet's `count` its own, as integer strings. For ERC-721 that is the contract's `balanceOf(owner)`; for ERC-1155, `balanceOf(owner, id)` for the collection's token ID; for a Solana collection (`standard` `"metaplex"`), the number of the collection's verified NFTs the wallet holds, read at a slot (`at.kind` `"slot"`). Pass `address` to read one linked wallet. ### What the NFT read covers [#what-the-nft-read-covers] * **All linked wallets of the collection's chain family, or one of them.** EVM wallets for an EVM collection, Solana wallets for a Solana one. Any other `address` throws a `CapabilityError` with code `author-error`. * **No wallet is not "holds none".** With no linked wallet of that family, `ctx.wallet.nfts()` returns `null`. * **Failures are never ineligibility.** A malformed collection, a chain Domino does not read and a contract that rejects the read throw `author-error`; a provider outage throws `provider-unavailable`. None of them reads as holding none. * **Counts at verification time.** Each read is recorded with the attempt, so a retried verification reuses it, and it is one of the evaluation's eight capability calls. It does not prove the member keeps holding the NFT, and it counts NFTs without listing their token IDs. > [!DEV] > EVM NFT reads go through dRPC like EVM balance reads and need the platform's dRPC key; without it they fail with `provider-unavailable`. Solana NFT reads use the same Solana RPC as Solana balance reads: the wallets' token accounts through `getTokenAccountsByOwner`, then the candidate mints and their metadata accounts in batched `getMultipleAccounts` calls. `ctx.wallet.nfts` is optional in the `WalletReader` type only so that test doubles written before it keep compiling: Domino supplies it to every evaluation and the testing kit answers it from `facts.wallet.nfts`. ## Check staking and contract state [#check-staking-and-contract-state] Reward members who stake with you, hold voting power, or have a position in your vault. Pick the read by how the stake is recorded: | How the stake is recorded | Read it with | | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | Staking mints a receipt token, such as a liquid-staking token or ERC-4626 vault shares | [Token balances](#check-token-balances): `ctx.wallet.balance()` on the receipt token | | A contract keeps it per address, such as `stakedOf(address)`, `getVotes(address)` or `userInfo(pid, address)` | [Contract state](#read-contract-state): `ctx.wallet.call()` | | Native staking on a Cosmos SDK chain | [Cosmos delegations](#check-cosmos-delegations): `ctx.wallet.delegations()` | ### Read contract state [#read-contract-state] Domino calls one read-only function of your contract for each of the member's linked EVM wallets, every call at the same block, and gives you each result. #### 1. Install the snippet [#1-install-the-snippet-2] ```sh pnpm dlx shadcn@4.21.0 add https://snippets.domino.run/r/contract-state.json ``` It adds `quests/contract-state.quest.ts`, which accepts when the function's values for the member's linked EVM wallets add up to at least a minimum. Its settings are the contract function and the minimum, which Console shows in whole tokens. The default is ENS voting power on Ethereum, so set it to your contract. #### 2. Set the contract function [#2-set-the-contract-function] The setting holds the contract and the function in one line: the chain, the contract address, and the function's signature, such as `eip155:8453:0x… function stakedOf(address) view returns (uint256)` on Base. The chain is one `/wallet-balance-chains` lists. When you publish the quest, change the setting in Console, or unarchive the quest, Domino checks it. A function that can change state, or returns anything but one number, true/false value or address, is refused there, and so is a chain Domino does not read. Make these changes in test; they reach live through a promotion. > [!DEV] > The setting is `settings.contractCall({ label, default })`, typed as the query's `call`: the contract's CAIP-10 ID, a space, and a human-readable ABI signature. The function must be `view` or `pure`, take elementary parameters (`address`, `bool`, `string`, `bytes`, `bytesN`, `uintN`, `intN`), and return exactly one `uint`, `int`, `bool` or `address`; arrays, tuples and several return values are refused. #### 3. Or write the check yourself [#3-or-write-the-check-yourself-1] ```text import { defineQuest, settings, manual, once, points, decision } from "@domino-sdk/relay/authoring" import { community } from "./ledgers" export default defineQuest({ key: "stake-in-pool", title: "Stake in our pool", settings: settings.object({ staking: settings.contractCall({ label: "Staking contract", default: "eip155:8453:0x52908400098527886E0F7030069857D2E4169EE7 function userInfo(uint256 pid, address user) view returns (uint256)", }), pool: settings.integer({ label: "Pool ID", default: 0, min: 0, max: 1000 }), minimum: settings.amount({ label: "Minimum stake", default: "1000000000000000000", decimals: 18 }), }), trigger: manual(), completion: once(), rewards: [points(community, 50)], async evaluate({ wallet, settings }) { // { wallet: true } is each linked EVM wallet's address. const staked = await wallet.call?.({ call: settings.staking, args: [settings.pool, { wallet: true }], }) // null when the member has linked no EVM wallet. if (!staked) { return decision.reject({ reason: "Link an EVM wallet first." }) } return staked.total !== null && BigInt(staked.total) >= BigInt(settings.minimum) ? decision.accept() : decision.reject({ reason: "Stake at least the minimum, then try again." }) }, }) ``` The result is `{ call, chain, contract, returns, total, wallets: [{ address, value }], at, observedAt }`. Each wallet's `value` is a whole number as a string, `true` or `false`, or an address, as the function returns it. For a number, `total` is the wallets' sum; for anything else it is `null`. `at` is the block every call read. Pass `address` to call for one linked wallet. #### What the contract read covers [#what-the-contract-read-covers] * **The member's wallet goes in once.** `{ wallet: true }` stands for each linked wallet's address, in one `address` parameter. Every other argument comes from your settings or code, never from the member's input, and must fit its parameter. * **No wallet is not "has no stake".** With no linked EVM wallet, `ctx.wallet.call()` returns `null`. * **Failures are never ineligibility.** A revert throws a `CapabilityError` with code `author-error` and the contract's reason, and so do arguments that don't fit the function, an address with no contract, and data that isn't the declared type. A provider outage throws `provider-unavailable`. None of them reads as zero. * **State at verification time.** Each read is recorded with the attempt, so a retried verification reuses it, and it is one of the evaluation's eight capability calls. It does not prove the member keeps the stake. > [!DEV] > Contract calls go through dRPC like EVM balance reads and need the platform's dRPC key: Domino reads the latest block number, then sends every `eth_call` at that block, in batches. `ctx.wallet.call` is optional in the `WalletReader` type only so that test doubles written before it keep compiling: Domino supplies it to every evaluation and the testing kit answers it from `facts.wallet.call`. ### Check Cosmos delegations [#check-cosmos-delegations] Domino reads what the member's wallets linked on one Cosmos SDK chain delegate to validators, all at one block, in the chain's staking denomination. #### 1. Install the snippet [#1-install-the-snippet-3] ```sh pnpm dlx shadcn@4.21.0 add https://snippets.domino.run/r/cosmos-staker.json ``` It adds `quests/cosmos-staker.quest.ts`, which accepts when the member's wallets on the chain delegate at least a minimum, to any validator or to the one you set. Its settings are the chain (such as `cosmos:cosmoshub-4`), the validator's operator address (empty for any validator) and the minimum, which Console shows in whole tokens. The chain is one `/wallet-balance-chains` lists. #### 2. Or write the check yourself [#2-or-write-the-check-yourself-1] ```text import { defineQuest, settings, manual, once, points, decision } from "@domino-sdk/relay/authoring" import { community } from "./ledgers" export default defineQuest({ key: "delegate-to-us", title: "Delegate to our validator", settings: settings.object({ minimum: settings.amount({ label: "Minimum delegation", default: "1000000", decimals: 6 }), }), trigger: manual(), completion: once(), rewards: [points(community, 50)], async evaluate({ wallet, settings }) { const staked = await wallet.delegations!({ chain: "cosmos:cosmoshub-4", validator: "cosmosvaloper1qyqszqgpqyqszqgpqyqszqgpqyqszqgph84tp0", }) // null when the member has linked no wallet on this chain. if (!staked) { return decision.reject({ reason: "Link a Cosmos Hub wallet first." }) } return BigInt(staked.total) >= BigInt(settings.minimum) ? decision.accept() : decision.reject({ reason: "Delegate at least the minimum, then try again." }) }, }) ``` The result is `{ chain, denom, validator, total, wallets: [{ address, amount }], validators: [{ validator, amount }], at, observedAt }`. `denom` is the chain's staking denomination, such as `uatom`, and every amount is a whole number of its base units as a string: `total` for the wallets together, each wallet's own, and each validator's. With `validator`, only delegations to that validator count. Pass `address` to read one linked wallet. #### What the delegation read covers [#what-the-delegation-read-covers] * **Wallets linked on that exact chain.** A wallet linked on another Cosmos chain is not read. Any other `address` throws a `CapabilityError` with code `author-error`. * **No wallet is not "delegates nothing".** With no wallet on the chain, `ctx.wallet.delegations()` returns `null`. * **Failures are never ineligibility.** A chain Domino does not read and a malformed validator address throw `author-error`; a provider outage throws `provider-unavailable`. * **Bonded delegations at verification time.** Unbonding delegations, rewards and governance votes are not counted. Each read is recorded with the attempt and is one of the evaluation's eight capability calls. > [!DEV] > Delegation reads use the same platform-owned REST endpoint as Cosmos balances: the chain's `bond_denom` and each wallet's `/cosmos/staking/v1beta1/delegations/{delegator}`, every request pinned to the latest block's height. If the endpoint answers every request at one other height, that block is recorded; answers at different heights fail as an outage. `ctx.wallet.delegations` is optional in the `WalletReader` type for older test doubles; the testing kit answers it from `facts.wallet.delegations`. ## Check on-chain actions [#check-on-chain-actions] The [on-chain actions reference](/docs/reference/onchain) lists every option and result field of the EVM and Solana reads this section and the next use. Reward members for doing something with your contract on an EVM chain: swapping on your pool, minting from your collection, depositing into your vault, receiving a bridge transfer or voting on your Governor. Domino reads the events your contract emitted that name one of the member's linked wallets, so you need no indexer. An action counts only when its event names the member's wallet in an **indexed** argument, such as `to` in an ERC-20 `Transfer` or `voter` in a Governor `VoteCast`. A swap or deposit made through a router or aggregator, where the member is only the transaction's sender and your contract's event names the router, cannot be checked this way. ### 1. Install the snippet [#1-install-the-snippet-4] ```sh pnpm dlx shadcn@4.21.0 add https://snippets.domino.run/r/onchain-action.json ``` It adds `quests/onchain-action.quest.ts`, which accepts when one of the member's linked EVM wallets took part in a new matching event, and awards points once per event. Its settings are the event, the argument that names the member, and an optional minimum amount; the default is a USDC transfer of at least 1 USDC on Base, so set it to your contract. ### 2. Set the event [#2-set-the-event] The event is your contract on its chain and the event's Solidity signature, with argument names and `indexed` markers: ```text eip155:8453:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 Transfer(address indexed from, address indexed to, uint256 value) ``` When you publish the quest, change the event in Console, or unarchive the quest, Domino checks it. Make these changes in test; they reach live through a promotion. An event with no indexed address argument, a chain Domino does not read, a chain with no finalized or safe block, and an address with no contract are refused there. > [!DEV] > The event is a `settings.contractEvent({ label, default })` setting, typed as the query's `event`: a CAIP-10 contract, a space, and the signature (a leading `event` keyword is accepted). Every argument is named, at most three are indexed, and tuples and arrays of arrays are not supported. Publication, Console edits and unarchiving refuse a malformed event, a chain `/wallet-balance-chains` does not list, a chain whose RPC reports neither a `finalized` nor a `safe` block, and an address with no code. On a platform without its dRPC key, every event is refused, because none can be checked. ### 3. Or write the check yourself [#3-or-write-the-check-yourself-2] ```text import { defineQuest, settings, manual, once, points, decision } from "@domino-sdk/relay/authoring" import { community } from "./ledgers" export default defineQuest({ key: "vote-on-our-governor", title: "Vote on our proposal", settings: settings.object({ vote: settings.contractEvent({ label: "Vote event", default: "eip155:1:0x52908400098527886E0F7030069857D2E4169EE7 VoteCast(address indexed voter, uint256 proposalId, uint8 support, uint256 weight, string reason)", }), }), trigger: manual(), completion: once(), rewards: [points(community, 50)], async evaluate({ wallet, settings, now }) { const votes = await wallet.events?.({ event: settings.vote, account: "voter", since: now - 7 * 24 * 60 * 60 * 1000, where: { support: "1" }, }) // null when the member has linked no EVM wallet. if (!votes) { return decision.reject({ reason: "Link an EVM wallet first." }) } return votes.logs.length ? decision.accept() : decision.reject({ reason: "Vote for the proposal, then try again." }) }, }) ``` The result is `{ event, chain, contract, since, at, finality, complete, logs, observedAt }`. Each log is `{ wallet, block, timestamp, tx, logIndex, args }`, oldest first: `wallet` is the linked wallet the event names, `timestamp` the block's time in epoch milliseconds, and `args` every argument by name, with integers as decimal strings, addresses EIP-55 and bytes as lowercase hex. To reward each action once, key the completion on `` `${chain}:${tx}:${logIndex}` ``, as the snippet does. `where` narrows the logs by other arguments: an equality (an address in any case, an integer in decimal, `true` or `false`, or hex bytes) or `{ gte }` for an integer at least a value, such as `{ value: { gte: settings.minimum } }`. `afterActivation: true` also skips events before the quest was first activated in the environment. Pass `address` to read one linked wallet. ### What the event read covers [#what-the-event-read-covers] * **Finalized blocks only.** Domino reads up to the chain's `finalized` block, or its `safe` block on a chain that has no finality tag, so an event it counted is never undone by a reorganization. A transaction appears once its block is final, which takes about 13 minutes on Ethereum, longer on rollups such as Base, Optimism and Arbitrum that finalize after Ethereum does, and seconds to minutes on chains with fast finality. Tell members to try again later rather than reject them for good. * **At most 30 days back.** `since` earlier than 30 days before the evaluation throws `author-error`. * **At most 3,000,000 blocks and 1,000 logs.** A read over more than 3,000,000 blocks, or matching more than 1,000 logs before `where` filters non-indexed arguments, throws `author-error`. Pass `newest` to get the newest events instead, as described below. Without it, use a later `since`, or a `where` condition on an indexed argument. * **3,000,000 blocks is less than 30 days on fast chains.** That's about 34 days of 1-second blocks. At the block times Domino measured in October 2026, it covers fewer than 30 days on these chains: | Chain | Days | | --------------- | ---- | | Robinhood | 3 | | Gravity | 8 | | opBNB | 8 | | Arbitrum One | 9 | | Monad | 10 | | Cronos | 14 | | Plume | 15 | | BNB Smart Chain | 15 | | Sei | 16 | | Telos | 17 | | Arc | 17 | | Tempo | 19 | | Playnance | 20 | | Aurora | 22 | | Stable | 24 | | Moca | 28 | Block times change, so a read near the limit can start failing. Use `newest` for a window this long. * **`newest` returns a partial read instead of failing.** `newest: n`, from 1 to 1,000, returns at most the `n` newest matching events. Domino reads back from the newest block and stops at the block or log cap instead of throwing. `complete` is `true` when `logs` holds every matching event since `since`. It's `false` when older matching events were left out, or when the read stopped at a cap before reaching `since`. A read without `newest` returns all matching events with `complete: true`, or throws. Results recorded before Domino reported `complete` don't have it. * **No wallet is not "no activity".** With no linked EVM wallet, `ctx.wallet.events()` returns `null`. * **Failures are never ineligibility.** A malformed event, a chain Domino does not read, an `account` that is not an indexed address argument, a `where` on an unknown argument or of the wrong type, and an `address` the member never linked throw `author-error`; a provider outage throws `provider-unavailable`. None of them reads as no activity. * **Read once per attempt.** Each read is recorded with the attempt, so a retried verification reuses it, and it is one of the evaluation's eight capability calls. **Guard against farming.** An event is cheap to produce: anyone can send a small transfer, or split one deposit across many wallets. Set a minimum amount (`where` with `gte`), count only events after the member linked the wallet (the snippet compares each log's `timestamp` with the wallet's `verifiedAt`), keep the window recent, and cap how often one member can earn with a member budget. > [!DEV] > Event reads go through dRPC like EVM balance reads and need the platform's dRPC key. Domino estimates the first block from block times, then sends `eth_getLogs` over 10,000-block ranges, several at a time, halving a range the node refuses down to 100 blocks. `ctx.wallet.events` is optional in the `WalletReader` type only so that test doubles written before it keep compiling: Domino supplies it to every evaluation, and the testing kit answers it from `facts.wallet.events` without applying `since`, `where` or `newest`: answer a `newest` query with the logs and `complete` Domino would return. ## Check on-chain actions on Solana [#check-on-chain-actions-on-solana] Reward members for using your Solana program: swapping into your token on Jupiter, depositing into your vault, or calling your program at all. Solana has no event logs, so Domino reads the linked wallet's own transactions instead: the ones that succeeded, are final, and called your program. Each one comes with the programs it called and how the wallet's balances changed, so most checks need no decoding. When your program publishes an Anchor IDL, Domino also decodes the instructions it was called with, so you can check their arguments. | Quest | Check | | --------------------------- | ----------------------------------------------------------------------------------------------- | | Swapped into our token | The transaction called the swap program, and the wallet's token went up while another went down | | Deposited or staked | The transaction called your program, and the wallet's token went down by at least an amount | | Used our program at all | Any transaction that called your program | | Staked for at least 90 days | The transaction called your program's `stake` instruction with a lock of 90 days or more | ### 1. Install the snippet [#1-install-the-snippet-5] ```sh pnpm dlx shadcn@4.21.0 add https://snippets.domino.run/r/solana-action.json ``` It adds `quests/solana-action.quest.ts`, which accepts when one of the member's linked Solana wallets signed a new transaction that called your program, and awards points once per transaction. Its settings are the program, an optional token mint the wallet must receive in that transaction, and a minimum amount; the default is a Jupiter swap that brings the wallet at least 1 USDC, so set it to your program and token. For a deposit or stake, where the wallet gives tokens up, its README shows how to check what the wallet spent instead. ### 2. Set the program [#2-set-the-program] The program is its Solana address, such as Jupiter's: ```text JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 ``` When you publish the quest, change the program in Console, or unarchive the quest, Domino checks it. Make these changes in test; they reach live through a promotion. An address with no account, and an account that is not a program (a wallet, a token mint, or an account your program owns), are refused there. Publishing the quest or changing the program also stores the program's Anchor IDL, read from its on-chain IDL account, when it has one. Domino decodes the program's instructions with the stored IDL, so a later IDL upgrade on chain doesn't change a live quest until the next save. For a program without an on-chain IDL, open the quest in Console and paste its IDL under **Instruction decoding**: choose **Paste IDL**, paste the IDL's JSON, and choose **Use this IDL**. A program without any IDL still works; its transactions just come without decoded instructions. > [!DEV] > The program is a `settings.solanaProgram({ label, default })` setting (`picker: "solana-program"`), typed as the query's `program`: a base58 address of 32 to 44 characters (`isSolanaProgram()`). Publication, Console edits and unarchiving refuse a malformed address, an address with no account on Solana mainnet, and an account that is not executable; an RPC outage while checking is `503 unavailable`, not a refusal. > > The stored IDLs are the release's `solanaIdls`, by setting key: `{ program, source, idl }`, with `source` `"chain"` or `"pasted"`. Anchor IDLs in the legacy and the 0.30+ format are read; Shank and Codama IDLs are not. Domino reads the on-chain IDL account the program owns at `create_with_seed(find_program_address([], program), "anchor:idl", program)`. To paste one through the API, send `solanaIdls: { [settingKey]: idl }` with `POST /management/v1/releases/settings` (`quests.updateSettings()` in the server SDK); `null` removes a pasted IDL. A pasted IDL that is not an Anchor IDL, names another program in its `address`, or is larger than 512 KiB is `422 validation-failed`. An IDL the program publishes on chain takes precedence over a pasted one at the next save. ### 3. Or write the check yourself [#3-or-write-the-check-yourself-3] ```text import { defineQuest, settings, manual, once, points, decision, received, swapped } from "@domino-sdk/relay/authoring" import { community } from "./ledgers" const usdc = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" export default defineQuest({ key: "swap-into-usdc", title: "Swap into USDC on Jupiter", settings: settings.object({ program: settings.solanaProgram({ label: "Swap program", default: "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4", }), minimum: settings.amount({ label: "Minimum USDC", default: "10000000", decimals: 6 }), }), trigger: manual(), completion: once(), rewards: [points(community, 50)], async evaluate({ wallet, settings, now }) { const read = await wallet.transactions?.({ program: settings.program, since: now - 7 * 24 * 60 * 60 * 1000, }) // null when the member has linked no Solana wallet. if (!read) { return decision.reject({ reason: "Link a Solana wallet first." }) } const swap = read.transactions.find( (tx) => tx.signer && swapped(tx, { into: usdc }) && BigInt(received(tx, usdc)) >= BigInt(settings.minimum) ) return swap ? decision.accept() : decision.reject({ reason: "Swap at least 10 USDC on Jupiter, then try again." }) }, }) ``` The result is `{ program, chain, mint, since, at, complete, transactions, observedAt }`. Each transaction is `{ wallet, signature, slot, timestamp, signer, programs, balanceChanges }`, oldest first: `wallet` is the linked wallet whose history holds it, `timestamp` the block's time in epoch milliseconds, `signer` whether that wallet signed it, `programs` every program it called, and `balanceChanges` the wallet's changes as `{ mint, delta }`, with `mint` `"SOL"` for lamports and `delta` a signed integer string in base units. A transaction that touched two of the member's wallets appears once for each. To reward each action once, key the completion on the transaction's `signature`. Four helpers from `@domino-sdk/relay/authoring` read one transaction: | Helper | Returns | | ------------------------------- | --------------------------------------------------------------------- | | `received(tx, mint)` | How much of `mint` the wallet gained, as an integer string, or `"0"` | | `spent(tx, mint)` | How much of `mint` the wallet gave up, as an integer string, or `"0"` | | `swapped(tx, { into?, from? })` | Whether one mint went down and a different one went up | | `invoked(tx, program)` | Whether the transaction called `program` | ### 4. Check an instruction's arguments [#4-check-an-instructions-arguments] When the program's setting has an IDL, each transaction also has `instructions`: the program's instructions in it, top-level and called by other programs, in call order, each `{ name, args, accounts }`. `instruction(tx, name)` from `@domino-sdk/relay/authoring` returns the arguments of the first one named `name`, or `null`. For "Staked for at least 90 days": ```ts import { defineLedger, defineQuest, settings, manual, once, points, decision, instruction } from "@domino-sdk/relay/authoring" const community = defineLedger({ key: "community", name: "Community" }) export default defineQuest({ key: "stake-90-days", title: "Stake for at least 90 days", settings: settings.object({ // Your Anchor staking program, whose IDL has a stake(amount, lock_days) instruction. Replace // this placeholder with its base58 address: publication checks that the program exists. program: settings.solanaProgram({ label: "Staking program", default: "StakeProgram1111111111111111111111111111111", }), }), trigger: manual(), completion: once(), rewards: [points(community, 100)], async evaluate({ wallet, settings, now }) { const read = await wallet.transactions?.({ program: settings.program, since: now - 7 * 24 * 60 * 60 * 1000, }) if (!read) { return decision.reject({ reason: "Link a Solana wallet first." }) } const staked = read.transactions.some( (tx) => tx.signer && Number(instruction(tx, "stake")?.lock_days ?? 0) >= 90 ) return staked ? decision.accept() : decision.reject({ reason: "Stake with a lock of at least 90 days, then try again." }) }, }) ``` Names are spelled as the IDL spells them: snake\_case in a 0.30+ IDL (`lock_days`), camelCase in a legacy one (`lockDays`). An instruction the IDL doesn't describe is left out, never an error. | In the IDL | In `args` | | ------------------------------------------ | ------------------------------------------------------- | | `u8` to `u32`, `i8` to `i32`, `f32`, `f64` | A number | | `u64`, `i64`, `u128`, `i128` | A decimal string; compare with `BigInt()` | | `pubkey` | A base58 address | | `string`, `bool` | A string, a boolean | | `bytes`, `vec`, an array | An array | | An option | The value, or `null` when absent | | A struct | An object of its fields | | An enum | `{ Variant: fields }`, `{ Variant: {} }` without fields | `accounts` names each account the instruction lists, as the IDL names it, with its address: check `accounts` when the wallet must be the instruction's signer or owner, not only an account it touched. Pass `mint` to read only the transactions that touched each wallet's associated token account for that token, usually far fewer than the wallet's whole history. `afterActivation: true` also skips transactions before the quest was first activated in the environment. Pass `address` to read one linked wallet. ### What the transaction read covers [#what-the-transaction-read-covers] * **Finalized transactions only.** Domino reads at the `finalized` commitment, so a transaction it counted is never rolled back. A transaction appears about 13 seconds after it lands. * **Successful transactions only.** A failed transaction is skipped, even if it called your program. * **Called anywhere in the transaction.** A program called by another program, such as a pool Jupiter routes through, counts as called. Addresses loaded from lookup tables are included. * **Balance changes are the wallet's own.** They come from the token accounts the wallet owns and from its SOL balance, which leaves out the fee the wallet paid but includes any rent, such as for opening a token account. A token that passed through an account the wallet doesn't own, such as an intermediate account of a route, doesn't show. A swap out of SOL usually wraps and unwraps it inside the transaction, so it shows as SOL going down; SOL the wallet keeps wrapped shows under the wrapped SOL mint, `So11111111111111111111111111111111111111112`. * **Transactions that touched the wallet, whoever signed them.** A transfer someone else sent to the member's wallet, in a transaction that called your program, is returned too, with the tokens as received and `signer` false. * **With `mint`, only the associated token account.** Tokens the wallet holds in another token account aren't read. * **At most 30 days back.** `since` earlier than 30 days before the evaluation throws `author-error`. * **At most 1,000 transactions per wallet.** A busy wallet with more than 1,000 successful transactions since `since` returns its newest 1,000 and `complete: false`; it doesn't throw. So does a wallet whose last 10,000 signatures, failed ones included, don't reach back to `since`. Use a later `since` or `mint` to read fewer. * **No wallet is not "no activity".** With no linked Solana wallet, `ctx.wallet.transactions()` returns `null`. * **Failures are never ineligibility.** A malformed program, an account that is not a program, a `mint` that is not a token mint, and an `address` the member never linked throw `author-error`; a provider outage throws `provider-unavailable`. None of them reads as no activity. * **Read once per attempt.** Each read is recorded with the attempt, decoded instructions included, so a retried verification reuses it, and it is one of the evaluation's eight capability calls. **Guard against farming.** A transaction is cheap: anyone can make a tiny swap, split one deposit across many wallets, or swap and send the output to a member's wallet. Check `signer` for anything that rewards an action, so a transaction counts only when the member's wallet signed it; `received()` alone also rewards transfers someone else sent. Opening a token account costs the wallet about 0.002 SOL of rent, so a swap out of SOL needs a minimum above that, not only `swapped()`. Require a minimum amount (`received(tx, mint)` compared with a `settings.amount()` value), count only transactions after the member linked the wallet (compare `timestamp` with the wallet's `verifiedAt`), keep the window recent, and cap how often one member can earn with a member budget. > [!DEV] > Transaction reads use the same Solana RPC as balance reads: dRPC's `solana` network where the platform has its dRPC key. Domino pages `getSignaturesForAddress` at `finalized` back to `since`, skipping failed signatures, then fetches each transaction with `getTransaction` (`jsonParsed`, `maxSupportedTransactionVersion: 1`), several requests at a time. `ctx.wallet.transactions` is optional in the `WalletReader` type only so that test doubles written before it keep compiling: Domino supplies it to every evaluation, and the testing kit answers it from `facts.wallet.transactions` (called with `{ program, since, afterActivation, mint, address }`) without applying `since`, `program` or `mint`. A transaction there without `signer` reads as `false`, as does one captured before Domino reported it. To test an instruction check, answer with transactions that carry `instructions`. ## Sign in with a wallet [#sign-in-with-a-wallet] A visitor signs in, or signs up, by signing a message with their wallet. A new address creates a new member; the same wallet later signs them in again. The wallet joins the member's list for the project as a **sign-in wallet**. ### 1. Enable wallet sign-in for the project [#1-enable-wallet-sign-in-for-the-project] In Console, open **Settings → Sign-in**. **Sign-in methods** lists every sign-in method: the OAuth providers and email code Domino configures, read-only, then **Wallet sign-in**. Under **Wallet sign-in**, check the wallet families members can use: **Solana**, **EVM** or **Cosmos SDK**. Choose **Save**. Changing it needs publish permission, and each environment has its own setting. **Cosmos SDK** accepts a Cosmos wallet on any chain: your app names the chains it offers, and Console reminds you while Cosmos is checked. Agents and scripts use the management API: ```bash echo '{ "wallets": ["solana", "eip155", "cosmos"] }' > sign-in.json domino api PUT /participant-sign-in sign-in.json ``` Use only the three family codes shown above. Before upgrading to v1, replace any stored per-network Cosmos value such as `cosmos:cosmoshub-4` with `cosmos`; v1 rejects per-network codes in this project setting. Wallet sign-in is off until a project enables it. Wallet addresses cost nothing to create, so gate rewards with quests rather than counting sign-ups. If your app signs members in with a wallet and the test environment has no wallet family enabled, `domino stage` and `domino doctor --remote` warn and print the command that enables one. The build still stages. ### 2. Offer the sign-in options [#2-offer-the-sign-in-options] `useSignInOptions()` lists every way a member can sign in to the project from this browser, as one flat list: the configured OAuth providers, the installed wallets of the families the project enabled, and email. Each option has an `id`, a `label` ("Google", "Phantom", "Email"), a `kind` (`"oauth"`, `"wallet"` or `"email"`) and an `icon` hint. OAuth and wallet options sign in with `start()`; the email option sends a code with `sendCode(email)` and signs in with `verifyCode(code)`. `signInProviders` lists the configured sign-in providers on their own, Telegram included, each signing in with `start()`: [Telegram](/docs/build/telegram#offer-telegram-login) shows the buttons. ```tsx import { useState } from "react" import { useSignInOptions } from "@domino-sdk/relay/react" function SignIn() { const signIn = useSignInOptions({ cosmos: [{ chainId: "cosmoshub-4" }] }) const [email, setEmail] = useState("") const [code, setCode] = useState("") if (!signIn.options) { return

Loading…

} const hint = signIn.unavailable.find((entry) => entry.reason === "no-wallet-installed") return ( <> {signIn.options.map((option) => option.kind === "email" ? (
{ event.preventDefault() void (signIn.codeSent ? option.verifyCode(code) : option.sendCode(email)) }} > {signIn.codeSent ? ( setCode(event.target.value)} /> ) : ( setEmail(event.target.value)} /> )}
) : ( ) )} {hint &&

{hint.message}

} {signIn.error &&

{signIn.error.message}

} ) } ``` Cosmos wallets sign in on your app's chains: pass them as `cosmos`, each `{ chainId, chainInfo? }` or `"cosmos:"`, and Keplr, Leap or Cosmostation are listed once per chain. Without `cosmos`, the hook offers no Cosmos wallets even when the project enabled them. > [!DEV] > `options` is `null` until the project's methods are read. OAuth and email appear right away, and installed wallets join them when wallet discovery finishes; for a project that takes only wallets, `options` stays `null` until then. The options keep their identity across renders until the methods, the installed wallets or the chains change, so an effect can depend on them. Their actions never reject: a failure lands in `error`, a `RelayError`. When the methods cannot be read, the hook offers email alone and `methodsError`, also a `RelayError`, says why; the server still has the final say. `unavailable` says what the project enabled but this app or browser cannot offer: | `reason` | When | Who acts | | ----------------------- | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | | `cosmos-chains-missing` | The project takes Cosmos wallets and the app named no chains. | The developer: pass `cosmos`. | | `cosmos-chains-invalid` | The project takes Cosmos wallets and a chain in `cosmos` is invalid, such as `"cosmoshub-4"` without `cosmos:`. | The developer: `message` says what is wrong. Other methods still load. | | `no-wallet-installed` | The project takes wallets and none is installed. | The member: `message` names the wallet families to install. | The starter apps build their sign-in on this hook, next to email and OAuth, and a project that enables only wallet sign-in can take members with them. Starters name no Cosmos chains: add yours to the `useSignInOptions()` call in `src/components/sign-in.tsx`. In development, a starter shows `cosmos-chains-missing` and `cosmos-chains-invalid` as a note; a production build never shows them to members. When a member who signed in with an EVM wallet such as MetaMask or Rabby switches to another account in it, or locks it, the app signs them out and offers sign-in again. The page never keeps showing one account's points and quests while the wallet holds another, and the new account signs in only when the member chooses to. Picking the same account again changes nothing. Solana and Cosmos wallets are not followed this way. > [!DEV] > `RelayProvider` follows the wallet for sessions signed in with `useSignIn().withWallet(signer)` or a wallet option's `start()`: an `accountsChanged` event with a different address (compared case-insensitively) or an empty list signs out, as `useSignOut()` does. Pass `followWallet={false}` to `RelayProvider` to keep the session until the member signs out, if your app handles account changes itself. After a reload it finds the wallet again by name and checks `eth_accounts` without a prompt. Without React, `evmSigner(provider).watchAccounts(listener)` reports the accounts and returns the function that stops it; sign out with `relay.auth.signOut()` when they change. Without React, `signInOptions(relay, { cosmos })` from `@domino-sdk/relay/browser` returns the same `{ options, signInProviders, unavailable, methodsError? }` once wallet discovery finishes, each option signing in with that client. Its actions reject when they fail, so catch them: ```ts import { createBrowserRelayClient, signInOptions } from "@domino-sdk/relay/browser" const relay = createBrowserRelayClient({ baseUrl: "/relay" }) const { options } = await signInOptions(relay) const wallet = options.find((option) => option.kind === "wallet") if (wallet?.kind === "wallet") { await wallet.start().catch((error) => console.error(error.message)) } ``` Apps with their own wallet UI can still pass a discovered wallet's signer to `relay.auth.wallet.signIn(signer)`; `relay.auth.providers().wallets` lists the families this project enabled. ### 3. Handle refusals [#3-handle-refusals] | Error | Status | What to do | | --------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Wallet sign-in is not enabled for this project | 403 | Enable the wallet's family in Console, or hide that wallet. | | This wallet is linked to an account but is not a sign-in method. Sign in with your original method. | 409 | The member linked this wallet while signed in. They sign in their original way. | | Sign out before switching accounts | 409 | Sign out first. | | Too many sign-in requests. Try again later. | 429 | Wait: every ten minutes, thirty per IP address (an IPv6 address counts by its /64), ten per wallet address from one IP address, and thirty per wallet address in the project. | | The wallet signature does not match this challenge | 400 | Sign again with the same wallet and account. | A challenge lasts five minutes and works once. `signIn` is not retried automatically: if it fails, start again. ## Show a generated avatar [#show-a-generated-avatar] `defaultAvatar(seed)` draws a deterministic SVG avatar and returns a data URI. Seed it with the `avatarSeed` Domino returns for the signed-in member: Domino derives it from the member, so linking wallets does not change it, and it reveals no address. ```tsx import { defaultAvatar } from "@domino-sdk/relay/avatar" import { useMe } from "@domino-sdk/relay/react" function Avatar() { const me = useMe() return me.status === "ready" ? ( ) : null } ``` Outside React, `relay.me.get()` returns `{ member, avatarSeed, email, emails, clock }`. After wallet-only sign-in, `email` is null and `emails` is empty. [Member profiles](/docs/build/profiles) use the same seed for their generated avatars. > [!WARNING] Never seed shared avatars from an address > Anyone can compute the avatar of any address, so an avatar seeded from a wallet address shows which member holds it. Use `walletAvatarSeed(wallet)` only in a member's own view of their wallets. ## What operators see [#what-operators-see] A member's page in Console lists their linked wallets by chain family, with a Cosmos wallet's chain ID (such as **Cosmos · cosmoshub-4**), marked **Sign-in wallet** or **Linked wallet**. **Recent balance checks** lists the balances quest verifications recorded for them: the amount, the token and chain, and a link to the attempt. These are saved observations, not live balances. **Unlinked** lists earlier wallets and who unlinked them. An operator with review permission on the project can, for a wallet in the project's list and with a reason the audit log records: * **Unlink** a wallet. Unlinking does not stop a sign-in wallet from signing in. A wallet marked **Imported** or **Vouched by** that the member never signed in with or proved is released instead: it leaves the member, with nothing to relink, and its owner can link it. * **Relink** an unlinked wallet. See [restore a retained wallet link](#restore-a-retained-wallet-link). * **Disable sign-in** for a wallet, for example when its key leaks. It can no longer sign in to the project in that environment; sessions it already started end within 8 hours. Console warns when it is the member's only sign-in method. * **Enable sign-in** for a wallet the member linked, when you are sure they control it. ### Restore a retained wallet link [#restore-a-retained-wallet-link] Under the member's **Wallets**, open **Unlinked**, choose **Relink** and record why its ownership is safe to restore. Review permission is required. The wallet must already belong to this member in this project and environment. Relink preserves the original proof and first-link time. It leaves wallet sign-in unchanged; use **Enable sign-in** or **Disable sign-in** separately. Relinking also gives tracker activity that was skipped while the wallet was unlinked a fresh decision, under each quest's current history policy, and keeps attempts already admitted. See [historical activity](/docs/concepts/trackers#historical-activity). > [!DEV] > Scripts read `management.members.identityStates.list(member)`, then call `identityStates.relink(member, { identity: { kind: "wallet", chain, address }, expectedRevision, reason })`. `identityStates.unlink` takes the same input, and `identityStates.setSignIn(member, { identity, enabled, reason })` turns sign-in on or off. See [operator identity changes](/docs/concepts/participants#operator-identity-changes). # Get notified with webhooks (/docs/build/webhooks) A webhook lets your own systems hear about member activity as it happens. Use one to: * **Ping your team** when a submission needs review. * **Update your CRM** when a member completes a quest or earns points. * **Ship a reward** your own system fulfils, such as merch, when a member earns it. You add an endpoint, a web address on your backend, and pick the events it should receive. Domino then sends each matching event there, signed so your backend can tell it came from Domino, and retries when your backend doesn't answer. Your team can see every delivery in Console and send a failed one again. > [!DEV] > Configure a public HTTPS receiver, then subscribe to activity from one project and environment. Use a management credential with `publish` permission. Store the returned signing secret in your backend's secret store. ## Manage endpoints in Console [#manage-endpoints-in-console] Open **Settings → Webhooks** in the project and environment you want. Choose **Add endpoint**, enter the public HTTPS URL, tick the events it should receive, then choose **Create endpoint**. Copy the one-time signing secret to your backend before leaving the page. Choose **View endpoint** to edit the URL or event filters (an event name Console doesn't list stays subscribed when you save), disable or enable delivery, or rotate the signing secret. Its **Endpoint ID** has a copy button, for the CLI and `management.webhooks` calls that name the endpoint. The delivery log shows status, attempts, HTTP response and failure reason. After fixing the receiver, enable the endpoint and choose **Replay delivery** on a completed, failed or skipped delivery. Use **Load older deliveries** to continue the log and **Refresh deliveries** to check retry results. People with read permission can inspect endpoints and deliveries; Console hides mutation controls from them. Changes, secret rotation and replay require publish permission. A pending delivery has no replay action until it finishes. These operational controls work in both Test and Live. ## Connect your backend [#connect-your-backend] ### 1. Create an endpoint [#1-create-an-endpoint] ```ts import { createManagementClient, activityTypes } from "@domino-sdk/relay/server" const management = createManagementClient({ baseUrl: "https://relay.domino.run", organization: "your-organization", project: "your-project", environment: "test", getToken: async () => "your-management-token", }) const created = await management.webhooks.create({ url: "https://your-backend.example/webhooks/domino", types: [activityTypes.completionCreated, activityTypes.reviewPending], }) const endpointId = created.endpoint.id const signingSecret = created.secret ``` The secret is returned only when you create the endpoint or rotate its secret. List responses omit secrets. Keep the endpoint ID for delivery inspection and recovery. To use the CLI, save the URL and `types` in `endpoint.json`, then run: ```sh domino webhooks create endpoint.json --environment test --json ``` Save the one-time `secret` from the output securely. List endpoints with `domino webhooks list --json`. Domino refuses an event type it never sends, such as a typo, with `unknown-activity-type`. The error lists the valid types. If the answer is lost, for example when the connection drops, do not create the endpoint again. The CLI saves the create as an operation and exits with code 3: run `domino operations resume ` with the ID from the error to get the same endpoint and secret. The saved operation keeps the secret in your local Domino configuration directory only until the CLI has printed it once; after that, rotate the secret if you lost it. From the SDK, call `create` again with the same `actionId`. ### 2. Verify the raw request [#2-verify-the-raw-request] Read the body once as text. Pass the `Domino-Signature` header and raw body to `verifyWebhook` before you process the event. ```ts import { verifyWebhook, type WebhookEvent } from "@domino-sdk/relay/server" export async function receive( request: Request, secret: string, acceptOnce: (event: WebhookEvent) => Promise, ): Promise { let event: WebhookEvent try { event = await verifyWebhook({ body: await request.text(), signature: request.headers.get("Domino-Signature") ?? "", secret, }) } catch { return new Response("Invalid webhook", { status: 400 }) } await acceptOnce(event) return new Response(null, { status: 204 }) } ``` Branch on `event.type`, a string, by comparing it with `activityTypes`, and ignore a type you don't handle instead of failing on it, since Domino adds event types. To type your own handlers, use `ActivityType` from `@domino-sdk/relay/server`: it names the known types and stays open to new ones. Implement `acceptOnce` with durable storage keyed by `event.id`. Commit the dedupe record and your business effect in the same transaction, or atomically enqueue durable work. Return a 2xx response only after that commit. Apply a 64 KiB request-body limit in your HTTP server before reading the body. > [!IMPORTANT] Dedupe is separate from signature verification > The signature helper checks authenticity and timestamp age. A valid request can be sent again within that window. Persist processed event IDs, including across deployments and manual replays. ### 3. Rotate the signing secret [#3-rotate-the-signing-secret] Call `management.webhooks.rotateSecret(endpointId)` or `domino webhooks rotate-secret --json`. Install the new secret in your receiver immediately. Domino signs with both old and new secrets for 24 hours, so either verifies during deployment. A second rotation during a usable overlap window returns 409. After 24 hours only the new secret signs requests. If a rotation's answer is lost, resume its CLI operation, or call `rotateSecret(endpointId, { actionId })` again with the same `actionId`. Either returns the same new secret during the overlap. After a later rotation, the earlier one's retry returns 409 `secret-superseded`. > [!DEV] When the old secret is unusable > If Domino cannot decrypt the old secret after an infrastructure encryption-key replacement, rotation restores signing immediately and returns `previousValidUntil: null`. There is no overlap: install the returned secret in your receiver, re-enable the endpoint if needed, and replay failed deliveries. An unusable overlap does not block this recovery. ## Recover a failed delivery [#recover-a-failed-delivery] 1. Call `management.webhooks.deliveries(endpointId)` or `domino webhooks deliveries --json`. Inspect `status`, `attempts`, `responseStatus`, and `lastError`. 2. Fix the receiver. If the endpoint is disabled, call `management.webhooks.update(endpointId, { enabled: true })`. 3. Call `management.webhooks.replay(endpointId, deliveryId)` or `domino webhooks replay --json`. Replay uses the original event ID and payload, the current endpoint URL, and the current signing secrets. The new delivery gets its own ID and retry budget. Pending deliveries cannot be replayed. Replaying a successful event still reaches your dedupe check. If a replay's answer is lost, check the delivery log before replaying again, or pass an action ID up front: `domino webhooks replay --action-id `. Sent again with the same ID, the replay returns the delivery it first created instead of creating another. Pass the returned `nextCursor` back as `cursor` (`--cursor` in the CLI) to continue the delivery log. Endpoint changes and replay are audited. The same delivery diagnostics and replay controls are available in **Settings → Webhooks**. > [!NOTE] Test also sends requests > Test and live endpoints are independent, and both send real HTTPS requests. A [promotion](/docs/concepts/promotion) never copies endpoints: when test has one and live has none, the promotion warns, and **Open Webhooks** on it in **Live → Deployments** opens live's webhook settings. CLI sign-in sessions cannot change live endpoints. A live API key with `publish` permission can manage live endpoints. See the [webhook reference](/docs/reference/webhooks) for payload fields, signing format, event types, and limits. ## Member activity and event names [#member-activity-and-event-names] The same committed changes can drive a member's cursor-polled activity feed and your backend's webhooks. Member polling works without a webhook endpoint. Subscribe to `activityTypes.attemptRejected` (`attempt.rejected`) for rejected attempts, `activityTypes.entitlementGranted` (`entitlement.granted`) for new or upgraded earned tiers, and `activityTypes.rewardDeliveryUpdated` (`reward.delivery.updated`) for delivery state changes, including Discord reconciliation. Existing completion, review-pending, point-award, collection and referral-qualification events remain available. Webhook payloads identify the affected member. The member feed explicitly selects public fields and omits review notes, provider diagnostics and the other member's referral identity. Its event names describe member outcomes, such as `attempt.accepted` and `delivery.updated`; do not assume they are identical to webhook event names. See [Participant activity](/docs/reference/participant-api#participant-activity). > [!DEV] > A newly queued integration reward publishes `reward.delivery.updated` with status `pending` in the same transaction as its delivery record. The event does not depend on the Discord reconciliation worker starting. ## Delete an endpoint [#delete-an-endpoint] With publish permission, choose **Delete endpoint** in Console **Settings → Webhooks**, read its current revision and provide a reason. Deletion stops new delivery and retries, frees endpoint capacity and keeps read-only delivery history under **Deleted endpoints**. An HTTP request already in flight may finish. Deleted endpoints cannot be resumed, rotated or replayed; create a new endpoint to send again. > [!DEV] > Use `management.webhooks.state(id)` for the current fence, then `management.webhooks.delete(id, { expectedRevision: endpoint.revision, reason })`. `management.webhooks.states({ cursor, limit })` pages active and deleted endpoints, and `deliveries(id, query)` retains their history. The CLI provides `domino webhooks get ID`, `domino webhooks states` and `domino webhooks delete ID --expected-revision N --reason TEXT`. Mutation inputs accept an optional action ID for identical retries. These controls retain the existing publish permission and credential rules in Test and Live. # Access: credentials, roles and permissions (/docs/concepts/access) Everything that changes a Domino project goes through a **credential**: a person's Console session, a key for an app or an agent, or a sign-in the CLI saved. What a credential may do depends on three things: 1. **Who it acts for.** A person's credentials carry that person's roles. A key that no person owns carries only the permissions it was given. 2. **Its own limits.** Most keys work in one project and one environment, and some kinds can never do certain things, whatever their permissions say. 3. **The environment.** Live, where your real members are, accepts fewer changes than test. See [what each credential may do in live](#what-each-credential-may-do-in-live). Domino checks all three on every request, whichever tool sends it. Hiding a button in Console is never what stops an action. ## Organization admins and project roles [#organization-admins-and-project-roles] People join your organization through Console, which uses Clerk for accounts and invitations. Each person is either an **organization admin** or a member. * **Organization admins** hold every permission in every project, in test and live, without being granted anything. Only they manage the team's access, service and automation keys, app login keys, and the project itself: its name, deletion and transfer. * **Members** can do nothing in a project until an admin grants them a role there, in **Settings → Team**. A member can hold several roles in a project: | Role | What it's for | Permissions | | --------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | | Editor | Publish quests, manage stock and points, and send tracker records and enroll members from your backend | Read, Publish, Inventory, Points, Ingest, Enroll, Dashboards | | Reviewer | Review evidence and retry attempts | Read, Review, Dashboards | | Staff | Confirm quests and hand over rewards | Read, Confirm, Handover, Dashboards | | Viewer (experimental) | See the project's dashboards and nothing else: no members, evidence or settings. For a sponsor or client | Dashboards | ### Permissions [#permissions] | Permission | Allows | | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Read | Looking up the project: quests, members, progress, balances and analytics | | Publish | Changing what the program is in test, preparing promotions, approving them in Console, and running day-to-day controls such as pausing a quest | | Review | Deciding submissions and retrying attempts | | Confirm | Confirming that a member completed a quest, such as checking them in at an event | | Handover | Handing over rewards that members collect in person, and correcting those handovers | | Inventory | Managing offers and stock | | Points | Awarding, spending and correcting points | | Ingest | Sending records to custom [trackers](/docs/concepts/trackers) | | Enroll | Enrolling members in quests and campaigns from your server, which vouches for their consent | | Dashboards | Reading the project's [dashboards](/docs/build/dashboards), which show totals and never an individual member. Read includes it, and no key holds it on its own | | Access | Administering access. Only organization admins hold it: no role includes it, and no key can hold it | ### Grants are per environment [#grants-are-per-environment] An admin chooses, for each member, the roles and whether they apply in test, live or both. The same roles apply in each environment chosen. A grant can also have an end date: after it, the grant gives nothing, and **Settings → Team** shows it as **Expired** until an admin changes it. A member granted Editor in test only can build and try quests, and can neither read nor change anything in live. Changes to a grant, and removing someone from the organization, take effect on that person's next request, for every credential that acts for them. > [!DEV] > Roles are `editor`, `reviewer` and `staff`; permissions are `read`, `publish`, `review`, `confirm`, `handover`, `inventory`, `access`, `points`, `ingest` and `enroll` (`roleSchema`, `permissionSchema` and `rolePermissions` in `@domino-sdk/relay/server`). The experimental `viewer` role and `dashboards` permission are in `projectRoleSchema`, `projectPermissionSchema` and `projectRolePermissions`; responses list them in `projectRoles` and `projectPermissions`, beside the 1.x `roles` and `permissions`. An admin is a member with Clerk's `org:admin` role. `management.access()` lists the permissions the current credential has in the selected project and environment. A grant is `{ subject, roles, environments, expiresAt? }` (`projectGrantSchema`, `expiresAt` in epoch milliseconds), set with `PUT /management/v1/grants` from an admin's Console session. ## Kinds of credentials [#kinds-of-credentials] | Credential | Who gets it, and how | What it acts as, and where | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | Console session | Anyone who signs in to Console | That person, in any project and environment their roles reach | | Personal API key, `ak_…` | Anyone with a role in the project other than Viewer alone, in **Settings → API keys**, or `domino tokens create` for a test key | Its owner, with the permissions chosen for it, in one project and one environment | | CLI session, `dc_…` | `domino login`, approved in Console | That person, in any project their roles reach, with the [CLI's limits](#what-each-credential-may-do-in-live) | | Playground session, `dc_…` | Starting a [Playground](/docs/build/playground) | That person, in its one project and environment | | AI assistant connection, `oat_…` | [Connecting an AI assistant](/docs/agents/connect) and signing in | That person, in any project their roles reach, with the CLI's limits and no changes in live | | Service key, `dsk_…` | An organization admin, in **Settings → API keys** | An app's own backend, in one project and one environment | | Automation key, `dak_…` | An organization admin, in **Settings → API keys** | A headless agent or CI, in all of the organization's projects or chosen ones, changing test only | | Hosted app key, `dhk_…` | Domino, for each [hosted app](/docs/concepts/hosting) server and `domino dev` | The hosted app's server, in its project: test for previews, staging and development, live for the live app | | App login key, `di_…` | An organization admin, in **Settings → App login keys**, which can save it straight into a hosted app's secrets | Your server, signing members in with [your app's login](/docs/build/app-login) for one project and environment; it calls nothing else | The first five act for a person and stop working when that person leaves the organization. Service, automation, hosted app and app login keys belong to no person. A `domino login` request waits 15 minutes for approval. Console's approval page shows when it was made, the country it came from and the CLI version and system that sent it. Approve only a request your own terminal just made: the session it creates acts as you. Connecting a service through its own sign-in, such as [Klaviyo](/docs/build/klaviyo), is finished in Console by the person who started it, so a credential that belongs to no person can't start one. ### Personal API keys [#personal-api-keys] A personal key acts as you, so it can never do more than you can. You choose its project, its environment and a subset of your permissions in them when you create it, and it can't hold Access. A key from Console expires after 30 days; a test key from `domino tokens create --days` lasts 1 to 90. It stops working when you leave the organization, and loses any permission you lose. A key holding Ingest can be limited further, to sending records to chosen custom trackers only. Use one for an indexer or a partner's server that should not reach anything else. You can create a test key from the CLI. A live key is created in Console only. ### Service and automation keys [#service-and-automation-keys] These belong to no person, so they keep working when the admin who made them leaves. Each has the permissions chosen when it was created, never Access, and never expires unless an expiry was chosen. An admin can rotate a key by adding a new secret, then revoking the old one once nothing uses it, or revoke the whole key at once. * **A service key** is for an app's own backend, outside Domino hosting: awarding points, sending tracker records, qualifying referrals. It works in one project and environment. See [connect your app](/docs/start/existing-app#5-call-domino-from-your-backend). * **An automation key** is for a coding agent or CI with nobody there to approve `domino login`. It reaches all of the organization's projects or chosen ones, holds at most Read, Publish, Inventory, Points, Ingest and Enroll, and changes nothing in live. See [run an agent without signing in](/docs/agents/automation). ### Hosted app keys [#hosted-app-keys] Domino gives every staged and live Worker app, and the dev server `domino dev` starts, its own key, so nobody pastes a credential into a hosted app. It holds exactly Read, Points and Ingest, in its own project and environment. A `domino dev` key ends when the dev server stops, or after 12 hours. See [call the management API from your server](/docs/build/hosted-app#call-the-management-api-from-your-server). ### What keys and the CLI don't do [#what-keys-and-the-cli-dont-do] * **Administer access.** Grants, the team and the project's name, deletion and transfer take an organization admin's Console session. CLI sessions and AI assistant connections are refused too. * **Create another credential.** Only an admin's Console session creates service, automation and app login keys. Personal keys are created by their owner in Console, or as test keys from a CLI session; no key creates or revokes one. * **Act as test members.** Service and hosted app keys are an app's own server, so they can't create [test members](/docs/build/testing) or act as them. Console sessions, personal keys, CLI and Playground sessions, AI assistant connections and automation keys can, in test, when they act with Publish. > [!DEV] > Every request sends `Authorization: Bearer `, and Domino tells kinds apart by the prefixes above. Old `dm_` tokens are refused with 401 `invalid-credential`; replace them with a personal API key. When an API server has refused one address 60 times within 10 minutes for a missing or refused credential or a path the API does not serve, it answers that address's management, MCP and Git requests 429 `rate-limited` with `Retry-After` until the 10 minutes end, valid credentials included. A personal key or MCP token counts only when Clerk does not recognize it. Successful requests and Git's first, credential-less request of a fetch or push do not count. A credential fixed to a project or environment refuses a request whose `X-Relay-Project` or `X-Relay-Environment` names another, with 403 `scope-mismatch`. Refusals use the [error codes](/docs/reference/errors#sign-in-or-use-another-credential): `permission-required` for a missing permission or admin role, `credential-not-allowed` when the action needs another kind of credential, and `console-approval-required` for the live limits below. Personal keys are managed with `domino tokens` and `management.tokens`; service keys with `management.serviceKeys` and automation keys with `management.automationKeys`, from an admin's Console session. ## What each credential may do in live [#what-each-credential-may-do-in-live] Live accepts two kinds of change, as [test and live](/docs/concepts/environments) explains. **Definitions** (quests, rewards, point ledgers, code and the rest of what your program is) reach live only through a [promotion](/docs/concepts/promotion) a person approves in Console. **Operations** (pausing a quest, reviewing a submission, correcting points) happen in live directly, under the credential's permissions. No credential changes definitions in live, Console included, and new code reaches live only through a promotion: make the change in test and promote it. For everything else: | Credential | Decide a promotion | Day-to-day operations | | ------------------------------ | ------------------ | --------------------------------------- | | Console session | Yes, with Publish | Yes | | Personal API key for live | No | Yes, with its permissions | | Service key for live | No | Yes, with its permissions | | Hosted app key of the live app | No | Read, points and tracker records | | CLI session | No | Only the [list below](#the-cli-in-live) | | AI assistant connection | No | None: reads only | | Automation key | No | None: reads only | Every credential that reaches live and holds Read can look things up there. A key or session fixed to test can't read live. ### The CLI in live [#the-cli-in-live] A `domino login` session may make only these changes in live: * Prepare a promotion with the deprecated `domino deploy --promote` (`domino promote` does the same from test), and roll back a promotion. A person still approves each promotion in Console. * Preview catalog changes and submit them for review. * Set app secrets, custom domains and plugin connections, which belong to each environment and never move with a promotion. * Upload images a preview needs. * Prepare, check, cancel, stop and revert a [member import](/docs/use/imports). Only Console applies one in live. * Revoke your personal keys. The CLI creates and changes test keys only. For anything else in live, use Console, or create a live personal key there for your script. ### Actions only Console takes in live [#actions-only-console-takes-in-live] Some live actions need a person signed in to Console, whatever keys exist: * Approving or rejecting a promotion. * Applying a member import. * Creating a personal key for live, or changing which trackers one may send to. Deleting a whole quest's evidence at once in live takes an organization admin, through Console or their own personal key, or a service key holding Review, because it can't be undone. Deleting one attempt's evidence stays a reviewer's action. > [!DEV] > A definition write in live answers 422 `promotion-required` to Console sessions and to personal, service and hosted app keys; a CLI session, AI assistant connection or automation key mostly gets 403 `console-approval-required` from the live limits first. Deciding a promotion, applying a member import, and publishing, rejecting or rolling back a live code deployment made before promotions answer 403 `console-approval-required` in live to any key; a CLI session, AI assistant connection or automation key gets the same code for its other live writes. An automation key or AI assistant connection also can't attach, re-check or remove a custom domain from test, because a domain always serves live. The quest-wide evidence deletion is `POST /management/v1/attempts/redact-evidence`; in live it answers 403 `permission-required` to any other credential the live limits above let through. # Analytics (/docs/concepts/analytics) Analytics answer "how is the program going?" with numbers Domino counts from its own records. For example: * **How many people signed up this week**, and how that compares with last week. * **How many members were active each day** of the last month. * **How many friends qualified** from your referral race. Each number is a **metric**, counted for one project in one environment. Domino counts sign-ups, active members, quest attempts, completions, rejections, attempts sent to review, points awarded, spent and net, members earning points, offer redemptions, accepted and qualified referrals, group joins and leaves, tracker outcomes, and the members in leaderboard settlements. See them in Console under **Analytics**, for the selected project and environment: headline numbers, a chart per metric, and a table by quest. Your developer or coding agent can read the same numbers through the CLI or the API. Quote these numbers rather than computing your own from other reads. > [!DEV] > Read a project's metrics over a range with `domino analytics get` or `management.analytics.read()` (`read` permission). [Project analytics](/docs/reference/automation#project-analytics) defines every metric. ## How metrics match Console [#how-metrics-match-console] Where Console counts the same thing, a metric uses the same definition, so the numbers agree: * **Sign-ups** are the memberships the Members page counts. * **Completions** are the completions Console counts, never revoked ones. Some metrics count history Console doesn't show: * **Attempts sent to review** count every attempt that went to staff review, decided since or not. Console counts only those still waiting. * **Active members** count members with an attempt in each period. Any member with an attempt counts, test members included, and so does a member whose attempt a tracker started. This is not the [active members](/docs/concepts/participants#active-members) count your plan bills, which counts live members who sent a signed-in request on a day after they joined, and leaves out test members, imports and tracker records. **Settings → Plan & usage** shows the billed count. ## The metric catalog [#the-metric-catalog] Domino counts a fixed set of metrics, each with a one-line definition. There are no formulas or custom queries: a new number becomes a new metric in Domino. > [!DEV] > Each metric has an ID, such as `quests.completions`, a definition, and the breakdowns and filters it accepts. List the catalog with `domino analytics metrics`, and read one metric with `domino analytics query --metric ID --range 7d`. The [metric catalog reference](/docs/reference/automation#metric-catalog) lists every metric. Read with no breakdown or filter, a metric that `domino analytics get` also shows has the same number there: both run the same count. Two metrics are rates, from 0 to 1: the share of decided attempts that were rejected, and the share of accepted referrals whose friend has qualified since. A rate stays empty while fewer than 10 attempts or referrals stand behind it, for the whole range, each period, and each row of a breakdown. > [!DEV] > The rates are `quests.rejectionRate` and `referrals.qualificationRate`, which counts the referrals accepted in a bucket. An empty rate is `null`. ## Breakdowns and filters [#breakdowns-and-filters] A **breakdown** splits a metric into rows: by quest, offer, referral program, group, tracker or leaderboard, by where and why attempts were rejected, or by whether a tracker's records were admitted or skipped. A quest you publish tomorrow gets its own row without anyone changing the query. An archived quest or program shows up only when it had activity in the range or the range before, marked as archived. Rows come largest first, and the rest can be summed into an "Other" row, so a chart can show its top 8. Rejections split by who made them: your evaluator, staff, an exhausted budget, an expired deferral, or Domino itself when another member already completed the quest with the same key. That last case is a shared key, such as a one-time code two members both tried, not a fault in your evaluator; an evaluator that rejects with its own `key-used` code still counts as the evaluator. Top rejection reasons keep the two apart as well. > [!DEV] > The sources are `evaluator`, `staff`, `budget`, `expired` and `keyUsed`, in `rejectionsBySource` and as `source` rows. Each top reason's `codeSource` says who wrote its code: `relay` or `evaluator`. A **filter** narrows what a metric counts: | Filter | Keeps | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `quest` | Activity on those quests | | `tracker` | Activity on the quests the tracker triggers; on `trackers.records`, the tracker's own outcomes | | `offer` | Redemptions of those offers | | `program` | Qualifications in those referral programs | | `leaderboard` | Settlements of those leaderboards | | `group` | Activity by members who are in the group now, whatever their membership was at the time; on `groups.joins` and `groups.leaves`, the changes in that group | > [!DEV] > Each filter takes a key or a list of keys. A query caps breakdown rows with `limit` (1 to 100, default 100) and sums the rest into `other`; for a rate, `other` divides the summed numerators by the summed denominators of the rows it holds. A group is checked when the query runs: a key with no group answers `group-not-found`, naming it. ## Dashboards [#dashboards] A **dashboard** is a page of panels that project code defines with `defineDashboard`. Each panel reads one catalog metric, with at most one breakdown, its filters, a ledger for points metrics, and a chart: `number`, `line`, `bar` or `table`. A panel's numbers are what `domino analytics query` returns for the same metric, breakdown and filters. A dashboard is a definition, like a quest: * **Deployed in test.** A deployment checks every panel against the catalog and every key against the environment, and refuses a dashboard that breaks a rule. A changed definition publishes a new version; an identical one publishes nothing. * **Promoted to live.** A [promotion](/docs/concepts/promotion) carries it to live by key. * **Archived, not deleted.** Removing it from relay.json leaves it in place. Archiving takes it out of the list and keeps it readable. A dashboard shows totals, never an individual member, so a person with the experimental **Viewer** role can read the project's dashboards without reading anything else in it: a way to [share them with a sponsor](/docs/build/dashboards#share-dashboards-with-a-sponsor). Dashboards read every panel at once, over one range: the dashboard's default, or the one you choose. A panel that fails, such as one filtered on a group the environment lacks, carries its own error while the others still read. [Dashboards](/docs/build/dashboards) shows how to define, deploy and check one, and the [analytics reference](/docs/reference/analytics) how an agent picks metrics for a project's goals and explains a dashboard's numbers. ## Ranges end now [#ranges-end-now] You choose a range: the last 7, 30 or 90 [project-calendar](/docs/concepts/calendar) days, today included, or all time. All time starts on the day of the project's earliest dated record: usually its first membership, or an older record such as a completion imported with its original date. Every range ends at the moment you read it. | Range | Buckets | Compared with | | ----------- | ------------------------------------------------------------------------ | ----------------------------------- | | `7d`, `30d` | One per project-calendar day | The range of equal length before it | | `90d` | One per week, from Monday; the first and last weeks are cut to the range | The range of equal length before it | | `all` | One per week, from Monday; the first and last weeks are cut to the range | Nothing | Days and weeks follow your project calendar, as budgets and leaderboards do. A day starts at local midnight. > [!DEV] > Each metric has a `total` for the range, a value per bucket (`series`), and, except for `all`, a `previousTotal` for the range before. A day lasts 23 or 25 hours around daylight saving, and when the timezone changes, the day or week already open finishes in the old timezone. Each bucket's key names its timezone, such as `2026-10-08@Europe/Budapest`. ## Records count as they stand now [#records-count-as-they-stand-now] A metric reads the records as they are when you read it, so the past can change: * **A completion revoked later leaves the bucket it was in.** So does a redemption canceled later, and a leaderboard settlement rejected later. * **Tracker outcomes show their current state.** A retry overwrites a skipped outcome, so `trackers.records` shows each record's outcome today, in the bucket of when the record was observed, not the outcome it had then. * **Group changes count only real changes.** `groups.joins` and `groups.leaves` skip repeats that left membership as it was, and memberships Domino recorded without a date. * **Points carry their entry's effective date.** A reversal takes the date of the entry it reverses, so a revoked award cancels out in its original bucket. * **Member counts are distinct.** Active members and members earning points count each member once over the range, so a total can be less than the sum of its buckets. ## Qualified referrals, two ways [#qualified-referrals-two-ways] Referrals have two qualification numbers, dated differently: | Number | Counts | Answers | | ------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------- | | `qualified` | Active qualifications, by when each first qualified | How many friends qualified this week? | | `acceptedQualified` | Referrals accepted in the range whose friend has an active qualification now | Of the friends accepted this week, how many have qualified since? | A friend accepted before the range who qualifies inside it counts in `qualified` only. Divide `acceptedQualified` by `accepted` for the share of a range's friends who have qualified: each friend counts once, so it never exceeds 100%. A recent range's share can still grow as its friends qualify. Both count paused (suspended) referrals. ## The test environment [#the-test-environment] Test uses the same definitions, and counts test members like everyone else. Two things make its numbers move: * **Test member clocks.** A test member's clock can be [advanced](/docs/use/quests#try-quests-with-test-members) up to 90 days. Their attempts, completions and quest points are dated on that clock, and activity dated after now is left out until its date passes. * **Resets delete history.** Resetting a test member deletes their attempts, completions and points, so past buckets shrink. > [!DEV] > The read's `testEnvironment` reports when a test member was last reset and how many have an advanced clock. Live has neither. # Project calendar (/docs/concepts/calendar) Your project calendar is the timezone your community lives in. It decides when "today", "this week" and "this month" start. For example: * **A daily check-in quest** resets at midnight in Budapest, not in London. * **A weekly leaderboard** runs Monday to Sunday in New York time. * **A "3 per month" limit** starts fresh on the 1st, in your timezone. Limits (budgets), streaks and calendar leaderboards all count periods of this one calendar, so a weekly limit and a weekly leaderboard always cover the same week. Each environment has its own. Until you choose a timezone, the calendar is UTC. ```mermaid flowchart LR C["Project calendar
(one timezone)"] --> B["Quest budgets"] C --> E["Engagement budgets"] C --> S["Streak days"] C --> L["Calendar leaderboards"] ``` ## What follows the calendar [#what-follows-the-calendar] | Feature | Uses | Counted at | | -------------------------------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------- | | [Named budgets](/docs/concepts/quests#attempts-and-completion) | `day`, `week`, `month`, or `lifetime` per budget | When the activity happened (`eligibleAt`) | | [Engagement budgets](/docs/concepts/ugc#engagement-credits-growth) | `day`, `week`, `month`, or `lifetime` per budget | When the growth was measured; accounts settle once per calendar day | | [Streaks](/docs/concepts/quests#calendar-day-streaks) | Days | When the activity happened | | [Calendar leaderboards](/docs/concepts/leaderboards#scores-are-calculated-from-points) | The current `day`, `week`, or `month` | When points take effect | Budgets and leaderboards can count the same completion in different periods. A quest's points are awarded when its attempt is accepted, but its budget is charged in the period of the activity. A tracker activity from Monday that is accepted on Tuesday, after a deferral or review, spends Monday's daily budget and counts on Tuesday's daily leaderboard. Domino does not date a quest's points back to the activity, because that window may already be settled. * **Days start at local midnight.** On daylight-saving days a day lasts 23 or 25 hours. * **Weeks start on Monday.** They are ISO weeks. * **Months are calendar months.** * **Nothing names its own timezone.** A quest, budget, streak or board cannot override the project calendar. For a season on fixed dates, give a leaderboard fixed dates instead. * **Some things count plain time.** A tracker's window runs from one moment to another, and a referral bonus's duration counts days since the friend qualified. Neither follows the calendar. > [!DEV] > A fixed-dates leaderboard uses a `fixed` window. A tracker's `window` is a start and an optional end instant. ## Use the calendar in an evaluator [#use-the-calendar-in-an-evaluator] Call `ctx.calendar.day()` to read the project-calendar day containing `ctx.eligibleAt`. It returns a stable key plus the local date, timezone, and epoch-millisecond bounds: ```ts import { keyed } from "@domino-sdk/relay/authoring" const daily = keyed(({ calendar }) => calendar.day().key) // For example: 2026-10-01@Europe/Budapest ``` Pass an instant as `ctx.calendar.day(at)` to read another day. `start` is inclusive and `end` is exclusive. A day can last 23 or 25 hours around daylight saving, and the first day after a timezone change can be shorter. Use the returned bounds; do not calculate them by adding 24 hours. How the calendar is pinned for retries is in [evaluation time and randomness](/docs/reference/quest-rules#evaluation-time-and-randomness). ## Choose the timezone [#choose-the-timezone] In Console, open **Settings → General → Project calendar**, choose a timezone, give a reason, and save. Pick a city, such as `Europe/Budapest` or `America/New_York`, or `UTC`. Fixed offsets such as `+02:00`, `Etc/GMT-2` and `EST` are refused, because they ignore daylight saving: half the year, their midnight would be an hour off. Choose the timezone before launch. A change mid-campaign is safe, but the first period after it can be short; see [When the timezone changes](#when-the-timezone-changes). > [!DEV] > Timezones are named IANA zones. Other spellings resolve to the canonical name (`US/Eastern` is saved as `America/New_York`, and `Etc/UTC` or `GMT` as `UTC`), so saving another spelling of the current timezone changes nothing. | Where | How | | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | relay.json | `"calendar": { "timeZone": "Europe/Budapest" }`, applied by `domino deploy` | | Console | **Settings → General → Project calendar**: choose a timezone, give a reason, and save | | Management API | `management.calendar.configure({ timeZone, reason })`, or `domino calendar configure --time-zone Europe/Berlin --reason "…"` (`publish` permission) | > [!DEV] Read the calendar > Read the calendar with `management.calendar.read()` or `domino calendar get`. It returns the timezone in effect, today's date, when today ends (`nextBoundary`), any pending change, and the timezone relay.json last declared (`declared`). If the project kept a fixed-offset timezone from an older streak quest, `unsupported` names it: days follow UTC until you choose a timezone, and Console shows a warning. * **The latest decision wins.** A deployment changes the calendar only when relay.json declares a different timezone from the last deployment. A change made in Console stays until someone edits relay.json. * **A deployment makes the change its preview showed.** The preview's `calendar` shows the change and when it takes effect, or `effectiveAt: null` when the deployment keeps the current timezone and cancels a pending change. If a day boundary passes, or someone changes the calendar so that publishing would make a different change, publishing fails and asks you to preview again. * **Each environment has its own calendar.** Live refuses Console and management changes to it with `promotion-required`. Live gets test's calendar through a [promotion](/docs/concepts/promotion) that a person approves. See [test and live](/docs/concepts/environments). * **The audit history records every change**, with who made it and why: **Project timezone changed** for a scheduled change, and **Project timezone change cancelled** for a cancelled one. Saving what is already in effect records nothing. ## When the timezone changes [#when-the-timezone-changes] A change never rewrites history. It waits for the current day to end in the current timezone, and reads show it as pending until then. Saving another timezone replaces a pending change, and choosing the current timezone cancels it. | What | During the change | | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | | Today | Finishes in the old timezone. The next day starts in the new one. | | Streaks | Keep running. The first new day follows the last old one, even when its local date repeats or skips. | | An open week or month (budgets and leaderboards) | Finishes at its end in the old timezone. | | The next week or month | Runs from there to the next boundary in the new timezone, so it can be shorter than usual, never longer. | | Recorded days, budget use and points | Never change. | > [!NOTE] One short period per change > Because the first period after a change can be short, a budget can reset a few hours early once. A large westward change can also make the first day in the new timezone very short: from Pacific/Kiritimati to Pacific/Pago_Pago it lasts one hour. Streaks still need activity on that day to keep running. Choose the timezone before launch to avoid both. ## Where next [#where-next] - [Quests](/docs/concepts/quests) — Budgets and calendar-day streaks. - [Leaderboards](/docs/concepts/leaderboards) — Calendar windows. - [Build leaderboards](/docs/build/leaderboards) — Window options and examples. # Campaigns (/docs/concepts/campaigns) A campaign groups quests that run together, such as a launch month, a holiday event or a hackathon week. The quests share one set of rules: * **The same dates.** Every quest in the campaign runs within the campaign's dates, and a quest can run for a shorter stretch inside them. * **An optional sign-up.** You can ask members to join the campaign before any of its quests count. * **Shared limits.** Quests can draw from one shared budget, such as 1,000 completions across the whole campaign. In Console, open a quest under **Quests**: its **How it works** summary shows its campaign, whether the campaign requires enrollment, and the campaign and quest dates that apply. They come from code, so your developer changes them there. > [!DEV] > Define a campaign once in code, as below, and attach the same definition to each quest that belongs to it. Campaign keys and definitions must agree across every active quest that uses them. A quest can add its own `availability` window inside the campaign window. Both intervals are half-open: the start is included and the end is excluded. Publication refuses a quest window that does not overlap its campaign. ```ts import { defineCampaign } from "@domino-sdk/relay/authoring"; export const launch = defineCampaign({ key: "launch", title: "Launch campaign", window: { from: Date.parse("2026-10-01T00:00:00Z"), until: Date.parse("2026-11-01T00:00:00Z") }, enrollment: "required", }); ``` ## Enrollment [#enrollment] A campaign that requires enrollment counts a member's activity only after they join it, for example with a **Join the launch** button. Domino remembers when each member first joined, for every current and future quest in the campaign. Members can leave a campaign, and your team can take them out of it in Console, with a reason. Leaving doesn't ban anyone: joining again restores their place and keeps their first join time. | While a member is out | What happens | | ----------------------- | ---------------------------------------------------------------------------------------------------------- | | New activity | Not counted: no new quest tries start, whether submitted, automatic or from a tracker. | | Tries already under way | Continue: they can still be retried, reviewed, completed and rewarded. | | Completions and rewards | Stay. | | Rejoining | Lets new activity count again, and looks again at activity that was skipped because they weren't enrolled. | Console shows each member's first join time, and when and why they left, and lets someone with permission take them out or restore them. You can't join a campaign that doesn't require enrollment, or one that has ended. A member you [erased](/docs/use/member-erasure) can't be enrolled again; their enrollment history stays. > [!DEV] > Set `enrollment: "required"` on the campaign. The member calls `relay.campaigns.enroll({ actionId, campaign })` and `relay.campaigns.withdraw({ actionId, campaign })`. A trusted server or Console operator calls `management.campaigns.enroll({ actionId, campaign, member })` and `management.campaigns.withdraw({ actionId, campaign, member, reason })` with `enroll` permission. Repeating enrollment returns the first time; enrolling again after withdrawal restores the enrollment without changing `enrolledAt`. Attempts admitted before withdrawal keep their pinned release. Restoration decides prior `not-enrolled` skips again. A quest's `evaluate` reads the member's enrollment with `ctx.member.enrolled(campaign)`: `true` while it is active, and `false` after withdrawal or without an enrollment. See [evaluator readers](/docs/reference/verification#evaluator-readers-null-errors-and-pinning). ## Quest eligibility [#quest-eligibility] Your app can tell each member what they can do with a quest right now: start it, finish a prerequisite first, wait for it to open, or wait for a review. > [!DEV] > `QuestView.availability` says what can happen now: > > | Shape | Meaning | > | --- | --- | > | `{ kind: "available", input }` | The member can submit. | > | `{ kind: "locked", missing }` | One or more typed prerequisites are missing. | > | `{ kind: "blocked", reason, resetsAt? }` | A window, enrollment, pause, connection, wallet, or budget rule blocks participation. | > | `checking`, `needs-review`, `retryable` | An admitted attempt owns the next action. | > | `automatic`, `staff-required`, `completed` | Relay, staff, or the completed state owns the next action. | > > The view also includes the effective `window`, campaign definition, `enrolledAt`, `withdrawnAt`, and `withdrawalReason`, and each authored budget's current `remaining` capacity and `resetsAt`. `questStatus(view)` returns stable codes for localization; `questStatusText(view)` supplies optional English labels. What a member can do changes over time without rewriting their history. Point and group prerequisites read the member's current balance and groups. "Once a day" prerequisites and budget resets follow the [project calendar](/docs/concepts/calendar). A try already under way keeps its rules; a later change to the member's balance, groups, the dates or a budget doesn't rewrite it. ## Campaign-wide budgets and rankings [#campaign-wide-budgets-and-rankings] Quests share a campaign budget when each uses the same budget, with the same scope, period and limit. Declare the budget once and use it in each quest: Domino refuses to publish two quests that declare one budget differently. It enforces the limit at the moment each completion is recorded, so it can't be overshot. Day, week, and month budgets follow the project calendar. A leaderboard can count only the campaign's quests, and can cover the current campaign day, week, or month. Being in a campaign doesn't award points or prizes by itself. > [!DEV] > Use the same budget ID, scope, period, limit and cost on each quest; declare it once and import it. Deployment refuses one budget ID declared differently, and `domino check` and `domino build` report it already when the values are literals. The budget is enforced atomically at completion. Filter a leaderboard to the campaign's quests with an earned-score `quests` filter, and use a calendar leaderboard window for standings per day, week, or month. ## Where next [#where-next] - [Quests](/docs/concepts/quests) — Prerequisites and how often a quest can be completed. - [Compose a campaign](/docs/composition) — Connect campaign quests, points, referrals, and standings. - [Participant API](/docs/reference/participant-api) — Render availability and enroll a signed-in member. # Collections (/docs/concepts/collections) A collection is a titled list of quests your app shows together, such as "Getting started", "Booth challenges" or "This week's quests". You choose the order, and can add a description and an image. The same quest can sit in several collections. A member's progress follows the quest, not the list, so finishing it in one place shows it finished everywhere. Duplicating a quest makes a new quest with fresh progress. ```mermaid flowchart LR A["Collection A"] --> Q["Quest"] B["Collection B"] --> Q Q --> P["Shared member progress"] Q -.->|duplicate| D["New quest"] D --> F["Fresh progress"] ``` ## Membership and identity [#membership-and-identity] | Who | Controls | | ------------ | ----------------------------------------------------------------- | | Project code | Declares collections. | | Console | Lets you edit their details, arrange quests, and publish changes. | | Your app | Decides how to show them. | ## Author collection presentation [#author-collection-presentation] In Console, **Edit details** in a collection's **⋯** menu changes the title, description, image and alt text, in the same draft as your changes to its quests and their order. Publishing shows them to members. An image can be an HTTPS address, or a path beginning with `/` that your app serves. Your developer can also set these in code. A later deployment replaces the details they set there; details they left out keep your values. > [!DEV] > Declare optional presentation in `relay.json`, as below. A later deployment replaces fields explicitly authored there; omitted optional fields retain the current values. > > `GET /v1/me/experience` and `relay.quests.experience()` return collection presentation next to `title`. The collection's `quests` array follows the published Console placement order and leaves out archived quests. React's `useExperience().data.collections` and `questGroups()` retain the same fields and order. ```json { "collections": [ { "key": "getting-started", "title": "Getting started", "description": "A few activities to get familiar with the community.", "image": "/images/getting-started.png", "imageAlt": "The workshop entrance" } ] } ``` ## Arrangement and eligibility [#arrangement-and-eligibility] A collection only arranges quests; it doesn't decide who can take part: | To… | Use | | ------------------------------------------------------ | ------------------------------------------------------------------------------ | | Show quests together in order | A collection. Its order does not enforce a sequence. | | Require one quest to be finished before another starts | A [prerequisite](/docs/concepts/quests#who-can-take-part) on the second quest. | | Stop new tries | Pause the quest. Removing it from a collection does not pause it. | A quest without a collection still exists; whether members see it depends on your app. Quests that run on their own don't need a claim button. ## Where next [#where-next] - [Arrange collections](/docs/use/quests#arrange-collections) — Arrange and edit collections in Console. - [Compose a campaign](/docs/composition) — Dependencies between quests. # Marketing consent (/docs/concepts/consent) > [!NOTE] Experimental > Marketing consent is experimental. Its API can still change in a minor release. Marketing consent is a member's yes or no to hearing from you on one channel, such as email. Your app asks: it shows the checkbox and its wording. Domino keeps the record. | Domino records | Example | | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | The channel | Email, the only channel Domino records consent for so far. | | The member's choice | Opted in (granted), or opted out (withdrawn). | | When | The time Domino received it, by Domino's clock, never the member's device. For imported consent, the time the old platform recorded. | | The wording | The exact text the member saw beside the checkbox, word for word. | | Where it came from | The member, choosing in your app, or a [member import](/docs/use/imports), for consent the member gave on the platform they came from. | ## Every change is kept [#every-change-is-kept] Each time a member opts in or out, Domino adds a new record and keeps the old ones. The member's current choice on a channel is their latest record. Opting out takes one step, the same as opting in. A member who was never asked has no record. That is different from opting out: show them the checkbox. Sending the same choice with the same wording again changes nothing and records nothing. ## See a member's consent [#see-a-members-consent] In Console, open a member. **Marketing consent** shows their current choice on each channel, the words they saw and when they chose, then every earlier change, newest first. A member who was never asked shows **No consent recorded**. Your team can only look: nobody but the member can opt them in or out, in Console or through the API. A member import can carry over the consent a member gave on the platform they came from; Console marks it **Imported**, and it never replaces a choice the member made in Domino. > [!DEV] > With `read` permission, `management.members.consent(member)` lists the member's current entry per channel, and `management.members.consentHistory(member, { cursor })` pages through every change, newest first, as `{ items, nextCursor }`. From a terminal, `domino members consent MEMBER` prints both; add `--json` for scripts. Both reads are experimental. An unknown member is a 404 `not-found`. ## Who it reaches [#who-it-reaches] Integrations that send member data to your marketing tools read this record: by default they include only members who opted in, and they subscribe a member in the tool when the member opts in and unsubscribe them when they opt out. [Klaviyo](/docs/build/klaviyo) is the first. Consent belongs to one [environment](/docs/concepts/environments), like the member it belongs to: a test member's consent never reaches a live tool. Resetting a test member deletes their consent records with the rest of the member. > [!DEV] > The signed-in member reads their consent with `relay.consent.get()` and changes it with `relay.consent.set({ channel, granted, wording })`, or with the `useConsent()` React hook. See [record consent from a checkbox](/docs/build/consent). # Test and live (/docs/concepts/environments) Every project has two environments, **test** and **live**. Test is your rehearsal space: build a quiz, try it with pretend members, and change your mind as often as you like. Live is what your real members use. Each environment keeps its own members, progress, points, rewards, settings and history, so nothing you try in test touches a real member. In live, Console marks every project page with a mint strip along the top of the header and a **Live** badge beside the page title, and confirmations for actions that affect members name the environment they apply to. ## What you can change in live [#what-you-can-change-in-live] Changes come in two kinds: * **What your program is**, such as a quest, its reward, a badge, or a leaderboard. You change these in test, and a person on your team moves them to live with a [promotion](/docs/concepts/promotion). * **Running the program day to day**, such as pausing a quest, reviewing submissions, correcting a member's points, or managing a group. You do these directly in live. | Kind | Examples | In live | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Definitions | Quests, their settings values and archive state; Quest Studio drafts; point ledgers; badges; the project calendar; profile settings; pickup policy; stock items; a leaderboard's exclusion-group binding; archiving or restoring any resource | Refused for every credential, Console included. Make the change in test, then [promote](/docs/concepts/promotion) it. | | Operations | Pausing and resuming quests, redemptions, offers and trackers; point changes; attempt review; settlement approval; member and profile moderation; reward corrections; stock corrections; groups and their members; badge awards; referral controls | Accepted under the caller's permissions. | | Promotions | `domino promote`, `management.promotions.prepare` | Prepared from test with every definition. A person approves each one in Console. See [promotion](/docs/concepts/promotion). | | Code deployments | `domino deploy --environment live`, `management.deployments.preview` | Refused for every credential, Console included. Deploy to test, then promote. See [Publish behavior to live](/docs/build/publishing#publish-behavior-to-live). | | Environment settings | Integrations, webhooks, member sign-in, hosted-app secrets | Set in each environment on its own. A promotion never copies them; it warns when live lacks one test uses. | | Plugin connections | **Integrations**, `domino connections`, `management.connections` | Connected in each environment on its own, with its own secrets or access granted at the service. A promotion never copies one and doesn't warn when live lacks one, so connect live yourself. | Looking things up and previewing changes work in live too, because they change nothing. * **Definitions reach live through a promotion.** A [promotion](/docs/concepts/promotion) carries every definition in test, from code and from Console alike, to live when a person approves it. Resources match by authoring key. * **Code reaches live through a promotion too.** Live refuses code sent to it directly, so your developer always publishes to test first. * **App login keys belong to one environment.** A hosted app's staging and preview addresses sign members in to test and its live address to live, so a [key for one of those addresses](/docs/build/app-login#pick-the-environment-for-the-address) is created only in the environment that address serves. A key for your own app's local address, such as `http://localhost:3000`, is a Test key only: [develop on localhost](/docs/build/app-login#develop-against-domino-on-localhost) against Test, and give the live app an HTTPS address. A promotion never copies keys. > [!DEV] > A personal API key works only in the environment it was issued for. Reads and previews work in live, except a code deployment preview, which starts a direct live deployment that live refuses. > > In live, a `domino login` CLI session makes only a few changes, such as promoting from test and setting app secrets, and an MCP connector or automation key makes none; their other live writes get 403 `console-approval-required`. [What each credential may do in live](/docs/concepts/access#what-each-credential-may-do-in-live) lists them. For other live operations, create a live API key in Console (**Settings → API keys**). ## When live refuses a change [#when-live-refuses-a-change] In live, Console shows editors for definitions as read-only, with the note "Live changes only through a promotion from test." Choose **Edit in Test** to open the same page in test, and **Promote to live** when you're ready. Day-to-day controls stay available. Trying the same change again in live never works. Make it in test, then [promote](/docs/concepts/promotion) test to live. > [!DEV] Errors > The permission check comes first: a caller without the permission gets 403 `permission-required`. A caller with it gets HTTP 422, shown below. A code deployment sent to live gets the same code, with the message "Code changes in test and reaches live through a promotion. Deploy to test, then run `domino promote`." > > | Where | What you see | > | --- | --- | > | Console | **Promote to live** prepares a promotion for people with publish permission. | > | CLI | Error code `ENVIRONMENT_UNSUPPORTED` (exit 1) with `apiCode: "promotion-required"` and `status: 422`. `domino quests archive` and `unarchive`, and the deprecated `domino resources archive` and `unarchive`, stop before sending and ask for `--environment test`. | > | Management client | `ManagementError` with `status: 422` and `code: "promotion-required"`. | ```json { "error": "Definitions change in test and reach live through a promotion. Make this change in the test environment.", "code": "promotion-required" } ``` # Groups (/docs/concepts/groups) A group is a named list of members, such as your VIPs, your ambassadors, or the creators you've approved. You keep one list, and it can control three things at once: * **Which quests a member can take.** For example, a VIP-only quest. * **Who ranks on a leaderboard.** For example, a race among ambassadors only. * **Who earns referral rewards.** For example, only approved creators earn for inviting friends. Your team adds and removes members in Console, or a quest adds the members who complete it, such as an application your team approves. ```mermaid flowchart LR O["Operators: Console, CLI, management API"] --> G["Group"] Q["Quest reward: groupMembership"] --> G G --> P["inGroup prerequisite"] G --> B["Leaderboard group"] G --> R["Referral inviterGroup"] ``` ## Keys and handles [#keys-and-handles] Each group has a key, such as `vip`, and a title your team sees. The key never changes; the title can. In Console, **New group** fills in the key from the title until you type your own. > [!DEV] > Code names a group with a typed handle, as below. A leaderboard's `group` and a referral program's `inviterGroup` take the same key. Publishing code never adds members; it creates a group only when the environment has none with that key (see [Groups your code names](#groups-your-code-names)). > > Keys starting with `domino--exclusions--` are reserved for the groups Relay creates as a leaderboard's [exclusion group](/docs/concepts/leaderboards#exclusions). Creating one fails with 409 and code `already-exists`. ```ts import { defineGroup, inGroup } from "@domino-sdk/relay/authoring"; export const vips = defineGroup({ key: "vip", title: "VIP" }); // In a quest definition: const requires = [inGroup(vips)]; ``` ## Groups your code names [#groups-your-code-names] When your code names a group the environment doesn't have yet, publishing creates it for you, with no members: * **Deploying to test** creates it in test. * **Approving a promotion** creates it in live. The promotion lists the groups approval will create, and members are never copied from test. * **The title comes from your code**, from `defineGroup({ key, title })`. A group only a leaderboard or referral program names takes its key as its title. Rename it in Console **Groups** like any other. * **An existing group stays as it is.** Publishing never renames a group or changes its members. * **An archived group is never unarchived.** A quest reward that adds members to an archived group fails to publish: unarchive the group in Console **Groups** first. **Audit history** records each created group, naming the deployment or promotion that created it. > [!DEV] > A quest's `groupMembership(group)` reward or `inGroup(group)` prerequisite, an offer's `inGroup(group)` requirement, a leaderboard's `group`, and a referral program's `inviterGroup` all count; a promotion also creates a leaderboard's exclusion group live lacks. The deployment preview and result list each group with the warning code `group-created`; a prepared promotion lists it as a `group-created` warning with `kind: "group"` and the group's `key`. A key starting with `domino--exclusions--` is never created. ## Create a group and add members [#create-a-group-and-add-members] Create the group and manage its members in Console **Groups**. Select a group to open it. Your developer can do the same from the CLI or their server. | Action | Console | CLI | Management API | | ------------------ | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | | Create | **New group** | `domino groups create vip --title VIP` | `management.groups.create({ key, title })` | | Rename | Change **Title**, then **Save title** | `domino groups update vip --title …` | `management.groups.update(group, { title })` | | Add a member | Find them in **Add a member**, give a reason, then **Add to group** | `domino groups add vip --reason …` | `management.groups.addMember(group, { member, reason })` | | Remove a member | **Remove** beside them, give a reason, then **Remove from group** | `domino groups remove vip --reason …` | `management.groups.removeMember(group, member, { reason })` | | Archive, unarchive | **Archive group** or **Unarchive group**, give a reason, then confirm | `domino groups archive vip --reason …`, `domino groups unarchive vip --reason …` | `management.groups.archive(group, { reason })`, `unarchive(group, { reason })` | | Read | Select the group | `domino groups get vip`, `domino groups members vip`, `domino groups member vip ` | `management.groups.get(group)`, `members(group)`, `member(group, member)` | On a leaderboard with a group, a member's **Eligibility** section in Console **Leaderboards** also has **Add to group** and **Remove from group**. * **Groups are day-to-day operations.** You can change groups directly in live. A promotion never copies groups or members; it only creates, empty, the groups your code names and live lacks. * **Only members of the project can join.** You can't add someone who hasn't joined the project. * **Every change has a reason.** Adding or removing a member records the reason and who made the change in **Audit history**. Adding someone who already belongs, or removing someone who does not, records nothing. Console also asks for a reason to archive or unarchive a group; from the CLI or your server that reason is optional. * **Keys are unique.** Creating a group with a key that already exists fails. > [!DEV] > Reads need `read`; changes need `publish`. A personal API key with `publish` can change groups in live; a CLI sign-in cannot. Adding someone who has not joined the project fails with 404 and code `not-joined`; creating a key that exists fails with 409 and code `already-exists`. Every refusal carries a stable code; see [API error codes](/docs/reference/errors). `management.groups.list()` and `members(group)` return `{ items, nextCursor }`: pass `nextCursor` back as `cursor` until it is `null`. ## What reads a group [#what-reads-a-group] A group always reflects who is in it now: take someone out, and they lose what the group gave them from then on. | Feature | Rule | | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Quest prerequisite](/docs/concepts/quests#who-can-take-part) `inGroup(group)` | The member must belong now. Removing them makes the prerequisite missing again. | | [Leaderboard](/docs/concepts/leaderboards) `group` | Only current members are ranked. A [prize settlement](/docs/concepts/leaderboards#prize-settlements) ranks the members the group had at its cutoff. | | [Leaderboard exclusion group](/docs/concepts/leaderboards#exclusions) | Current members are left out of the boards bound to it. A prize settlement leaves out the members it had at the cutoff. | | [Referral program](/docs/concepts/referrals) `inviterGroup` | The program's rewards for a qualifying friend, its milestones, and its inviter bonuses apply only while the inviter belongs. Rewards missed while outside the group are not paid later. | | [Quest evaluator](/docs/reference/verification#evaluator-readers-null-errors-and-pinning) `ctx.member.groups()` | Returns the keys of the unarchived groups the member belongs to, fixed at the evaluation run's first call. | Adding a member also checks [quests that unlock on their own](/docs/concepts/quests#outcomes-for-prerequisite-only-automatic-quests) the member can now enter. ## Add members with a quest reward [#add-members-with-a-quest-reward] A quest can add the member who completes it to a group. For a curated program, such as approved creators, make an application quest your team reviews: approving an application adds the applicant, and quests for that group open to them. > [!DEV] > Put `groupMembership(group)` in the quest's rewards, next to `points(...)` and `badge(...)`, and turn on human review. Quests gated on `inGroup(group)` then open to the member. ```ts import { defineGroup, groupMembership } from "@domino-sdk/relay/authoring"; export const creators = defineGroup({ key: "approved-creators", title: "Approved creators" }); // In the application quest's definition: const rewards = [groupMembership(creators)]; ``` * **Publishing creates the group if it's missing.** Deploying the quest creates it in test, and approving its promotion creates it in live, both empty. An archived group fails publication: unarchive it first. * **It works like adding them yourself.** **Audit history** records the change with the quest and completion as its reason, and quests that unlock on their own are checked. Someone already in the group stays as they were. * **Revoking the completion removes what it added.** The member leaves the group unless your team added them too, or another completion's reward did. An archived group keeps them. * **Your team keeps control.** Removing a member works whatever added them. > [!DEV] > An archived group fails a deployment with 422 and code `validation-failed`, and promotion approval with 409 and code `conflict`. The revocation result's `groups` says whether the member is still in each group. See [group membership rewards](/docs/concepts/rewards#group-membership) for how the reward appears to members. ## Archive instead of delete [#archive-instead-of-delete] Groups are never deleted. Archive a group you no longer manage: * **Members keep counting.** Prerequisites, leaderboards, and referral rules still read an archived group's members. * **Membership is frozen.** You can't add or remove members until you unarchive the group. * **History stays.** Leaderboard settlements read past membership, so it is kept. * **Exclusion groups stay open.** A group a leaderboard uses as its exclusion group can't be archived. Bind those boards to another exclusion group first. * **Rewarded groups stay open.** A group a quest adds members to can't be archived while that quest isn't archived. Remove the reward or archive the quest first. > [!DEV] > A change to an archived group's members fails with 409 and code `group-archived`. Archiving a group in use fails with 409 and code `group-in-use`: the response's `leaderboards` names the boards that use it as their exclusion group, and its `quests` lists the IDs of unarchived quests that reward it with `groupMembership`. To stop a group from granting eligibility, remove its members, or remove the group from the quest, leaderboard, or referral program that names it. > [!NOTE] Earlier leaderboard groups > `management.leaderboards.group(group, { member, included, reason })` still works but is deprecated. It creates the group, titled with its key, on its first member. Use `management.groups` instead; create the group first. ## Where next [#where-next] - [Quests](/docs/concepts/quests) — Prerequisites, including group membership. - [Publish a leaderboard](/docs/build/leaderboards) — Restrict a board to a group. - [Build referrals](/docs/build/referrals) — Restrict inviter rewards with `inviterGroup`. # Hosted apps (/docs/concepts/hosting) Domino can host the app your members use, such as your quest page or rewards hub, so you can launch without servers of your own. While you build, your team shares a private **staging** app that runs on test data. When you're ready, a [promotion](/docs/concepts/promotion) puts the same app **live** at `.domino.page`, or at your own address such as `quests.example.com`. Hosting is optional: if you already have a website or app, it can keep its own code, login and hosting and add Domino to it. See [connect your app](/docs/start/existing-app). ```mermaid flowchart LR Repo["Hosted repository"] --> Dev["domino dev or Playground preview"] Repo --> Stage["domino stage"] Stage --> Build["Isolated build"] Build --> Staging["Staging app on test data"] Staging --> Promote["Promotion approved in Console"] Promote --> Live["Live app on live data"] ``` ## What Domino hosts [#what-domino-hosts] Your app goes through a few stages, each at its own address: | Piece | Where it runs | Address | Stays online | | ----------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------- | | **Repository** | A private Git repository behind Domino's Git gateway | `https://git.domino.run/git/.git` | Always | | **Preview** | Your device with `domino dev`, or Console's [Playground](/docs/build/playground) sandbox | `https://--.domino.page` | Only while development runs | | **Staging** | Domino's builder and Cloudflare Workers | `https://--staging.domino.page` | Until the next successful build replaces it | | **Live** | Domino's builder and Cloudflare Workers | `https://.domino.page` | Until an approved promotion carries another build | | **Custom domain** | The live app, at a domain you control | `https://quests.example.com` | While its CNAME stays in place | * **One repository per project.** Creating a project from a [starter](/docs/start/new-app) gives it a working app to start from. * **Previews are for working.** A preview shows the app while your developer or Playground works on it, and goes offline when they stop. See [Develop and share a preview](/docs/build/development). * **Staging is for sharing.** Each time your developer stages a version, the staging address serves it, for your team to try. See [Stage and share](/docs/build/staging). * **Live is for your members.** Approving a [promotion](/docs/concepts/promotion) that carries a version puts it live. See [How test and live hosting relate](#how-test-and-live-hosting-relate). * **A custom domain serves live under your name.** See [Custom domains](#custom-domains). > [!DEV] > `domino checkout` clones the repository and installs a credential helper, so Git uses your Domino sign-in. Fetching and pushing need `publish` in the project's test environment. Each `domino stage` builds an exact pushed commit; when it succeeds, the staging address serves it. ### Changing the address [#changing-the-address] The project name is only a label; renaming it doesn't change any address. The addresses come from the project's **slug**, which organization admins can change from Console's danger zone. See [project settings](/docs/use/projects#rename-a-project-or-change-its-slug). * **Staging moves without a rebuild.** Domino registers the new address before switching. If that fails, the old address keeps working and you can retry. * **Live moves within about a minute.** The old live address stops serving, with no redirect, so links containing the old slug stop working and members sign in again at the new address. * **Your custom domain is unaffected.** A [custom domain](#custom-domains) belongs to the project, not the slug. * **Old slugs stay yours.** Previous and prepared slugs stay reserved to the project until it is purged. Secrets and hosted storage keep their permanent internal identity. ## Static sites and Worker apps [#static-sites-and-worker-apps] An app is either a plain website, or one with its own server code that can, for example, award points when something happens on your site. > [!DEV] > The `app` section of `relay.json` says which kind of app the project is. Both kinds use the same repository, previews, staging, and live app. > > | Kind | What Domino serves | Server code | > | --- | --- | --- | > | `static` | Built files, with a single-page-app fallback to `index.html` | None | > | `worker` | A compiled Cloudflare Worker, with optional public files beside it | Your Worker handles requests | > > Every starter is a Worker app. [Configure a hosted app](/docs/build/hosted-app) lists every setting. ## What a Worker app receives [#what-a-worker-app-receives] An app with server code gets what it needs to talk to Domino and keep its own data, with separate keys and storage for test and live, and no passwords stored in the code. Staged and live Worker apps get these through their environment. Each environment supplies its own values: | Feature | What it gives your server code | Guide | | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | Member sign-in | A `/relay/*` proxy to Domino's participant API, mounted by your app | [Add Domino participant sign-in](/docs/build/hosted-app#add-domino-participant-sign-in) | | Management key | `DOMINO_*` bindings for awarding points and reading project data, scoped to the environment the app serves | [Call the management API](/docs/build/hosted-app#call-the-management-api-from-your-server) | | App secrets | Credentials such as API keys, set per environment in Console or the CLI, never in the repository. Console can save a new app login key here when it creates one | [Set app secrets](/docs/build/hosted-app#set-app-secrets) | | Schedules | Signed calls to routes on the serving build, in test for staging and in live for the live app | [Run scheduled jobs](/docs/build/schedules) | | Storage | Cloudflare KV namespaces and D1 SQL databases, separate for test and live | [Store app data](/docs/build/storage) | | Variables | Non-secret strings committed in `relay.json` | [Configure Worker variables](/docs/build/hosted-app#configure-worker-variables) | ## Scheduled jobs [#scheduled-jobs] An app can run jobs on a schedule, such as a nightly sync of your shop's orders. > [!DEV] > A Worker app declares schedules in `app.schedules`. Relay invokes their routes on the serving build in each environment, using a signing key the app verifies; the live app's key differs from staging's. An interval or UTC cron expression controls when each job becomes due. Jobs have bounded execution and one attempt per tick; an uncertain result blocks future calls until an operator confirms recovery. A job can answer with a result (an outcome, a summary, counts and diagnostics) that Relay records with the run, and each schedule keeps its latest 20 runs. Pausing or archiving an environment's schedules stops new calls there and survives build replacement. In-flight dispatch completes before activation switches builds. Deletion stops all schedules. See [Run scheduled jobs](/docs/build/schedules). ## How test and live hosting relate [#how-test-and-live-hosting-relate] Previews and staging run in the project's **test** environment, on test data with real member sign-in. The live app runs in **live**, on live data. A version reaches live only through a promotion that a person approves in Console. 1. **Stage a build.** Your developer puts a version on staging, with test's key, secrets, stores, and schedules. 2. **Promote.** A promotion carries the version serving staging. Its preview warns about app secrets set in test and missing in live. 3. **Approve.** Once approval commits, Domino deploys the same version again with live's own key, secrets, stores, and schedule signing key. Live stores that do not exist yet are created, and SQL migrations run against live's databases before the switch. Then the live app serves the new version, typically a couple of minutes after approval. Until then, the version live already serves keeps serving. > [!DEV] > `domino stage` stages a build; `domino promote` prepares the promotion. * **See where it stands.** The **Live app** card in **Deployments**, shown in both environments, gives the address, `.domino.page` (it redirects to the [custom domain](#custom-domains) while one is active), and its state: **Nothing live yet**, **Deploying**, **Live**, or **Deployment failed** with the reason. Choose **Open live app** to open it. * **Public.** The live app has no staging sign-in, no staging banner, and search engines may index it. * **Member sign-in selects live.** Sign-in from the live app's address uses the project's live members and live sign-in settings. Staging and previews stay on test. * **Your app can ask which one it is.** Every hosted address tells the app whether it serves test or live; see [Tell test from live](/docs/build/hosted-app#tell-test-from-live). * **Nothing live yet.** Before the first approved promotion that carries a build, and for a deleted project, `.domino.page` answers 404 with a page titled **Nothing is live here yet**. The page does not say whether a project exists. * **Offline without a plan.** Where plans apply, a project whose live trial and its 7-day grace ended without a plan goes offline: `.domino.page` and its custom domain answer 503 with a page titled **This app is offline**, and participant requests to live are refused with `live-offline`. A plan brings the same build back within about a minute. See [Going live needs a plan](/docs/concepts/promotion#going-live-needs-a-plan). * **Failed deployments keep the previous build.** If the live deployment fails, or does not finish within an hour, the previous live build keeps serving. The next promotion shows the app build as changed again; approve it to retry. * **Rollback.** A rollback promotion carries the earlier promotion's build. Approving it deploys that build to live the same way. > [!DEV] > `management.liveApp.get()` answers the same as the **Live app** card, with `read` permission in either environment. The live app has no `noindex`. It has the same routing as staging: a `transparent` app receives requests unchanged; a `legacy` app sends `/relay/*` to the participant API and accepts only `GET` and `HEAD` for everything else, with `Cookie`, `Authorization`, and response `Set-Cookie` removed. On every `domino.page` address Domino answers `/.well-known/security.txt` itself, with its abuse contact; a custom domain passes that path to the app. > [!NOTE] Serving an earlier build > To serve an earlier build in live, approve a rollback promotion. Your app and its quests travel together through a promotion, which carries both test's definitions and the version serving staging. Staging a version also publishes that commit's quests and catalog to test. Live refuses code sent to it directly (`domino deploy --environment live`), so a promotion is the only way either reaches live. See [Publish behavior](/docs/build/publishing#publish-behavior-to-live). ## Custom domains [#custom-domains] A custom domain, such as `quests.example.com`, puts your live app at an address you own. You add it in Console, then whoever manages your domain adds two DNS records. It is a setting of the live environment: it serves only the live app, a promotion never copies or changes it, and staging keeps `--staging.domino.page`. Set it up in Console **Settings → Domains**; [Use your own domain](/docs/build/hosted-app#use-your-own-domain) has the steps. > [!DEV] > Add a domain with `domino domains add` or `management.domains.add()`. ```mermaid flowchart LR Add["Add the domain"] --> Waiting["Waiting for DNS"] Waiting -->|"TXT record found"| Setup["Setting up HTTPS"] Waiting -->|"Not found in 7 days"| Attention["Needs attention"] Setup -->|"Certificate issued"| Active["Active"] Setup -->|"Not active in 7 days"| Attention Active -->|"CNAME moved or certificate failed"| Attention Attention -->|"Fix, then re-check"| Setup ``` | State | What it means | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | **Waiting for DNS** | Domino has not found the TXT record yet. | | **Setting up HTTPS** | Domino found the TXT record, and Cloudflare is validating the domain and issuing its certificate. HTTPS to the domain fails until then. | | **Active** | The domain serves the live app. | | **Needs attention** | You or Domino must act. The domain shows what to fix. | | **Suspended** | Domino suspended the domain. Contact Domino support. | * **Two DNS records.** A `CNAME` from your hostname to `cname.domino.page`, which sends its traffic to Domino, and a `TXT` record at `_domino.` with a value unique to this attachment, which proves you control the domain's DNS today. Domino does nothing at Cloudflare until it finds the TXT record, so typing someone else's domain takes nothing from them. * **What Domino saw, not a summary.** The domain shows each record next to what public DNS returned, Cloudflare's hostname and certificate statuses and its validation errors word for word, when Domino last checked and checks next, and when the certificate expires. Domino checks often while a domain is set up and daily once it is active. * **Subdomains only.** Root domains such as `example.com` cannot be attached. Attach `www.example.com` or another subdomain, and redirect the root to it at your DNS provider. Wildcards, IP addresses and Domino's own domains are refused too. * **Growth and up.** Where plans apply, adding a custom domain needs the project on Growth, Scale or Enterprise; on Free or Launch it fails with `plan-required`. A project that moves to a smaller plan through Domino billing keeps its domains until the period it paid for ends; when the smaller plan starts, its domains are detached and the app is served on its `domino.page` address again. A plan Domino support set keeps the domains. Organizations whose domain limits Domino support raised can add domains on any plan. When the project's plan doesn't include a domain, Console's **Domains** settings say so and link to **Settings → Plan & usage**. * **One per project.** By default a project has one custom domain, and an organization at most 10. Contact Domino support to raise either limit. * **The domino.page address redirects.** While the domain is active, page loads (`GET` and `HEAD`) on `.domino.page` answer with a temporary redirect to it, keeping the path and query. Members signed in at the old address sign in again at the new one. Other requests keep working at both addresses. * **Before the first live build,** the domain answers as `.domino.page` does: **Nothing is live here yet**. * **Moving a site.** HTTPS fails between pointing the CNAME at `cname.domino.page` and the certificate's issue, usually a few minutes. If the domain has CAA records, they must allow `letsencrypt.org` and `pki.goog`. * **Moving the domain to another project.** Add it to the other project and publish that attachment's TXT record. Once Domino finds it, the domain stops serving the first project, then serves the new one within a couple of minutes; the first project's domain shows **Needs attention**. * **Removing it** stops serving within about a minute. Attaching it again, to any project, needs a new TXT record. Subdomains of one domain, such as `a.example.com` and `b.example.com` in two organizations, can set cookies for each other; that is the domain owner's to manage. ## Who can open it [#who-can-open-it] Staging is never public, and neither is its participant API: calls that name the staging address answer only for browsers signed in to staging, through the staging address, and anything else is refused with `staging-access-required`. By default, visitors sign in with Domino Console and need access to the project in test. A person with `publish` can switch [staging access](/docs/build/staging#control-who-can-open-staging) to a password for reviewers outside Console. A preview is reachable by anyone with its address while it runs; member sign-in still controls what members can do. The live app is public. ## Traffic from one address [#traffic-from-one-address] A conference venue, school or office often puts every visitor behind one shared internet address. Domino sets no limit on how many requests a hosted app takes from one address, on staging, live or a custom domain, so a crowd on one Wi-Fi network is served like a crowd on many. Sign-in is separate: it limits attempts per address to slow down abuse, so a crowd signing in at the same moment on one network can reach those limits. Member sign-in has its own limits (see [Add Domino participant sign-in](/docs/build/hosted-app#add-domino-participant-sign-in)), and a [staging password](/docs/build/staging#control-who-can-open-staging) takes 10 attempts per address in 5 minutes. > [!DEV] > Hosted addresses run behind Cloudflare, whose automatic protection against attack-scale traffic still applies. Staging also checks each request's access with Domino; when that check takes longer than 10 seconds, staging answers `503` with `Retry-After` instead of keeping the request waiting. Live serves from its own copy of the address map and has no such check. ## When a project is deleted or moved [#when-a-project-is-deleted-or-moved] | Action | Effect on hosting | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Delete the project | The staging address stops serving, and the live address and the custom domain stop serving within about a minute. Domino support can restore the project within 30 days; restoring it brings both back. | | Purge after 30 days | Domino deletes the builds, the live app and its address, the custom domain, the staging hostname, the repository, app storage, and Playground data. | | Transfer to another organization | The slug, repository, staging and live addresses, custom domain, secrets, and stores move with the project. The new organization's admins manage the domain, and its DNS records keep working. | See [Delete or move a project](/docs/use/projects#delete-or-move-a-project). ## Where next [#where-next] - [Create a new app](/docs/start/new-app) — start from a starter and get a hosted app and staging address. - [Build in Playground](/docs/build/playground) — work with an agent in Console. - [Configure a hosted app](/docs/build/hosted-app) — every `relay.json` app setting and server feature. - [Stage and share](/docs/build/staging) — build a pushed commit and share staging. - [Promotion](/docs/concepts/promotion) — how a build and test's definitions reach live. # Leaderboards (/docs/concepts/leaderboards) A leaderboard ranks your members by the points they hold or earn. Use one to make taking part feel like a game. For example: * **A weekly race** where the top three get a prize every Monday. * **An all-time "top contributors"** list on your community page. * **A referral race** that ranks members on a ledger only your referral program pays. A board reads one of your [point ledgers](/docs/concepts/points). You choose how it scores members, which time window it covers, and, if you want, which group of members or which quests count. ```mermaid flowchart LR Q["Quest awards"] --> L["Point ledger"] R["Referral points"] --> L L --> F["Filters and window"] F --> S["Standings"] S --> App["Your app"] S --> Cutoff["Window cutoff"] Cutoff --> Review["Frozen settlement"] Review --> Rewards["Approved prizes"] ``` ## Scores are calculated from points [#scores-are-calculated-from-points] Members climb a board by earning points. When a quest pays points into the board's ledger and matches the board's filters, the member's score goes up. Your app never submits a separate leaderboard score. | Score | Meaning | | ------- | ---------------------------------------------------------------------- | | Balance | What the member holds now. Spending points lowers their rank. | | Earned | Points earned in the window, with corrections. Spending doesn't count. | | Net | Everything that changed in the window, including spending. | Earned and net boards can cover all time, fixed dates, the last few days, or the current calendar day, week or month. A calendar window follows your [project calendar](/docs/concepts/calendar), so a weekly board covers the same week as your weekly budgets. Weeks start on Monday. A point counts in the window it took effect in: a quest's points take effect when the attempt is accepted, while its budget is charged on the day of the activity, so the two [can differ](/docs/concepts/calendar#what-follows-the-calendar). * **Imported balances count on balance boards only.** Opening balances a [member import](/docs/use/imports) brought from another platform never decide an earned or net race, or its prizes. * **Referral points count too.** They raise the same ledger's standings unless the board's filters leave them out. Decide how points enter the ledger separately from how a board ranks them. ## External scores [#external-scores] Want to rank something measured outside Domino, like a game rating or a token balance? Give it its own ledger and keep that ledger equal to the score. A `balance` board then ranks it like any other. > [!DEV] > Leaderboards rank only point ledgers. A score measured outside Domino, such as a game rating or a balance your server samples into a [tracker](/docs/concepts/trackers), becomes a dedicated ledger that the system measuring it keeps equal to the score with `set` updates. Every change is a journal entry. The ledger is never spent or priced by rewards. See [Rank an external score](/docs/build/leaderboards#rank-an-external-score). ## Standings [#standings] * **Ties share a rank, or go to whoever got there first.** By default equal scores share ranks. A board can instead rank first whoever reached the score earliest, the same order a settlement with `earliest` ties uses, so every member who scored has their own rank. Either way, members without qualifying entries rank after every member with one. * **No points, no rank, unless the board lists everyone.** Members without qualifying entries stay unranked. A board can list all its eligible members, showing those who haven't scored at 0 points after everyone who has. Prizes still go only to members who scored. * **Use returned ranks.** Your app should show the ranks Domino returns, not row positions. * **Members can see how they scored.** A signed-in member can read the entries behind their own score on a board, and they add up to the score. See [Show how a member earned their score](/docs/build/leaderboards#show-how-a-member-earned-their-score). * **Looking changes nothing.** Reading standings never triggers referral payouts or reward delivery. > [!DEV] > A board defined with `ties: "earliest"` orders equal scores by each member's latest qualifying entry, earliest first, then member ID. Without it, or with `"shared"`, equal scores share a rank. It doesn't change `settle.ties`. A board defined with `includeUnscored: true` lists its eligible members without qualifying entries at 0 points. Settlements still rank only members who scored. A member's score breakdown is the board's ledger filtered by its window, quests and calculation. ## Exclusions [#exclusions] You can leave a member out of a board, for example a team member or someone caught gaming it. Their points stay exactly as they were; they just don't appear in the standings or win its prizes. A board leaves out the members of its **exclusion group**, a [group](/docs/concepts/groups) like any other. A member counts when they are in the board's group (if it has one) and not in its exclusion group. * **Each board starts with its own group.** Excluding a member from a board for the first time creates the board's exclusion group. * **Excluding is a group change.** **Exclude from this board** in Console adds the member to the exclusion group, and reinstating removes them. Adding or removing them in **Groups** does the same. * **Share a group only on purpose.** **Change exclusion group** in Console points a board at another group. Every board using one group leaves out its members, so excluding someone from one of those boards excludes them from all of them. Point the board back at its own group to undo it. Changing the group is a definition, so it reaches live by promotion; excluding and reinstating members work in live. * **A group in use can't be archived.** You can't archive a group a board uses for exclusions, and you can't point a board at an archived group. > [!DEV] > The generated group is keyed `domino--exclusions--` followed by the board ID and titled with the board ID and "exclusions"; group keys starting with `domino--exclusions--` are reserved. `management.leaderboards.exclude` and `domino leaderboards exclude` change the group, as the groups operations do. Bind another group with `management.leaderboards.bindExclusionGroup(board, { group, reason })` or `domino leaderboards bind-exclusion-group`; bind the board to its own group's key to go back. Live refuses a binding with `promotion-required`. Archiving a group that any board uses for exclusions fails with 409 and code `group-in-use`; the response's `leaderboards` lists those boards. ## Prize settlements [#prize-settlements] A weekly or fixed-dates race can pay prizes when it ends. When the window closes, Domino freezes the result: the board as published, who was eligible, who was excluded, the ranks and who won what. Later points and board changes never rewrite it. Your team reviews the frozen result before anyone is paid. * **Approval pays the winners.** Approving creates one completion for each winner and grants its points and tier rewards the same way a quest completion does. * **A single winner can be corrected.** Revoking a winner's completion takes back their points and reward, and the reviewed result stays visible. It can't reopen the prize or pay it again. * **Rejection closes the window.** See [Reject a pending settlement](#reject-a-pending-settlement). * **Only boards with an end can pay prizes.** Balance, rolling and all-time boards never settle. Discord roles can't be leaderboard prizes. * **Late points don't count.** Points must land inside the window and arrive by its end, or, on a board that takes a [closing measurement](#closing-measurement), by the time the result freezes. Points backdated after the freeze never change it. Ties need a rule. You choose one, separately from the order the live standings use; pick the same one for both so members see the order prizes will follow: | Policy | Equal scores | | ---------- | ----------------------------------------------------------------------------------------------------------------- | | `shared` | Members share the same rank. Every member whose shared rank matches a prize range receives it. | | `earliest` | Members are ordered by the time of their last qualifying score entry, then by member ID for a stable final order. | > [!DEV] > Prizes need a fixed or calendar `earned` or `net` board with `settle.at: "window-end"`. The exclusions in a settlement are the members of the exclusion group the board was bound to at the cutoff, as that group stood then. Which points make the cutoff is in [leaderboard settlement cutoff](/docs/reference/authoring#leaderboard-settlement-cutoff). Receipt uses database time, not app clocks or test-member clock advances, and Relay waits for in-flight point writes to commit or roll back before freezing. ### Pool prizes [#pool-prizes] A settlement can also split a pool prize, a fixed amount such as 10,000 USDC or 5,000 points, over the frozen eligible members in proportion to their scores. A board may have rank prizes, a pool, or both, and Relay computes them independently. * **Who shares.** Only frozen rows share, so excluded members and members outside the board's group get nothing and their part goes to everyone else. A member scoring 0 or less gets nothing, and so does a member at or below the pool's `minScore`. * **Exact split.** Each pool share is `amount × score ÷ total`, rounded down, in whole base units of the pool's unit. The units left over go one each to the largest remainders, the earlier position first when remainders are equal, so the shares add up to the pool exactly. Relay uses integer arithmetic throughout. * **Nothing to split.** If no member scored above the minimum, nothing is allocated and the settlement shows the whole pool as unallocated. * **Frozen with the row.** Each share is stored on its frozen row and never recomputed, so a later change to the board leaves earlier settlements as they were. Reviewers see every share before approving. What approval does depends on the pool's unit: | Unit | On approval | | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `"points"` | Each share is a points reward into the pool's `ledger`, in the member's prize completion beside any rank prize. Revoking the completion reverses it. | | A token or currency | Nothing moves. The approved shares are the record of what is owed, and the settlement's export lists each share with the member's verified wallets. | Relay never holds or moves tokens or money. For a non-points pool, you pay each member yourself from the export and track the payment in your own system; revoking a member's rank-prize completion does not change their recorded share. A rejected settlement allocates nothing: its export owes no one, though its review keeps the frozen shares. ### Closing measurement [#closing-measurement] [Engagement points](/docs/build/trackers#settle-engagement-points-daily) take effect when their growth was measured, but measurements arrive on the tracker's refresh schedule and are paid once a day. So a settling board whose score counts an engagement rule's points (the rule pays into the board's balance and, on an `earned` board with `quests`, qualifies through one of them) does not freeze at the cutoff. Its settlement starts closing instead: it reads as `pending` with no rows, and its `closing` field holds when the measurement started and its `deadline`. Console shows it as **Measuring**. 1. Relay asks the scheduled trackers that collect the content those rules pay for to sample it again now, ahead of their refresh interval. A custom source that receives records by ingestion needs a fresh record from your app. 2. A sample observed within 30 minutes after the cutoff counts as measured at the cutoff, so its growth lands in the window that closed. Relay pays it right away, off the daily schedule. 3. When every item is measured, the settlement freezes: its rows appear and `closing` goes away. If 30 minutes pass first, each item without a closing sample settles its last good sample, and the settlement freezes once they have, at most 10 minutes later. Those items count at their last good sample and are listed in the settlement's `unmeasured` list with that sample's time. Content waiting on qualification or a paused quest, or that was removed, earns nothing and is not measured. Boards that count no engagement points freeze at the cutoff. A reviewer can approve a settlement with unmeasured content as is, or choose **Retry measurement** in Console (`retrySettlementMeasurement(id)`, or `domino leaderboard-settlements retry-measurement SETTLEMENT`). The settlement drops its frozen rows, closes again for another 30 minutes, and freezes again. Growth measured that late splits at the cutoff in proportion to time. While a settlement is closing it cannot be approved (`settlement-closing`); a pending settlement with nothing unmeasured has nothing to retry (`settlement-measured`). ## Eligibility changes [#eligibility-changes] Joining a group can also open automatic quests that were waiting on it. Points from a leaderboard prize can do the same once the prize is paid. > [!DEV] > Group inclusion checks prerequisite-only automatic quests that the member can now enter. Points awarded by leaderboard settlements queue an asynchronous current-balance eligibility check after the settlement commits. These decisions appear in [automatic quest outcomes](quests.md#outcomes-for-prerequisite-only-automatic-quests); duplicate settlement or scheduling delivery cannot create another attempt for the same once occurrence. ## Where next [#where-next] - [Publish a leaderboard](/docs/build/leaderboards) — Definitions, prize review, member reads, external points, and moderation. - [Points](/docs/concepts/points) — How points enter the ledger a board reads. - [Project calendar](/docs/concepts/calendar) — Where calendar windows begin and end. ## Archive a leaderboard [#archive-a-leaderboard] Archive a board with **Archive leaderboard** under **About this board**, or in **Settings → Resources**, to hide it from members. Members can no longer read its standings; your team still can, with its history. Unarchive it to show it again under its existing access rules. Removing the board from code and redeploying keeps this decision. See [resource lifecycle](/docs/concepts/quests#resource-lifecycle). ## Read standings and exclusions from the CLI [#read-standings-and-exclusions-from-the-cli] `domino leaderboards list` lists published boards, `domino leaderboards standings BOARD` pages current standings best rank first, `domino leaderboards history BOARD` lists published definitions, and `domino leaderboards exclusions BOARD` lists excluded members. `domino leaderboards exclude BOARD MEMBER --reason "…"` removes a member from standings and settlements, and `include` adds them back. `domino leaderboards exclusion-group BOARD` shows the board's exclusion group, and `domino leaderboards bind-exclusion-group BOARD GROUP --reason "…"` binds another one. They call the same management methods as a trusted server, with the same permissions. ## Reject a pending settlement [#reject-a-pending-settlement] A reviewer can choose **Reject settlement** in Console with a reason, while the settlement is pending or still measuring. Rejection keeps the frozen result for the record, pays no prizes and closes that window for good. It does not freeze the window again. Once a settlement is approved, it can't be rejected. > [!DEV] > Rejection retains the frozen version, standings, exclusions and prize assignments. Approval and rejection compete for the same pending decision. Read all decisions with `management.leaderboards.settlementReviews(board, { cursor, limit })` and `settlementReview(id)`, or `domino leaderboard-settlements list BOARD` and `get SETTLEMENT`. Reject with `rejectSettlement(id, { expectedCreatedAt, reason })` or `domino leaderboard-settlements reject SETTLEMENT --expected-created-at MS --reason "…"`, and approve with `approveSettlement(id, { reason })` or `domino leaderboard-settlements approve SETTLEMENT --reason "…"`. The older `settlements` and `settlement` reads keep their pending/approved response contract and omit rejected and closing settlements. # Members (/docs/concepts/participants) A member is one person in your community program. Domino keeps everything they do with you in one place: the quests they finish, their points, their rewards, and the accounts they've proved are theirs, such as their X handle, their Discord account, or their wallet. * A member who signs in with X can earn points for posting about you. * A member who links their wallet can take part in a holders-only quest. * A member you bring over from another platform keeps the points they had there. A member belongs to one project. Someone who joins two of your projects, even in the same organization, is a separate member in each, with their own accounts and progress. Test and live members are separate too. ```mermaid flowchart LR Identity["Proved accounts"] -->|sign in, match activity| P[Member] Login["Your app's own login"] --> P P --> Progress["Quest progress"] P --> Earned["Points and rewards"] ``` > [!DEV] > A hosted app's address decides the environment: sign-in on staging and previews uses test, and on the live app, at `.domino.page` or its [custom domain](/docs/concepts/hosting#custom-domains), live. A session belongs to the address it was made on, so a member signed in at one address of the live app signs in again at another. ## Verified identities [#verified-identities] Members prove which accounts are theirs: an email address, an X, Discord, Google or Apple account, a Telegram account, or a wallet. Domino signs members in with them, and quests only credit a member's X, Discord, Telegram and wallet activity through accounts they proved. A name, a handle or a profile picture is never enough. | Account | Proved by | Signs in | Quests and trackers use it to | | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Email address | A one-time code sent to it | Yes, with Domino sign-in | — | | X, Discord, Google, Apple | Signing in with the provider | Yes, for the providers Domino configures | Read the member's X or Discord account in a quest, and match [tracker](/docs/concepts/trackers) records by stable X or Discord user ID | | Telegram account | Telegram Login, or Telegram's signature on your Mini App's launch data | Yes, with Telegram Login where Domino configures it, and in your [Mini App](/docs/build/telegram#sign-in-inside-your-mini-app) where the project names its bot | Check membership in the project's connected [Telegram chats](/docs/build/telegram#check-membership-in-a-quest), and match reaction records by Telegram user ID | | YouTube channel ([creator account](#creator-accounts)) | Linking it while signed in, on Google's consent screen | No | Match [YouTube Shorts](/docs/build/trackers#reward-youtube-shorts) to the member who linked the channel | | TikTok account ([creator account](#creator-accounts)) | Linking it while signed in, on TikTok's consent screen | No | Match [TikTok videos](/docs/build/trackers#reward-tiktok-videos) to the member who linked the account | | Wallet: Solana, EVM, Cosmos SDK | Signing a one-time message in the wallet | A wallet first used to sign in, where the project enables its chain family; a wallet linked while signed in only once an operator enables sign-in for it | Check which wallets the member controls, the token balances they hold and, on EVM chains and Solana, how many NFTs of a collection they hold ([Wallets](/docs/build/wallets#check-nft-holdings)), and what they stake in a contract or delegate on a Cosmos SDK chain ([Wallets](/docs/build/wallets#check-staking-and-contract-state)), all at verification time, which events of an EVM contract name their wallets ([on-chain actions](/docs/build/wallets#check-on-chain-actions)) and which of their Solana transactions called a program ([on Solana](/docs/build/wallets#check-on-chain-actions-on-solana)), and match tracker records by CAIP-10 account ID | | Your app's user ID (`app:`) | Your app server, when it signs the user in through an app login integration that has an identity kind | Yes, through your app | Match [tracker](/docs/concepts/trackers) records by your app's user ID, and list it with the member's other identities in a quest | > [!DEV] Every sign-in provider in one list > SDK 1.x keeps `AuthProvider`, and the lists typed with it, to Google, Apple, X and Discord. `relay.auth.providers().signInProviders`, `relay.auth.signInAccounts()` and `useAccount(provider)` list every sign-in provider, Telegram included, with an open `provider` a newer Domino may extend; `relay.auth.start({ provider, mode })` and `relay.auth.disconnect(provider)` take any of them. Operators read and unlink a member's X, Discord and Telegram accounts the same way with `management.members.accounts(member)` and `unlinkAccount`. A few paths take your organization's word instead of the member's proof: * **Imported members.** A [member import](#imported-members) attaches the X, Telegram and Discord accounts and wallets you vouch for. * **Accounts your app vouches for.** With an [existing app login](#sign-in), your server can attach those accounts each time it signs a member in, once an admin with **Publish** permission [allows it](/docs/build/app-login#vouch-for-your-members-accounts) for the key. * **Your own login.** With an [existing app login](#sign-in), your app server vouches for its user. It also links that user as an `app:` identity when the integration has an [identity kind](/docs/build/app-login#link-your-apps-user-ids). * **Your server's records.** A [custom tracker](/docs/build/trackers) record that names a Domino member ID is taken on the word of the server that sends it. * **Test members.** In test, an operator can give a [test member](#test-members) wallets and X or Discord accounts. ### Sign-in [#sign-in] Your app signs members in one of two ways. See [app login](/docs/build/app-login). | Sign-in | How it works | | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Domino sign-in | Members sign in with the X, Discord, Google or Apple accounts, Telegram Login and email code Domino configures, and with a wallet or inside a [Telegram Mini App](/docs/build/telegram) where the project enables them. | | Existing app login | Your app keeps its own login. Your server checks the user's session and exchanges their stable user ID for a Domino member session. | When a member first signs in with an email code, Domino reuses the member of an OAuth account with the same verified email, provided that account is enabled for sign-in. Accounts connected only to check activity can't link the two. If several members match, email sign-in stops instead of choosing one. An existing email identity keeps its current member; this does not repair accounts already split. Domino recovers older Google and Apple sign-in profiles before matching a new email identity. If an older sign-in grant cannot be recovered, the attempt stops: the member signs in with their original method to refresh the account, or contacts you. Existing email identities can still sign in. > [!DEV] > A signed-in member reads their own email addresses with `relay.me.get()`, or `useMe()` in React. `emails` lists the verified addresses that sign them in: each email-code address, then the verified email of each Google or Apple account enabled for sign-in. `email` is the first of them, for showing who is signed in. Accounts connected only for activity verification are not listed. A member who signs in only with a wallet, X, Discord, Telegram or your app's own login has none: `email` is null and `emails` is empty. Participant routes never return another member's email. ### Connected accounts [#connected-accounts] A signed-in member adds accounts by connecting them, so Domino can check their activity with a provider such as Discord. Connecting a member's Discord account and connecting your Discord server to the project are separate steps: | | Connected account | Project integration | | ---------- | ------------------------------------ | ------------------------------------------------- | | Belongs to | The member | The project | | Purpose | Identifies the member | Gives the project access to its configured server | | Discord | Linking the member's Discord account | Connecting the project's Discord server | Rewarding Discord messages and reactions needs both. An organization administrator installs Domino's bot into a server once, or [sends a one-time link](/docs/build/integrations#send-the-install-to-your-server-admin) to whoever manages the server; other projects of the organization then connect the same server without installing it again. The integration names the server Domino listens to, and Domino keeps a message or reaction only when its Discord account is linked to a member of the project. Activity from before the link is not kept. See [Discord messages and reactions](/docs/build/trackers#reward-discord-messages-and-reactions) and [project integrations](/docs/build/integrations). A quest's [announcements](/docs/concepts/quests#announcements) mention the member through the Telegram or Discord account they connected, with their Telegram @username when their sign-in profile has one. A member who connected none there is named by their display name instead. A test-linked account is someone else's, so announcements never mention it. Quests that need Discord fail while the project's integration is missing or needs attention, whatever the member linked. Once it's ready again, Domino retries the failed attempts of automatic quests without a tracker on its own. See [recover a configuration failure](/docs/build/integrations#recover-a-configuration-failure). ### Accounts Domino reads in the background [#accounts-domino-reads-in-the-background] Some providers let Domino keep reading a member's own account after they link it, with their permission, for example to follow their posts. Linking such an account also asks for the permissions those reads need. If the member later revokes access, or the access lapses, the account needs reconnecting: Console shows **Needs reconnect** and the reason on the member, and the member fixes it by linking the same account again. A temporary provider outage doesn't count; Domino just tries again later. > [!DEV] > Domino keeps the account's OAuth tokens, encrypted, while the account stays linked, and refreshes them before they expire. Only the background reads open them; account reads, management reads and audit events never include them. Unlinking the account, resetting a test member, or purging a deleted project deletes the tokens and asks the provider to revoke them. YouTube channels and TikTok accounts are the exception: Google or TikTok would revoke every link of that account to Domino, in every project, so Domino only deletes the tokens, and the member can remove Domino's access in their Google account or TikTok settings. > > | Reason | When | > | --- | --- | > | `revoked` | The provider refused the refresh: the member revoked Domino's access, or the provider ended it | > | `expired` | The tokens expired and the provider gave nothing to refresh them with | > | `missing-scope` | A source needs a permission the member did not grant | > > While an account needs reconnecting, `relay.auth.accounts()` and `useAccounts()` report `status: "reconnect"`, and the management member read reports `status: "needs-reconnect"` with the `reason` (`connected` while the tokens work; accounts Domino keeps no tokens for carry neither field). Linking again asks for every permission the provider's sources need, so a member who granted fewer is asked again. ### Creator accounts [#creator-accounts] A creator account is the account a member publishes from: their YouTube channel or their TikTok account. Domino reads it in the background, so trackers like [YouTube Shorts](/docs/build/trackers#reward-youtube-shorts) and [TikTok videos](/docs/build/trackers#reward-tiktok-videos) can credit the member for what they post there. * **Linked, never used to sign in.** A member signs in some other way first, then links the account. On Google's consent screen they pick the YouTube channel; Domino identifies it by its channel ID, not by the Google account. On TikTok's consent screen they approve Domino reading their profile and public videos; Domino identifies the account by the ID TikTok gives Domino for it, not by the @username. * **One per platform.** Linking another channel or TikTok account replaces the current one. Content the earlier account posted stays where it is. * **Only where Domino configures it.** Until then, members aren't offered that platform at all. Creator accounts are [read in the background](#accounts-domino-reads-in-the-background), so an account whose access stopped working shows **Needs reconnect** until the member links it again. TikTok shares a member's videos only with their own permission, so while a TikTok account needs reconnecting Domino reads nothing from it. In Console, open a member: **Creator accounts** lists their linked accounts, and a reviewer can **Unlink** one with a reason. Domino then stops reading the account; the records it already collected stay. > [!DEV] > In SDK 1.x creator accounts sit beside the closed provider lists. `relay.auth.creatorAccounts.list()` (`GET /v1/auth/creator-accounts`) answers `{ accounts }`: one entry per provider Domino configured, each shaped like an `AccountConnection` with the account's `profile`. `connect("youtube")` or `connect("tiktok")` answers the consent-screen URL, and `disconnect(provider)` unlinks it. A TikTok account's `subject` is its `open_id`, and its `profile` has a name and picture but no `username`. In React, `useCreatorAccounts()` wraps the three. `provider` is an open string: `knownCreatorAccountProviders`, from `@domino-sdk/relay/schema`, lists the values this SDK knows. > > Operators read them on `management.members.get(member).creatorAccounts` and `management.members.creatorAccounts(member)`. `management.members.unlinkCreatorAccount(member, { provider, subject, expectedRevision, reason })` needs **Review** permission and answers the remaining accounts. `expectedRevision` is the account's `revision` as you read it; the older name `revision` still works. A retry with the same `actionId` answers with the first result. ### Operator identity changes [#operator-identity-changes] Sometimes an account needs to stop counting for a member, such as a shared wallet or a hijacked X account. Your team can unlink it, and relink it later. Each account has three separate facts: | Fact | Changes when | Who changes it | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | | Ownership | Never for an account the member proved or signed in with. An imported or vouched account they never used goes when a reviewer unlinks it | **Unlink**, for imported and vouched accounts | | Attribution | The member or a reviewer unlinks it; a reviewer relinks it | **Unlink** and **Relink** | | Sign-in | A reviewer turns a wallet's sign-in on or off | **Enable sign-in** and **Disable sign-in**, wallets only | In Console, open a member. **Identities** lists their X, Discord and Telegram accounts and app identities; **Wallets** lists their wallets. A reviewer chooses **Unlink** or **Relink** and enters a reason, which is recorded. Unlinking stops new quest checks and tracker records from crediting activity through that account in the project and environment. Activity already accepted and rewards already earned stay. Each kind keeps its own rules: * **Wallets and accounts** keep their sign-in when unlinked. A wallet's sign-in has its own command. * **App identities** sign in only while linked: unlinking one also ends the sessions your app started for it. * **X, Discord and Telegram** select one account per provider. To relink another account the member proved, unlink the selected one first. Another member cannot claim an unlinked account, and the original member can link it again. An account or wallet the member holds only on your organization's word is different: a [member import](/docs/use/imports#what-an-import-records) or your [app login](/docs/build/app-login#vouch-for-your-members-accounts) vouched for it, and Console marks it **Imported** or **Vouched by**. If the member never signed in with it or proved it, **Unlink** releases it. It leaves the member with nothing to relink, its owner can link it and sign in with it, and no import or app can attach it to that member again. Use it when the organization vouched for the wrong account. If an account changed while you were looking at it, Console reports a conflict: refresh the member and review the current state before trying again. > [!DEV] > Scripts use `management.members.identityStates`. `list(member)` reads every identity with its `revision`, `linked`, `signIn`, `canUnlink` and `canRelink`, and `source`: where it came from. `unlink(member, input)` and `relink(member, input)` take the `identity`, the `expectedRevision` you read, and a `reason`. `setSignIn(member, { identity, enabled, reason })` changes a wallet's sign-in. Each command accepts an `actionId`: a retry with the same ID answers the first result. All of them need **Review** permission, except `list`, which needs **Read**. > > Name the identity the way a [member import](/docs/use/imports) names it. `members.lookup` and the test-member account commands take the same shape. > > | Kind | `identity` | > | --- | --- | > | Wallet | `{ kind: "wallet", chain, address }` | > | X, Discord or Telegram | `{ kind: "x" \| "discord" \| "telegram", subject }`, with the numeric user ID | > | App identity | `{ kind: "app:", subject }` | > > For an X or Discord account, Relay uses the account with that subject the member has selected, or the only one they hold. If they hold it through more than one sign-in and have none of them selected, the command fails with 409 `identity-ambiguous`. Name it then with the selector the list returned. > > `source` is `member` when the member proved the identity, `import` when a member import attached it (with the import's ID in `import`), `app-login` when your app login's server vouched for it, and `test-link` when an operator linked it to a test member. New values may appear, so treat any other value as unknown. > > The selectors the list returns still work as input: `{ kind: "oauth", provider, issuer, subject }` and `{ kind: "app", appKind, subject }` are deprecated and removed in 2.0.0. So are `members.lookup({ provider, subject })` and the test-member `{ provider, id }`. The per-kind methods (`members.unlinkWallet`, `unlinkOAuthAccount`, `unlinkTelegramAccount`, `unlinkIdentity`, `relinkIdentity`, `setWalletSignIn`, `oauthAccounts`, `telegramAccount` and `identityLinks`) keep working through SDK 1.x and are deprecated. > > Management member reads always carry these fields: every connected account has `kind: "account" | "wallet"`, and the member detail has `wallets`, `balanceChecks`, `profile`, and `identities`. `profile` is `null` when the member has no participant profile. Clients should reject a response that omits these fields instead of guessing what an older server meant. ### Wallets [#wallets] A member links Solana, EVM and Cosmos SDK wallets to a project by signing a message in each wallet; a Cosmos wallet links on a chain the app names by its chain ID. Quests can then check which wallets the member controls and the token balances they hold, for a holders-only quest or reward, on Solana, EVM and the Cosmos SDK chains Domino lists (see [token balances](/docs/build/wallets#check-token-balances)), how many NFTs of an EVM collection or a Solana Metaplex verified collection they hold (see [NFT holdings](/docs/build/wallets#check-nft-holdings)), their staking: a view function's value on an EVM contract, or Cosmos native delegations (see [staking and contract state](/docs/build/wallets#check-staking-and-contract-state)), and whether a wallet took part in an event of an EVM contract, such as a swap, mint or vote, in the last 30 days (see [on-chain actions](/docs/build/wallets#check-on-chain-actions)), and whether a Solana wallet's transaction called a program, with how its balances changed (see [on-chain actions on Solana](/docs/build/wallets#check-on-chain-actions-on-solana)). See [wallets](/docs/build/wallets). | | Project wallet list | Address ownership | | ------------ | ------------------------------------------------ | ---------------------------------------------------------- | | Scope | One project and environment | One project and environment | | Changes when | The member links or unlinks; an operator unlinks | Never: an address belongs to the first member who links it | | Read by | Quests, the member's app, Console | Domino, to refuse the address to anyone else | Linking and signing in are separate: | | Linked wallet | Sign-in wallet | | ------------------- | --------------------------------------------- | -------------------------------------------------------------- | | How it starts | A signed-in member links it | Someone signs in with it; a new address creates a new member | | Signs in | No, unless an operator enables sign-in for it | Yes, while the project enables its chain family | | In the project list | Yes | Yes: signing in links it to the project | | Turned off by | Unlinking removes it from the list | An operator disabling sign-in; unlinking does not stop sign-in | ### Imported members [#imported-members] Moving from another platform? A [member import](/docs/use/imports) brings your members over, keyed by the account each will sign in with: your app's user ID, a wallet, or an X account. When that person signs in, they are the imported member, with their opening balances, quest progress and [referral](/docs/concepts/referrals): who invited them, and the friends they invited. An import never merges two members, and refuses an account another member holds. A row names an inviter who already signs in by the account they sign in with, the same way. Console shows who invited a member under the member's summary in **Members**, marked **Imported** when an import recorded it. An import can also attach X, Telegram and Discord accounts and wallets on your organization's word. Quests, trackers and wallet checks trust these vouched accounts like proved ones, and Console marks them **Imported**. The one exception is a leaderboard's [payout list](/docs/build/leaderboards#export-the-payout-list): it lists only wallets a member proved, so an off-platform payout never goes to an address you vouched for. When the member links an account of that kind themselves, by connecting X, Telegram or Discord, signing in with the X account, or signing with the wallet, their own link replaces the vouched one. A vouched Telegram or Discord account never signs in. Your app's server can vouch for accounts the same way when it signs a member in, so members who join after an import arrive with their accounts too. Console marks those **Vouched by** and the app login key's name. See [vouch for your members' accounts](/docs/build/app-login#vouch-for-your-members-accounts). A wrong import can be [reverted](/docs/use/imports#revert-an-import): its opening balances, imported quest completions, and the accounts it attached that nobody signed in with since are taken back. The members it brought over stay. ## Profiles [#profiles] A profile is the name and picture other members see in one project and environment, for example next to their score on a leaderboard. The member chooses a display name, and a picture they upload, take from a connected X, Discord or Google account, or have generated. A member's profile in one project never changes their profile in another. | Who | Controls | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | You, the operator | Who sees profiles: nobody (the default), signed-in members, or anyone. Which accounts, X or Discord and in which order, fill in names and pictures until members choose. | | The member | Their name and picture, and whether to hide their profile from everyone else. | | Reviewers | Resetting a name, removing a picture, and locking or unlocking a profile, each with a recorded reason. | Where profiles are visible, leaderboard rows show the profile's name and picture. A profile page can also show the member's place on a leaderboard, their completions of quests that opt in to profiles, and their progress in a referral program, but only what the viewer could read anyway. Emails, wallet addresses, member IDs and account IDs are never shown. Google and Apple accounts never supply a default name or picture. See [participant profiles](/docs/build/profiles). > [!DEV] > Apps read a profile by its public profile ID, along with separate reads keyed by it; each read applies its own resource's access. A generated avatar comes from a seed Domino derives from the member with a secret per project. Connecting accounts or linking wallets does not change it, and it reveals nothing about the member. ## Ownership of progress [#ownership-of-progress] * **Completions** are recorded for the member who did the quest. * **Quest rewards** belong to the member who completed the quest. * **Referral rewards** go to both sides: a [referral program](/docs/concepts/referrals) connects an inviter and an invited member so each can receive their own rewards. Your app shows progress, and Domino checks activity and records the result on its server: | Who | Does | Uses | | --------------- | -------------------------------------- | ------------------------------------------------------ | | Your app | Shows progress | The [participant API](/docs/reference/participant-api) | | Domino | Checks activity and records the result | Server-side verification | | Trusted backend | Staff operations | Scoped management credentials | > [!IMPORTANT] Member sessions cannot act as staff > A member's session cannot grant points or approve a review. Run staff operations on a trusted backend with scoped management credentials. ## Erased members [#erased-members] When a person asks you to delete their data, you [erase their member](/docs/use/member-erasure). Their profile, accounts, sign-in, consent and submitted evidence are deleted, and the member stays as an anonymous ID with the points, completions and rewards it earned, so leaderboards and other members' referral rewards don't change. Nothing new reaches an erased member, and the same person signing in again becomes a new member. Erasing needs the **Publish** permission and can't be undone. ## Banned members [#banned-members] You can [ban a member](/docs/use/member-bans) who farms or abuses your program. A banned member can still sign in, but can't take part: Domino refuses their attempts, completions and invitations, skips tracker records that would count for them, leaves them off every leaderboard and settlement, and stops referral payouts to and from them. When you ban, you can also take back their completions, the referral payouts they received and their manual awards, after a preview of what that reverses. Banning needs the **Review** permission, and you can unban later. A ban covers one member: the same person signing in with another account is a different member. ## Test members [#test-members] A test member is a pretend member you create in the test environment to try your quests end to end before launch. It has a display name, no sign-in, and a **Test** badge in Console. Someone with `publish` permission in test can act for it: * **Submit a quest** on its behalf. The submission is checked, reviewed and rewarded like a real member's, and the history names who did it. * **Pass a quest, or accept a rejected try anyway.** Completion rules, budgets and rewards still apply. See [Pass a quest or accept an attempt anyway](/docs/use/quests#pass-a-quest-or-accept-an-attempt-anyway). * **Accept a referral invitation**, with the same refusals, qualification and rewards as a member's own acceptance. * **Redeem an offer**, with the same price, stock, limits and refusals as a member's own redemption. * **Move its clock forward**, by days, hours or minutes or to a later instant, to try streaks, weekly limits and time-of-day rules without waiting. * **Give it a wallet** to try balance and holding quests. See [Give the member a wallet](/docs/use/quests#3-give-the-member-a-wallet). * **Link a real X or Discord account** to try follower, repost and role checks. See [Link an X or Discord account](/docs/use/quests#4-link-an-x-or-discord-account). * **Send X posts, Discord messages and reactions** as the member. See [Send X and Discord activity](/docs/use/quests#5-send-x-and-discord-activity). * **Reset it** to start again. These actions work from Console, a personal API key, or a `domino login` CLI session. Hosted app keys and service keys can't create, list, act as, accept referrals or redeem offers as, pass quests for, advance, give wallets or accounts to, send records for, reset or purge test members. Management credentials still can't issue member sessions. The one exception is the project's staging app. An operator who signed in to it through Console and holds `publish` can sign that app in as a test member. The member gets an ordinary 8-hour member session, so the app runs as that member, and the history records the operator. Signing out or resetting the member ends the session. Staging opened with the project password cannot do this, and live never issues one. > [!DEV] Test member details > Management member detail and list reads include `test: true` and the profile display name as `name`. So do the reads that name members elsewhere: `members.people()`, and the `people` or `person` of attempt and entitlement reads. A submission on a test member's behalf uses member admission, evaluation, human review, and rewards; a pass admits the attempt as a submission would, then accepts it in place of the quest's check, and the attempt's history names the operator. > > **Clock.** A test member has its own clock, initially real time. Operators can advance it by 1–30 project-calendar days per call, preserving local time where possible, or by an exact number of hours or minutes, or to a later instant. The clock can run at most 90 days ahead of real time. `offsetDays` counts the days advanced by date, initially zero. Each advance captures a fixed duration shift for all activity and progress until the next advance. A later DST change can cause an hour of wall-clock drift; elapsed ages and timestamp ordering stay intact. Optional `today` is the server’s current project-calendar date for that member (`YYYY-MM-DD`). Shared tracker records retain real time; each beneficiary’s attempt pins its own projected activity. An omitted `offsetDays` in an older response means zero. Other members and live are unchanged. Existing evaluations keep their frozen clock; workflow timers remain on real time. Reset and create a new member to return to day zero. > > **Records.** Custom tracker records can name a test member with a `member` account. No identity row is required. The test member belongs to the project that created it. > > **Wallets.** A test member can hold Solana, EVM or Cosmos addresses, up to five per chain family, added without a signature. Quest code and tracker attribution read them like wallets a member linked. They can't sign in, and an address that belongs to another member is refused. When the wallet's owner signs in with it or connects it, the address moves to them. > > **X and Discord accounts.** An operator links someone else's public account on their word, without OAuth, so quest code's X profile, follower and repost reads and Discord role reads answer for a real account. Attribution reads it like a proved link, and Console marks it **Test-linked**. It can't sign in, Domino never writes to it (a test-linked Discord account never receives role rewards), and an account that belongs to another member is refused. When the account's owner signs in with it or connects it in the same project and environment, the account moves to them. > > **Synthetic records.** Each X post, Discord message or reaction an operator sends is one synthetic record in a managed X or Discord tracker, which otherwise only Domino's X collector and Discord gateway fill. Relay attributes it, applies quest filters and decides quest triggers as it does for collected activity, and marks it `synthetic: true` in the tracker's records. The record names the member's linked X or Discord account when it has one. Otherwise the first record links the member a synthetic X or Discord account: a verified link that attribution and `x.connection()` read, but that X and Discord don't know and that can't sign in. > > **Reset.** Reset deletes the test member, its wallet addresses, its synthetic accounts and records, and its supported participation history. It unlinks the X and Discord accounts an operator linked to the member and the accounts the member connected while the staging app acted as it, without touching the accounts themselves. A permanent reset marker prevents the old ID from being recreated. Shared tracker datasets, creation and reset receipts, the reset audit record, and the audit of operators signing a staging app in as the member remain. Reset refuses states whose shared or external effects cannot safely be removed. See [Try quests with test members](/docs/use/quests#try-quests-with-test-members) for the API and refusal conditions. ## Active members [#active-members] Domino counts a project's **active members** each calendar month, in UTC. A member is active in a month when, on a UTC day of it after the UTC day they joined the project, they sent a signed-in request to the participant API. Their attempts and redemptions are such requests, and so is every call your app makes with their session. These never make a member active: * the day they joined, so a member who signs up and never comes back is not counted; * a [member import](#imported-members) or a [tracker](/docs/concepts/trackers) record your server reports, on its own; * anything in the test environment, so [test members](#test-members) never count. Each member counts once a month, however often they come back. A member's active days stay in the months they happened, and move with the project when it moves to another organization. Read the count with `management.usage.activeMembers()` or `domino usage active-members` (`GET /management/v1/usage/active-members`, `read` permission). It returns this month's `activeMembers`, the project's `plan` and its `limit` of live active members a month (null when the plan has none), and the six earlier months in `history`, newest first. The count is the same whichever environment you select. In Console, **Settings → Plan & usage** shows this month's count against the limit, with last month's beside it. Going over the limit refuses nothing. Where plans apply, a project over its limit in two calendar months in a row moves up: | Plan | After two months over | | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | Launch or Growth, paid through Domino billing | The next plan starts at the project's next renewal. Domino tells you the plan and the date first, and Domino support can call the change off. | | Scale | Domino gets in touch about Enterprise. | | A plan Domino support set | Domino suggests the next plan; nothing changes on its own. | ## Real people in test [#real-people-in-test] Where plans apply, a project's test environment admits a limited number of **real people**: members who joined test, by signing in or through a [member import](#imported-members). The limit is the plan's: 100 on Free and Launch, 250 on Growth, 1,000 on Scale, and what Domino agreed on Enterprise. [Test members](#test-members) never count. Once test holds that many, a person who never joined test cannot sign in to it: sign-in fails with `limit-reached` (403, `limit: "real-people-in-test"`) and a message that the test version is full. Nothing else changes: * people who already joined test keep signing in, even over the limit; * live sign-in is not limited this way; * staff and operator sessions are not counted. A bigger plan raises the limit. While the limit is enforced, Console shows a notice on project pages from 80% of it. Read the count with `management.usage.realPeopleInTest()` or `domino usage real-people-in-test` (`GET /management/v1/usage/real-people-in-test`, `read` permission). It returns `realPeople`, the project's `plan`, its `limit`, and whether the limit is `enforced`, whichever environment you select. ## Recover saved submissions [#recover-saved-submissions] If a member's saved answer refers to a submission Domino has permanently closed, the member reviews the current quest and chooses to discard the saved answer before sending again. > [!DEV] > If the server confirms that the operation is permanently retired, the SDK preserves that terminal state across reloads. A lost response or an unknown conflict does not authorize discard: resume the original operation instead. In React, `useQuest().retired` identifies this state and `restart()` discards it without submitting a new claim. ## Restore quest identity attribution [#restore-quest-identity-attribution] When a reviewer relinks an account from the member's page in Console, Domino looks again at that member's activity from while it was unlinked, under the quest's current rules. > [!DEV] > Reviewers relink a retained proved wallet or OAuth identity. Wallet attribution retains its first-link time. X and Discord selections receive a fresh link time on relink. Both belong to the project and environment. Ownership, sign-in grants and existing sessions remain unchanged. Wallet sign-in control is separate; app identity unlink retains its existing session-ending policy. > > Relink redecides unlinked historical observations under the current quest policy, while stored observations and admitted attempts remain pinned. See [relink retained identities](/docs/concepts/trackers#relink-retained-identities). # Points (/docs/concepts/points) Points are how your members keep score. They earn points for what they do, spend them on rewards, and climb leaderboards with them. For example: * **XP** for every quest a member completes. * **Raffle tickets** a member spends to enter a prize draw. * **Event credits** your own system hands out at the door. Each kind of points lives in its own **point ledger**. A project can have several, and every member has a balance in each one. Quests, referrals and your own trusted systems can all add points. ```mermaid flowchart LR Q["Quest rewards"] --> L["Point ledger"] R["Referral rewards"] --> L B["Trusted backend awards"] --> L L --> Bal["Member balance"] L --> Board["Leaderboard standings"] ``` ## What members see [#what-members-see] A signed-in member sees their own balance in each ledger and the history of how they earned and spent it, newest first. Each history entry names the quest that paid it when there is one. Members never see the notes an operator wrote when changing their points. Points only ever come from you: from a quest, a referral, a leaderboard prize, an engagement reward, or a change your team or your server makes. A member can't give themselves points. ## More than one kind of points [#more-than-one-kind-of-points] Make a separate ledger when the points mean different things, such as XP that ranks members and tickets that members spend. Several leaderboards can read one ledger with different time windows, so you rarely need a ledger per board. * **Renaming is safe.** A ledger keeps its identity when you rename it. Balances and history don't change, and two ledgers both named "XP" stay separate. * **Archive to retire.** Archive a ledger when new quests and rewards should stop using it. Nothing is deleted: rewards you already published can still pay it, and members and operators can still read its balances and history. Console leaves an archived ledger out of new reward pickers. * **Restore any time.** See [Restore an archived ledger](#restore-an-archived-ledger). > [!DEV] > Publication rejects a new quest, leaderboard, referral, engagement rule or priced reward that references an archived ledger. Ledger IDs are stable; names and descriptions only help people recognise a ledger. One points reward pays a whole number from 0 to 2,147,483,647, whether a quest, an evaluator decision, a leaderboard prize or a referral pays it. An offer price takes the same range but starts at 1. For Console setup, see [Choose a point ledger](/docs/build/leaderboards#choose-a-point-ledger). ### Define a ledger in code [#define-a-ledger-in-code] In shared authored configuration, use a stable ledger key. Domino resolves it to the environment's ID during publication. Define the ledger in `ledgers.ts`: ```ts import { defineLedger } from "@domino-sdk/relay/authoring"; export const community = defineLedger({ key: "community", name: "XP", description: "Community participation", }); ``` Publish that export from `relay.json`: ```json { "pointLedgers": [ { "entry": "ledgers.ts", "exportName": "community" } ] } ``` Merge this property into the existing manifest. A quest can import the handle and award `points(community, 10)`. > [!DEV] > Authoring helpers take typed handles, while member and management reads return opaque string IDs. Unknown references fail validation. See [resource IDs and authoring keys](/docs/reference/authoring#resource-ids-and-authoring-keys). ### Match ledgers in your app [#match-ledgers-in-your-app] Reads return ledger IDs, not keys. A referral reward, point account, or history entry has `balance: "ledger_…"` even when your code declared `"community"`. Comparing that value with a key never matches, and showing it puts an ID on screen. 1. Read the project's ledgers with `relay.pointLedgers.list()`, or `usePointLedgers()` in React, whose `data` holds the list once `status` is `ready`. Each entry has `id`, `key`, and `name`. Signed-out visitors can read it too. 2. Find your ledger by key, then compare IDs: `ledgers.find((l) => l.key === "community")?.id`. 3. Show the ledger's `name`, never its ID. Ledgers created in Console without a key have `key: null`. Quest views already name their ledger: each points reward carries `amount` and `ledger: { id, key, name }`, and its `label` reads like "10 XP". ## Entries, balances, and available points [#entries-balances-and-available-points] Every award, purchase, correction and reversal is recorded as an entry. A member's balance adds them up. Engagement points are dated when the growth was measured. An [engagement rule](/docs/build/trackers#settle-engagement-points-daily) pays once a day, but each of its entries takes effect when the content sample that measured the growth was observed, so the points land in the leaderboard window and budget period of the measurement, not of the payment. | Term | Meaning | | ---------------- | --------------------------------------------------------------------------------------------------------- | | Current balance | The sum of the member's entries. | | Reservation | Points held for a reward that's on its way. They aren't spent yet, but the member can't spend them twice. | | Available points | What the member can spend after reservations. | * **Balances never go negative.** Deductions cannot make a balance negative or consume reserved points. * **Only trusted server operations change points.** A member's own session cannot grant points. * **A retried award pays once.** Retrying an external award requires the same action ID and payload, so a lost response does not produce a second award. See [Supply points from a trusted backend](/docs/build/leaderboards#supply-points-from-a-trusted-backend). > [!DEV] Entry timestamps > New entries record `receivedAt` from the database clock when they are inserted. It is independent of `effectiveAt`, which controls the scoring window and can reflect backdating or a test member's advanced clock. Retrying an entry returns its original receipt time. Existing entries retain their recorded timestamps. ### Record points as an operator [#record-points-as-an-operator] Your team can award, take away or correct a member's points. Each change needs a reason, and **Audit history** records it with who made it. | Change | CLI | Management API `kind` | | --------------------- | ------------------------------------------------------------------------------------------------------- | --------------------- | | Award | `domino points award MEMBER LEDGER --amount 50 --reason "…"` | `award` | | Record spending | `domino points spend MEMBER LEDGER --amount 20 --reason "…"` | `spend` | | Correct by an amount | `domino points adjust MEMBER LEDGER --amount -5 --reason "…"` | `adjust` | | Set an absolute total | `domino points set MEMBER LEDGER --total 120 --expected-total 100 --classification earned --reason "…"` | `set` | | Reverse an entry | `domino points reverse MEMBER LEDGER --entry ENTRY --amount 50 --reason "…"` | `reverse` | > [!DEV] > Operators change points from a trusted server with `management.points.record`, or with `domino points` from the CLI, and need `points` permission. `LEDGER` is the point ledger's ID; `domino point-ledgers list` lists them. `domino points get MEMBER LEDGER` reads the member's total and held points, and `domino points list MEMBER LEDGER` their entries with each one's reason and actor. A CLI change is saved before it is sent: after an uncertain answer, run the error's `operation.resume` command, which repeats the same change without applying it twice. See [CLI automation](/docs/reference/automation#resource-commands). ### Show members their points [#show-members-their-points] A signed-in member reads their own points from the browser. No server route or management key is needed. ```ts import { createBrowserRelayClient } from "@domino-sdk/relay/browser" const relay = createBrowserRelayClient({ baseUrl: "/relay" }) const { items } = await relay.me.balances() const points = items.find((b) => b.ledger.key === "community") // points.balance: the current balance. points.available: what they can spend now. const page = await relay.me.pointHistory({ ledger: "community" }) // page.items: most recently recorded first, each { amount, kind, at, quest }; // pass page.nextCursor for more. ``` In React, `useBalances()` and `usePointHistory("community")` hold the same answers in `data`, and both read again whenever one of the member's quests completes. * **Own points only.** Neither read takes a member: each answers for the session's member, in the project and environment the session belongs to. * **Every ledger.** `balances()` lists every ledger in the project, including archived ledgers, with zero where the member has no entries. * **No operator notes.** History entries leave out the reason and actor an operator recorded. Operators see them in Console, with `domino points list`, and through `management.points.history(member, ledger)`. * **Recorded order.** History lists entries in the order Domino recorded them. `at` is when an entry took effect: a backdated award has an earlier `at`, and a reversal keeps the `at` of the entry it reverses. * **Where points came from.** `quest` names the quest that paid an entry, through a completion or an engagement settlement. `kind` is `award`, `spend`, `adjust` or `reverse`, and Domino may add kinds. Referral payouts appear as adjustments (`adjust`, with no quest). An opening balance a [member import](/docs/use/imports) recorded is an adjustment with `imported: true`. ## Completion and standings [#completion-and-standings] > [!IMPORTANT] A balance does not prove completion > A point balance does not prove which quests a member completed. To require a quest before another, use a completion prerequisite (`completed(...)`), not a points threshold. A [leaderboard](/docs/concepts/leaderboards) ranks members by a ledger. You choose whether spending lowers a member's rank: | Board | Spending | Adjustments and reversals | Imported opening balances | | --------------- | ---------------- | ------------------------- | ------------------------- | | Current balance | Lowers the score | Included | Included | | Earned points | Excluded | Relevant ones included | Excluded | A score that comes from outside Domino, such as a game's high score, gets its own ledger, updated with absolute values. That ledger is never spent or priced by rewards. See [external scores](/docs/concepts/leaderboards#external-scores). > [!DEV] Notify your backend > Positive point journal awards can notify an operator backend through the `points.awarded` [webhook event](/docs/reference/webhooks). Its `source` names what paid the award: a quest, a leaderboard prize, an engagement rule, or an operator's manual award, which has no source ID. Manual adjustments and imported opening balances (entry kind `adjust`) do not emit that event. ## Restore an archived ledger [#restore-an-archived-ledger] Choose **Unarchive ledger** beside it in **Rewards → Point ledgers**, or **Unarchive point ledger** in **Settings → Resources**, and give a reason. The same ledger becomes available to new rewards again, with its balances and history unchanged. > [!DEV] > From the CLI, use `domino point-ledgers unarchive --reason ` in test. The ledger's ID, balances and accounting history remain unchanged. Ledger names and descriptions are current operator configuration rather than versioned release history. See [resource lifecycle](/docs/concepts/quests#resource-lifecycle). # Promotion (/docs/concepts/promotion) A **promotion** makes live match test. When your new quiz, reward and leaderboard are ready in test, you prepare a promotion, check the list of what will change, and approve it. Your members then see the new version, all at once. It covers everything that defines your program: quests from code and from Quest Studio, their settings and archive state, quest types, collections, point ledgers, badges, tiered rewards and their archive state, offers, stock items, pickup policy, leaderboards and their exclusion-group binding, referral programs, trackers, engagement rules, images, dashboards, the project calendar and profile settings. When the project hosts an app, it also carries the version of the app on staging. A promotion always takes everything: you can't pick a subset. Definitions change only in test, and a person approves every promotion. See [test and live](/docs/concepts/environments). ```mermaid flowchart LR Edit["Change definitions in test"] --> Prepare["Prepare a promotion"] Prepare --> Review["Review its changes and warnings"] Review --> Approve["Approve in Console"] --> Live["Live matches test"] Review --> Reject["Reject with a reason"] ``` ## What a promotion holds [#what-a-promotion-holds] | Part | What it is | | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Changes | Each definition live would change: added, changed, archived or unarchived. Definitions that already match are left out. | | Warnings | What approval leaves as it is: a connection test uses that live hasn't set up, such as Discord or a webhook, and anything live has that test doesn't. A separate list names the groups approval creates in live. | | Build | The hosted app version on staging when the promotion was prepared, or none. Approval puts it [live](#the-hosted-app). | | Source | Prepared from test, a rollback of an earlier promotion, or prepared by your developer's deploy command. | | Status | Pending until decided, then applied, rejected (with the reason), or replaced by a newer promotion. | > [!DEV] > Changes carry the kind, the authoring key and `added`, `changed`, `archived` or `unarchived`. `binding-missing` names an integration, webhook endpoint, sign-in method or app secret test uses and live has not set up; `live-only` names a definition live has and test does not; `group-created` names a group approval creates in live. Source is `test`; `rollback` and the promotion it repeats; or `deployment` when the deprecated [`domino deploy --promote`](/docs/build/publishing#publish-behavior-to-live) or `management.deployments.promote` prepared it. Status is `pending`, `applied`, `rejected` or `superseded`. ## How resources match [#how-resources-match] Each resource has an **authoring key** that is the same in test and live: the `key` declared in code, or the ID a resource created in Console was given. A promotion matches resources by that key. Live keeps its own resource IDs, and every reference inside a definition, such as a quest's reward ledger or a leaderboard's balance, points at live's resource. Nothing is deleted. A definition that test archived is archived in live; one that live has and test does not stays as it is, with a warning. Text of a definition's own, such as a description, is copied as written. ## What never moves [#what-never-moves] Some settings belong to each environment, and an organization admin sets them up there: connections such as Discord, Telegram and X, webhook endpoints, member sign-in, and hosted-app secrets. A promotion lists the ones live lacks as warnings and approves anyway; until they are set up, live behaves as it would without them. A [custom domain](/docs/concepts/hosting#custom-domains) exists only in live, with nothing in test to compare, so a promotion neither copies it nor warns about it. Day-to-day state stays in each environment too. Members, points, attempts, stock quantities, pauses and groups are never copied. When a quest, offer, leaderboard or referral program names a group, such as one that rewards [group membership](/docs/concepts/groups#add-members-with-a-quest-reward), and live has no group with that key, approval creates it in live with no members. A group archived in live is never unarchived: if a quest rewards membership in it, or a leaderboard binds it as its exclusion group, approval refuses, naming the quest and the group, until you unarchive the group in live. ## Approval [#approval] A person signed in to Console with publish permission approves or rejects a pending promotion in **Live → Deployments**, where **Promote to live** opens it after preparing. Approval applies every change at once, so live never shows half a promotion. If test or live changed after the promotion was prepared, approval refuses and nothing changes in live. Choose **Prepare again** in Console and review the new list. Approval also refuses when the project needs a plan to go live; see [Going live needs a plan](#going-live-needs-a-plan). Preparing a promotion replaces any older pending one, so only the newest can be approved. > [!DEV] > Approval applies every change in one transaction. A changed environment refuses with `promotion-changed`, and a project that needs a plan with `plan-required`. ## Going live needs a plan [#going-live-needs-a-plan] Building and testing are free. Serving members in live needs a plan for the project. Where plans apply to your organization, this is how approval and live behave: | When | What happens | | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The project has a paid plan | Approval works as described above. | | The project has no plan, and the organization hasn't used its live trial | Approval starts the trial for this project: 14 days on Launch, no card needed. Console says so beside the promotion before you approve. Preparing a promotion doesn't start it. | | The project has no plan, and the trial is running on another project or used up | Approval is refused and live doesn't change. Console says where the trial is and shows the plans beside the promotion: an organization administrator who can buy plans subscribes there through Stripe, then approves again. | | The trial ended without a plan | Live keeps serving for a 7-day grace period. Approving another promotion needs a plan. | | The grace period ended | Live goes offline: the app shows "This app is offline" on its `domino.page` address and custom domain, and members can't use it. | | A paid plan's subscription ends | Live goes offline when the paid period ends. | | A payment for one of the organization's plans has been overdue 14 days | Approving any of the organization's promotions is refused until it is paid. Live keeps serving. | | The payment is 38 days overdue | Live keeps serving, and you get a notice that it goes offline a week later. | | The payment is 45 days overdue | That project's live goes offline until it is paid. | | The project gets a plan, or the payment is made | Live serves again within about a minute, with the same build and no redeploy. | Each organization gets one live trial. It stays used after its project is moved or deleted. Going offline keeps everything: definitions, members, points and history stay, and test is never affected. Projects that served live before plans were introduced keep serving on a plan Domino assigned. Domino support sets a project's plan. To see where a project stands, open **Settings → Plan & usage**. Everyone who can open the project sees its plan, the trial, this month's [active members](/docs/concepts/participants#active-members) against the plan's limit, and the AI builder's balance. Organization administrators who can buy plans also subscribe, change or cancel the plan, buy AI credit and open **Manage billing** there. To see every project at once, open **Billing** under **Organization** in the sidebar, or from **Manage** in the organization menu. Everyone in the organization sees the plan, status and renewal date of each project they can open, with a link to its Plan & usage page, how many of the organization's 10 Free projects are used, what's left of the free AI allowance, and where the trial stands. An organization administrator who can buy plans opens **Invoices and payment method** there for every invoice, the card and the billing details. > [!DEV] > The `plan-required` refusal's `trial` field says where the trial is; with an overdue payment it carries `reason: "unpaid"` and `unpaidSince` instead. While live is offline, member requests to live are refused with `live-offline`. `management.usage.plan()` or `domino usage plan` (`GET /management/v1/usage/plan`, `read` permission) returns what the Plan & usage page shows: the plan and its `source`, `live` and `liveState`, the organization's `trial`, `customDomain`, the AI balance in `ai`, the Stripe `subscription` and whether limits are enforced. > > `management.billing.organization()` or `domino billing organization` (`GET /management/v1/organization/billing`) returns what the Billing page shows, for any member of the organization and without selecting a project: each `projects` entry's `plan`, `source`, `live`, `liveState` and `subscription`, `freeProjects` against its limit, the shared free AI allowance in `freeAi`, the `trial`, and in `billing` whether you may open the customer portal. `management.billing.organizationPortal()` opens the portal and returns to the Billing page. > > The `management.billing` methods and `domino billing` commands are experimental while billing is open to Domino staff only: their results and plan names can change in a minor release. `management.usage.plan()` and `domino usage plan` are stable. ## The hosted app [#the-hosted-app] When a promotion carries an app version that live doesn't serve yet, it shows as a change. Approval applies the definitions first, then Domino puts that version up as your [live app](/docs/concepts/hosting#how-test-and-live-hosting-relate), at `.domino.page`. This typically takes a couple of minutes. Until then, the version live already serves keeps serving. If that fails, the previous version keeps serving. The next promotion shows the app as changed again; approving it tries again. > [!DEV] > Once approval commits, Domino deploys the build again with live's own key, secrets, stores, and schedule signing key, then switches the live app to it. ## Rollback [#rollback] To undo a promotion, choose **Prepare rollback** in Console's promotion history. It prepares a promotion of the definitions and app version an earlier promotion made live, and you review and approve it like any other. It adds to the history rather than rewriting it. * **Newer definitions are archived, not deleted.** Every live definition that promotion did not carry is archived, including one that was already live-only then. * **Pickup policy resets.** A pickup policy the earlier promotion did not have goes back to untracked. * **The earlier app returns.** Approving it puts the earlier app version live, as approval does for any version live does not serve. * **Test is untouched.** The next promotion from test carries its changes again. ## Related [#related] - [Publish behavior to live](/docs/build/publishing#publish-behavior-to-live) — the commands and client calls. - [Test and live](/docs/concepts/environments) — which changes live accepts directly. - [Hosted apps](/docs/concepts/hosting#how-test-and-live-hosting-relate) — how a promoted app version becomes the live app. # Quests (/docs/concepts/quests) A quest is one thing you ask your community to do. Domino checks that they did it, counts it once, and hands out what they earned. For example: - "Answer three questions about our launch" - "Post about us on X" - "Upload a selfie at our booth" - "Invite a friend" > [!TIP] Editing a quest is safe mid-campaign > Members who already sent something keep the rules they saw. Your changes apply to what comes next. ## What members see [#what-members-see] Each quest can show instructions, a list of steps with links, an image, a video, and quiz questions. It can also hint which account or wallet to link. Opening a link in a step doesn't complete the quest on its own: Domino still checks the activity. Your developer sets the defaults in code. In Console you can replace the steps, the image and the account hints together, and reset them to the defaults later with **Reset to type** or **Reset to code**. > [!DEV] > Steps are ordered, their links use HTTPS, and an image has alt text. A presentation edit in Console creates a new release; accepted attempts keep their original rules. See [Add participant presentation](/docs/build/first-quest#add-participant-presentation) for the authoring shape. ## How Domino checks a submission [#how-domino-checks-a-submission] When a member sends their answer, link, or photo, Domino checks it, and can hand unclear ones to your team for a decision. ```mermaid flowchart LR Checking --> Review["Your team reviews (if needed)"] Checking --> Accepted["Accepted: it counts"] Checking --> Rejected["Not accepted"] Review --> Accepted Review --> Rejected ``` If a check fails for a technical reason, that's not counted against the member, and the submission can still go through. Domino keeps a member's answer while they fill it in, so a lost connection or a second tab doesn't send it twice. If the quest changed since they opened it, Domino asks them to look again before sending, and keeps what they wrote. An unsent answer expires 7 days after its last change. You can discard one from the member's page in Console. A member can have one unfinished try per quest at a time. Until it's decided, including while it waits for your team, they can't send another. > [!DEV] > Each member has at most one open submission per quest: their text, URL, quiz answers or photo, and the release they saw. Submitting it creates one attempt, and submitting again returns the same attempt. A submission against an outdated release is refused and the draft kept. > > While a member's own attempt is undecided, another submission or a retry of a failed attempt is refused with `attempt-pending`. Staff confirmations and attempts Domino starts itself, from trackers or automatic quests, are not limited this way. ### When your team reviews [#when-your-team-reviews] A quest can send unclear submissions to your team. If human review is off for the quest, an evaluator's "send to review" becomes a rejection instead, and Console marks it "Would have gone to review; human review is off". Turn review on if unsure results should reach a person. When Domino rejects a submission, the member sees the reason written for them. Your developer can also tag each kind of rejection with a code, so you can tell them apart in Console without the member ever seeing it. > [!DEV] > Turn review off with `review: () => ({ enabled: false })`. An evaluator that returns `decision.review({ reason })` on such a quest is rejected with that reason and marked `requestedReview: true`; a plain `decision.reject({ reason })` leaves it `false`. > > Give each cause a code with `decision.reject({ reason, code: "wrong-answers" })`. Domino stores it as the attempt's `code`. Operators see it beside the reason in Console's attempt detail, the management API's attempt reads, and `domino attempts list|get`. Members never receive it. Keep a code when you reword its reason. See [reject with a code](/docs/reference/quest-rules#reject-with-a-code) for the format and the codes Domino reserves. ## Stop people guessing [#stop-people-guessing] Add a **retry cooldown** to make members wait before trying again after a wrong answer, so nobody solves a quiz by trying every option. New quiz quests in Console start with a 5-minute cooldown. > [!DEV] > Set `retryCooldownMs` in code, from 1 second to 7 days. While it runs, a new attempt is refused with `retry-cooldown` and a `retryAt` your app can show as a countdown. See [Set a retry cooldown](/docs/reference/quest-rules#set-a-retry-cooldown). ### Retry cooldown [#retry-cooldown] Without a cooldown, a member can try again as soon as an attempt is rejected. Set one in Console under **Retry cooldown** for quests made there; other quests, and quizzes defined in code, start with none. Only quests members submit themselves can have one. | Starts a cooldown | Does not | | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | Domino's check rejects the attempt, including a "send to review" on a quest with human review off | An acceptance, which ends any cooldown | | A reviewer rejects the attempt, also while Domino is waiting to check it again | A rejection Domino makes itself: an exhausted budget, a revoked completion, or a check that waited too long or too often | | | A technical failure; the attempt can still be retried | The cooldown applies only to the member whose attempt was rejected. It always uses the quest's current length, so shortening or removing it helps members who are already waiting. > [!DEV] > Until the cooldown ends, a new attempt, whether the member's own submission or a test member's action in Console, is refused with `409` and code `retry-cooldown`, and `retryAt` says when the member can submit again. Quest progress and the quest experience carry `retryAt` while it runs; the quest's `availability` stays `available`. It runs on the member's clock: advancing a test member's clock past it ends it. ### Submission limit [#submission-limit] Every member can submit 120 attempts an hour in a project, across all its quests, counted separately in test and live. It is a ceiling for scripts, far above what a member needs to finish every quest in a starter. Past it, their submissions are refused until the hour moves on, and the answer they were submitting stays saved. Test members you act as in Console are not counted; a test member signed in to your staged app through the staging toolbar counts like any member. > [!DEV] > Past the limit, a member's submit, a submit of their saved submission, and a retry of a failed attempt answer `429` with code `rate-limited` and a `Retry-After` header in seconds. Every request counts, a repeat with the same `actionId` included, and the limit is checked before the request is read. Submitting again through the SDK after a rejection sends two of them: it replays the rejected submission to read its result, then submits the new draft. The SDK keeps the saved draft, so `resume()` continues it later. ## How often someone can complete it [#how-often-someone-can-complete-it] | Choose | When you want | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Once** | Each member to finish it one time, like a sign-up bonus. | | **Per occurrence** | Credit for each separate thing, like every order or every Discord message. | | **Per occurrence, across members** | Credit once for each thing, whoever claims it first, like a single-use code or a post that pays once. See [keys shared by members](#keys-shared-by-members). | | **Streak** | A milestone after a number of days in a row. See [calendar-day streaks](#calendar-day-streaks). | | **Counter** | A reward after several separate actions, like three posts in a week. See [distinct-action counters](#distinct-action-counters). | | **Budgets** | A cap on completions or points, per member, per giver, or for everyone, per day, week, or month. | Your team can also take back a completion made by mistake, accept an attempt that was rejected by mistake, or delete the evidence a member submitted. See [revoke a completion](#revoke-a-completion), [accept a rejected attempt anyway](#accept-a-rejected-attempt-anyway) and [delete an attempt's evidence](#delete-an-attempts-evidence). Domino deletes evidence on its own 90 days after the decision unless you choose otherwise; see [how long evidence is kept](#how-long-evidence-is-kept). ### Completion cooldown [#completion-cooldown] A quest members can complete more than once can make them wait between completions, for example so a second story counts only 90 minutes after the first was accepted. The wait starts each time a completion is accepted. A rejection doesn't start it, and a completion your team revokes doesn't count. If a retry cooldown is also running, the member waits for whichever ends later. The cooldown applies only to that member, and always uses the quest's current length. Set it in Console under **Completion cooldown** for quests made there. Only quests members submit themselves can have one. > [!DEV] > Set `completionCooldownMs` in code, from 1 second to 7 days; publishing one on a quest that completes once is refused. Until it ends, a new attempt, whether the member's own submission or a test member's action in Console, is refused with `409` and code `completion-cooldown`, and `retryAt` says when the member can submit again. When both cooldowns run, the refusal names the later one. It runs on the member's clock: advancing a test member's clock past it ends it. See [Set a completion cooldown](/docs/reference/quest-rules#set-a-completion-cooldown). ### When a quest opens again [#when-a-quest-opens-again] Your app can show members a countdown to when they can try again: after a cooldown, when a used-up budget resets, or when a once-a-day or once-a-week counter opens its next window. There is no countdown when the member can try now, or when nothing will reopen the quest, such as a lifetime budget that's used up or a quest whose dates end first. > [!DEV] > Quest progress and the quest experience carry `nextAvailableAt` (Unix milliseconds), the latest of a retry cooldown, a completion cooldown, an exhausted budget's next reset, and a counter's next window. It is absent when the member can submit now and when nothing will reopen the quest. ### Keys shared by members [#keys-shared-by-members] A per-occurrence quest normally lets every member complete each occurrence once. A quest can instead give each occurrence to the first member who completes it: a code your backend issued that only one person may redeem, or a post that pays only one claimant. When someone else already holds the occurrence, a member's attempt is rejected and says another member already completed the quest with it. Two members who try at the same moment can't both be paid. Console's attempt detail shows that rejection, and the quest's **Completion** reads **Once per key, across members**. To give the occurrence to someone else, [revoke](#revoke-a-completion) the holder's completion and free its occurrence, then accept the other member's attempt anyway. [Member imports](/docs/use/imports) follow the same rule: a row is refused when another member, or an earlier row, already used one of its occurrences. > [!DEV] > Use `keyed(fn, { across: "members" })`, or `({ keyed }) => keyed(fn, { across: "members" })` in the builder form. The release's completion is `{ kind: "keyed", across: "members" }`, and quest progress and the quest experience carry it. An evaluator's `completionKey` takes precedence over `fn` and is checked the same way. The losing attempt is `rejected` with `code: "key-used"` and `codeSource: "relay"`, which members see. Relay claims the key with the completion, its budgets and its rewards in one transaction. An import refuses such a row with `key-used`. Only completions recorded while the quest shares its keys hold them: switching an existing per-member quest doesn't count its earlier completions, so start a new quest and import them onto it. ## Who can take part [#who-can-take-part] Prerequisites decide who can try a quest. A member can need to have completed another quest, hold a minimum number of points, or belong to a [group](/docs/concepts/groups), such as your VIPs. * **Completed another quest.** For example "Welcome" before "Daily check-in", or a check-in today before today's bonus. * **Holds enough points.** This is checked against the member's current balance, so spending points can make them miss it again. * **Belongs to a group.** Your team adds members in Console **Groups**, or a quest's reward adds them. The same group can also limit leaderboards and referral rules. > [!IMPORTANT] Completion is not a count > "Completed another quest" means at least one completion. It is not a count of completions or a points threshold. Use a points prerequisite for a live point threshold. A quest needs every prerequisite it lists. For a rule they can't express, such as "any two of these five quests", your developer can check it in the quest's own code. > [!DEV] > Check such a rule in `evaluate` with `ctx.member.balance(ledger)` and `ctx.member.completions()`. `ctx.member.groups()`, `ctx.member.enrolled(campaign)` and `ctx.member.referrals(program)` read the member's groups, campaign enrollment and referral progress the same way. See [the member's state in evaluators](/docs/reference/verification#evaluator-readers-null-errors-and-pinning). Prerequisites control who can take part. [Collections](/docs/concepts/collections) only control the order quests are shown in: listing a quest second doesn't require the first one. ## Quest types and settings [#quest-types-and-settings] A quest type is a reusable kind of quest, such as a quiz or a photo upload, with settings you fill in. In Console you can create quests from the types your app supports. Each quest has its own progress, even when it shares a type with another quest. * **Your app supports the interaction.** The app must handle the quest's interaction, such as a photo, quiz, or claim. * **Console can customize a code quest.** You can change the settings a code-authored quest exposes. Your values win over the code's defaults until you reset them; the rules themselves stay in code. * **Secret settings stay in Domino.** A code quest can have a secret setting, such as a shared scan code. You set it in Console, and the quest reads it, but no deploy output, quest listing or Console screen shows it again ([change a setting](/docs/use/quests#secret-settings)). * **Keys keep a quest's identity.** Changing a title or renaming a source file keeps the same quest and its history, as long as its key stays the same. * **Duplicates are separate.** Duplicating a quest starts a new completion history. ## Pause and archive [#pause-and-archive] A quest is active, paused, or archived. Console shows its state as **Active**, **Paused** or **Archived** under **Participation**. Where you pause it depends on where the quest comes from: * **A quest made in Console.** Choose **Pause** or **Resume** under **Participation**, then review and publish. * **A quest synced from code.** Choose **Pause quest** under **Quest availability** in its **Activity & details**, with a reason. This pause applies to the selected environment only, and works in live too. **Participation** doesn't offer a pause for these quests; it shows **Paused** only while a resource setting is empty. | State | New tries | What else | | -------- | --------- | ---------------------------------------------------------------------------------------------------------- | | Active | Allowed | | | Paused | None | Use it for a short stop. A code-authored quest is also paused while one of its resource settings is empty. | | Archived | None | Hidden from collections, and your app no longer needs to support it. | * **History stays.** Completions, attempts, points, and rewards remain. Attempts already under way keep their rules. Content qualification and engagement settlement for an archived quest wait until it is unarchived. * **Members keep what they earned.** Members who completed an archived quest still meet requirements on it. * **Deleting the code doesn't archive it.** Deploying its code again keeps an archived quest archived. Archive and unarchive in Console or with `domino quests`. * **Live waits for promotion.** Archive state is part of a quest's definition, so you can't archive in live directly; pause a live quest instead. See [Archive a quest](/docs/build/publishing#archive-a-quest). > [!DEV] > Lifecycle commands use the same verbs across resources: `archive`, `unarchive`, `pause` and `resume`. For example, `management.quests.pause(id, { revision, reason })` pauses participation and `management.trackers.archive(key, { reason })` retires a tracker; the CLI commands are `domino quests pause` and `domino trackers archive`. Archive preserves history; pause preserves archive state. > > Archiving publishes the quest's current rules again with the new state, so its release history shows the change. Read release history with `domino releases list [--quest KEY]` and one release with `domino releases get RELEASE`, or `management.releases.list` and `get`; both need `read` and `publish`. ## Revoke a completion [#revoke-a-completion] Someone on your team with `review` permission can take back a completion that was accepted by mistake or through fraud, with a reason. The completion then stops counting: it disappears from progress, counts and profiles, no longer unlocks other quests, and no longer qualifies a referral. The attempt stays accepted, and its history shows the revoke and the reason. Everything the revoke changes happens at once, or not at all: | What the completion granted | After the revoke | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Points | Each award is reversed in its original period, up to the member's total minus held points. The result reports any remainder as `unreversed` for each point ledger. Relay records no member debt for it, so points already spent or reserved stay with the member and in `earned` leaderboard scores. | | Tiered reward | Voided, after cancelling a handover in progress. If another completion grants the same reward, it is lowered to the best tier that one grants instead. A reward already collected stays collected with a persistent recovery flag, visible after reload. After recovering the item or correcting a mistaken collection, [correct collection](/docs/concepts/rewards#correct-a-tiered-reward) reopens only a tier still earned; without another grant it voids the reward. | | Discord role | Removes the completion’s justification and schedules membership reconciliation. The role stays while another accepted completion across any project/environment grants it. Relay removes only its own assignments; preexisting or explicitly preserved assignments stay. The revoke response retains original grant history; current state is in the delivery’s `reconciliation`. | | Announcement | A message not yet posted is cancelled. A message already posted stays in the chat or channel. | | Group membership | The member leaves the group unless an operator added them too or another unrevoked completion's reward did; an archived group keeps them. The result's `groups` reports `stillMember` for each group. See [group membership](/docs/concepts/rewards#group-membership). | | Referral qualification | Moves to another accepted completion of the program's quest before points are reversed, preserving that completion's percentage bonus contributions. Otherwise the member is unqualified and the inviter's reward is reversed. | | Streak milestone | Revoked. The recorded days stay. | You choose whether the member can complete the quest again for the same occurrence (its completion key): * **Kept occurrence (default).** The revoked completion still holds it, and the budget it used stays spent. Later attempts for it are rejected. A manual streak still records later days and pays nothing; a tracker-triggered `once()` or streak quest skips its observations, so it records no days at all. * **Freed occurrence.** A new attempt can complete it and earn its rewards again. The quest's budgets get back what the completion used, so the occurrence can be completed again within its caps. An automatic quest without a tracker or content trigger queues a fresh attempt immediately if its prerequisites still hold. A tracker-triggered quest needs a new matching observation; replaying the original observation does not pay again. A manual keyed quest needs a new action ID and can reuse its freed completion key. A streak pays its milestone again on the next qualifying day, because its days stay. On a quest whose keys are [shared by members](#keys-shared-by-members), the choice applies to every member: a kept occurrence keeps the key from everyone, and a freed one lets any member complete the quest with it. > [!WARNING] Freeing an occurrence can pay twice > A freed occurrence pays its points and rewards again when it is completed again, and a freed streak pays its milestone again on the next qualifying day. Points this revoke could not reverse, because the member had spent them, stay with the member as well. Keep the occurrence for fraud. Completions it already unlocked stay. Freeing the occurrence returns to each budget what the completion was charged, in the budget period it was charged to; a budget priced in points gets back the charge less the points the revoke couldn't reverse, because those stay paid out. Completions recorded before Domino kept their charges give no budget back. In Console, revoke from the member's **Completions** or from the accepted attempt. Console keeps an unreversed point remainder on the revoked completion in the member and attempt drawers. A person with `points` permission can open the member's existing point adjustment from the warning and record a separate correction with its own reason. That adjustment is an operator decision; revocation itself never creates debt or deducts the remainder later. > [!DEV] > Code uses `management.completions.revoke(completion, { reason, freeOccurrence })`, or the older `revoke({ completion, reason, freeOccurrence })`; see the [management client](/docs/reference/management#management-client). Revoking corrects member state, so an API key with `review` permission can revoke in live. A revoke that frees the occurrence reports `budgets`: each budget ID with the amount `released`. > > Revocation requires the completion's recorded reward history. Relay records it with every completion; only an older completion whose original record could not be read lacks it. Then the command returns `409 completion-grant-history-missing` before changing member state. If the completion granted a shared tiered reward, every other accepted completion of the member must also have reward history; otherwise Relay cannot prove which tier remains earned. Contact Domino support to restore the reported completion's history before retrying. To revoke every completion of a member who farms, [ban the member](/docs/use/member-bans) and take back their completions: each one is revoked this way, keeping its occurrence. ### Restore a revoked completion [#restore-a-revoked-completion] A revoke made by mistake can be undone. Someone with `review` permission restores the completion with a reason, and the completion counts again. Restoring re-applies what the revoke recorded, not the quest's current rewards, so it never gives back more than the revoke took, even if the quest's rewards changed since. Everything the restore changes happens at once, or not at all: | What the revoke did | After the restore | | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Reversed points | Awarded again, as new entries for the amount the revoke reversed. Points it could not reverse stayed with the member and aren't awarded again. | | Voided or lowered a tiered reward | A voided reward is available again, and a lowered or flagged one earns the completion's tier again. A reward someone changed since the revoke (restored, voided again or corrected) stays as it is. | | Revoked a badge | Awarded again, even if staff revoked the badge since. A group membership staff ended since stays ended. | | Removed a group membership | The member joins the group again. A membership the revoke kept is left as it is now, and an archived group's membership doesn't change. | | Stopped a Discord role grant | The grant counts for membership reconciliation again; one that never reached Discord is queued again. | | Cancelled an announcement | It stays cancelled: Domino doesn't post a late announcement. | | Re-evaluated a referral | The member qualifies on this completion again. | | Revoked a streak milestone | Awarded again. | Budgets stay as the revoke left them when it kept the occurrence. When it freed the occurrence, the restore charges each budget again what the revoke returned, against the budget's current limit (or the limit it was first charged under, if the quest removed the budget or changed its period or cost); if a budget has no room left, because other completions used it since, the restore is refused and nothing changes. The completion holds its occurrence again. If the revoke freed the occurrence and the member has completed it again since, the restore is refused, because two completions can't hold one occurrence. The revoke stays in history: the attempt's history shows the revoke and the restore, each with its reason, and Audit history lists both. In Console, restore from a revoked completion in the member's **Completions** or on the accepted attempt. > [!DEV] > Code uses `management.completions.restore(completion, { reason })` (or `restore({ completion, reason })`), or `domino completions restore --reason `, with `review` permission, in live too. The result lists what it re-applied: `points` (each new `entry`, the `reversal` it replaces and the `amount`), `badges`, `groups` (`added`), `entitlements` (`restored`, `raised` or `kept`), `deliveries` (`requeued` or `kept`), `referrals` (`qualified` or `kept`), `streakMilestone` and, when the revoke returned budget, `budgets` (each budget ID with the amount `charged`). A completion that isn't revoked returns `409 completion-not-revoked`; a freed occurrence the member completed again returns `409 occurrence-taken`, with the `completion` that holds it; a budget without room returns `409 budget-exhausted`, with the `budget`. `members.get(id).revokedCompletions` lists the member's 25 most recently revoked completions, each with its revoke's `reason` and recorded result (`revocation`). Restoring sends no webhook, as revoking doesn't. ## Accept a rejected attempt anyway [#accept-a-rejected-attempt-anyway] When a reviewer or Domino's check rejects an attempt by mistake, someone on your team with `review` permission can accept it anyway, with a reason. It works for any member, in test and in live. 1. In Console, open the rejected attempt from **Quest activity**. 2. Choose **Accept anyway**, write why the rejection was a mistake, and confirm. The attempt is accepted as if a reviewer had approved it: the member gets the completion and its rewards, budgets are spent, referrals can qualify, and the member's activity and your webhooks see a new acceptance. The rejection is not erased. The attempt's history shows the rejection and the reversal, each with its reason, and the rejection's retry cooldown ends. Domino refuses, and leaves the attempt rejected, when: | Refusal | Why | | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The member has tried the quest again since | Only the member's latest attempt on a quest can be accepted anyway. Console offers **Accept anyway** only there. | | The quest is archived | Unarchive it first. | | The member's completion cooldown is still running | The acceptance would complete the quest inside it. | | A budget the completion would spend is used up | The acceptance would exceed it. | | The member already has this completion, or your team revoked it | A once-only quest the member already completed, or a completion revoked without freeing its occurrence, can't be completed again. To give back a revoked completion, [restore it](#restore-a-revoked-completion). | | Another member holds the attempt's [shared key](#keys-shared-by-members) | The quest pays each key once across members. Revoke the other member's completion and free its occurrence first. | A counter or streak quest counts the accepted attempt as one action, as an approval does, and its own rules decide whether that action completes it: the refusals above apply only when the acceptance would create a completion. A quest that completes per piece of content can't be accepted this way. To take back an acceptance, [revoke the completion](#revoke-a-completion). > [!DEV] > Code uses `management.attempts.acceptAnyway(attempt, { reason })` (or `acceptAnyway({ attempt, reason })`), and the CLI `domino attempts accept-anyway ATTEMPT --reason TEXT`; both send `POST /attempts//accept-anyway` and answer the accepted attempt. Replaying the action ID returns the first answer. The attempt's events keep the `rejected` event and add a `staff-decision` with `decision: "accept-anyway"` and the reason. The acceptance publishes `completion.created`, which members' activity shows as `attempt.accepted`. > > Refusals are `409`: `attempt-superseded` (the error names the newer attempt; `management.attempts.get(id)` answers it as `supersededBy`), `quest-archived`, `completion-cooldown` with `retryAt`, `budget-exhausted`, `already-completed`, `completion-revoked` and `key-used` (another member holds the attempt's [shared key](#keys-shared-by-members)). An attempt in any other status than `rejected` is refused with `attempt-not-rejected`. A keyed quest's acceptance uses the completion key the check proposed with its review, otherwise a fresh one, so it can't tell that a rejected duplicate of an occurrence the member already completed is the same occurrence: check the evidence before accepting such a rejection anyway. Test members also keep `testMembers.acceptAnyway`, which doesn't check these limits unless you pass `rules: "live"`; see [test members](/docs/use/quests#try-quests-with-test-members). ## Delete an attempt's evidence [#delete-an-attempts-evidence] When a member asks you to delete what they submitted, such as a selfie or an email address they typed in, someone on your team with `review` permission can delete it, with a reason. The attempt and what it earned stay. It works for any member, in test and in live. 1. In Console, open the attempt from **Quest activity**. 2. Choose **Delete evidence**, write why, and confirm. | Deleted | Kept | | ------------------------------------------------------------------ | ------------------------------------------------------------------ | | The photo, which can't be downloaded again | The attempt, its outcome and the reason it was given | | The text, link or quiz answers the member submitted | The completion, its points and rewards | | What the quest's checks read from them, such as an AI photo review | The attempt's history, with who deleted the evidence, when and why | The attempt then shows who deleted its evidence and why, and Audit history lists the deletion. Other attempts that submitted the same photo lose it too, and are listed in the result. Duplicate-photo checks keep working, because Domino keeps a fingerprint of each photo it has seen, not the photo. You can delete evidence once the attempt is decided: accepted, rejected, partly counted toward a counter, or failed. An attempt still waiting for its check or for review keeps its evidence until someone decides it. An attempt without evidence can't be checked again, so retrying it is refused. A rejected one can still be [accepted anyway](#accept-a-rejected-attempt-anyway). Deleting evidence can't be undone. > [!DEV] > Code uses `management.attempts.redactEvidence({ attempt, reason })`, and the CLI `domino attempts redact-evidence ATTEMPT --reason TEXT`; both send `POST /attempts//redact-evidence` and answer `{ attempt, attempts, redaction }`, where `attempts` lists the attempts redacted and `redaction` is `{ at, actor, reason }`. Replaying the action ID returns the first answer; redacting a redacted attempt again answers its first `redaction` with `attempts: []`. `management.attempts.get(id)` answers `redaction`, and the attempt's `evidence` is `null` with no `input`. Downloading the deleted photo returns `404`. Each redacted attempt records an `evidence-redacted` event, in the `reviews` audit category. > > Refusals are `409`: `attempt-not-decided` while the attempt, or another attempt with the same photo, waits for its check or review, and `evidence-redacted` from `attempts.retry` afterwards. The photo leaves storage right after the deletion; if storage is unavailable, the hourly cleanup retries it. ### How long evidence is kept [#how-long-evidence-is-kept] Domino deletes the evidence of decided attempts 90 days after the decision, in test and in live, exactly as deleting one attempt's evidence does. You choose a different period for your project, from 1 day to 10 years, or keep evidence until someone deletes it: 1. In Console, open **Settings** → **General**. 2. Under **Evidence retention**, choose how many days after review evidence is deleted, or keep it, and save. The period counts from the decision, so an attempt still waiting for its check or for review keeps its evidence until it is decided and the period has passed. Failed attempts, which have no decision time, keep theirs. A photo also submitted by an attempt that isn't due yet stays until that attempt is due too. Domino deletes due evidence every hour, a batch per project at a time, and the deletions appear in Audit history. Nothing is deleted in a project's first 90 days. For projects that existed when Domino started deleting evidence, on 20 October 2026, those 90 days count from then, so their first deletions are on 18 January 2027; the project's settings show the date while it is ahead. Anyone who can publish can make the period longer or keep evidence. Making it shorter, or switching from keep to a number of days, deletes evidence sooner, possibly a whole project's at once, so it takes an organization admin or a [service key](/docs/start/existing-app#5-call-domino-from-your-backend) holding `publish`. The project's access history records each change with the old and new period. > [!DEV] > Code reads the period with `management.attempts.evidenceRetention()` and changes it with `management.attempts.setEvidenceRetention({ evidenceRetention })`, where `evidenceRetention` is `{ days }` (a whole number from 1 to 3650) or `"keep"`. Both methods are experimental. The CLI has `domino attempts evidence-retention` and `domino attempts set-evidence-retention --days N` or `--keep`. Both send `/management/v1/evidence-retention` (`GET` needs `read`, `POST` needs `publish`) and answer `{ evidenceRetention, noticeEndsAt }`: no evidence is deleted automatically before `noticeEndsAt`, in Unix milliseconds. A period outside 1 to 3650 days is refused with `400` `invalid-request`. Shortening it without an organization admin's Console session or personal key, or a service key, is refused with `403` `permission-required`, from test as from live, since one period covers both. A change records an `evidence-retention-changed` event in the project's access history (`before`, `after` and the environment the change was made from). Each hourly batch of up to 100 attempts records one `evidence-redacted-before` event in the `reviews` audit category with no actor and no quest, and each attempt's `redaction` has a `null` actor and the reason `retention: days`. ### Delete old evidence on a schedule [#delete-old-evidence-on-a-schedule] When you promise members to keep what they submit for less time than your project's period, or for a different time per quest, such as selfies for 7 days after an event, a job deletes it for you: each pass deletes the evidence of one quest's attempts decided before a time you choose, exactly as deleting one attempt's evidence does. For different periods per kind of evidence, run one pass per quest. See [delete old evidence nightly](/docs/build/schedules#delete-old-evidence-nightly) for a scheduled job that does it. In test, anyone with `review` permission can run a pass. In live, a pass deletes a whole quest's evidence at once and can't be undone, so it takes an organization admin or a [service key](/docs/start/existing-app#5-call-domino-from-your-backend) with `review`, which only an admin can create. Reviewers who aren't admins can still delete one attempt's evidence in live. A pass covers attempts that were accepted, rejected or partly counted toward a counter before the time you give. It skips attempts still waiting for their check or review, and failed attempts, which have no decision time. A photo also submitted by an attempt the pass doesn't cover, such as one waiting for review, one decided later, or one on another quest, stays with all its attempts. The time can't be in the future. > [!DEV] > Code uses `management.attempts.redactEvidenceBefore({ quest, decidedBefore, reason })`, where `quest` is a key or ID and `decidedBefore` is in Unix milliseconds, and the CLI `domino attempts redact-evidence-before --quest QUEST --decided-before MS --reason TEXT`, which repeats until nothing is left and prints the total. The method sends `POST /attempts/redact-evidence` (`review` permission; in live, an organization admin's own Console session or personal key, or a service key, otherwise `403` `permission-required`) and answers `{ quest, decidedBefore, attempts, more, redaction }`: each call deletes up to 100 attempts' evidence, oldest decision first, and `more` stays `true` while others remain, so repeat until it is `false`. `attempts` also lists the quest's other attempts that submitted the same photo. Replaying the action ID returns the first answer. Each call records one `evidence-redacted-before` event in the `reviews` audit category, naming the quest, `decidedBefore`, the number of attempts (`redacted`), their IDs and the reason. A `decidedBefore` in the future is refused with `400` `invalid-request`; an unknown quest with `404`. ## Calendar-day streaks [#calendar-day-streaks] A streak quest rewards days of activity in a row, such as posting on X five days running or checking in every day for a week. Domino records the day of each accepted try and pays the milestone when the run reaches its goal. Each day comes from a time Domino trusts, the same time budgets use: | How the quest starts | Day counted | | --------------------------------------- | ----------------------------------------------------------------- | | A member submits, or your team confirms | The day Domino received the submission or confirmation. | | From a tracker | When the activity happened, such as when an X post was published. | * **A submitted streak counts submission days.** A member who submits on seven days in a row completes a seven-day streak, whatever day the evidence shows. A submission that waits for review still counts the day it was submitted. To count the days the activity happened, start the quest from a tracker. * **Days follow the project calendar.** Every streak in a project and environment counts days of the [project calendar](/docs/concepts/calendar), not each member's device timezone. Days end at midnight in the project timezone. * **One action per day counts.** Several actions on one day fill a single day. Today stays open, so a run survives until a whole day passes without activity. * **Each quest has one milestone.** It pays once per member. A broken run can restart, but it does not pay the milestone again. Use separate quests for 7-day and 30-day milestones. Changing the goal, or switching between submitted and tracker activity, also needs a new quest. * **Late evidence counts at its activity time.** A delayed tracker record can repair a missing day. There are no implicit grace days. * **Progress is not completion.** An accepted action below the goal records progress without a completion. A timezone change takes effect at the next midnight, keeps a running streak going, and never rewrites recorded days. See [when the timezone changes](/docs/concepts/calendar#when-the-timezone-changes), and [streak progress](/docs/use/progress#consecutive-day-streaks) for what members see. > [!DEV] > `streak({ days: 7 })` replaces the completion rule; the evaluator accepts or rejects each attempt. A manual quest counts the day Domino admitted the submission or staff confirmation; a tracker quest counts the record's `occurredAt`. A [content quest](#content-backed-quests) completes once per post, and an `automatic()` quest with only prerequisites has no activity to count, so neither can be a streak; publication rejects them. ## Distinct-action counters [#distinct-action-counters] A counter completes a quest after several separate actions, such as three different posts in a week. Each action is checked on its own, and Domino counts the ones it accepts. Each action counts once, and an accepted action below the goal shows as progress, with no completion or reward yet. | Window | What counts | Default achievement limit | | -------------------------------------- | ----------------------------------------------------------------------------- | -------------------------- | | Omitted | Distinct actions over the member's lifetime | One lifetime achievement | | `calendar("day" \| "week" \| "month")` | Actions whose trusted occurrence time belongs to that project-calendar bucket | One achievement per bucket | | `rolling({ days: 7 })` | Actions in the trailing elapsed duration when qualification settles | One lifetime achievement | Quests members submit and tracker-triggered quests support counters; content quests and prerequisite-only automatic quests do not. Your team can exclude or restore an action that hasn't been settled yet, with a reason. A counter's goal, window and repeat rules can't change after publication: publish a new quest for different rules. > [!DEV] > Use `progress({ distinct, goal: count(n) })`. Relay records each accepted action's distinct key and settles the goal together with completion and rewards. `distinct` reads the same trusted context as `keyed`, including the pinned tracker observation and project calendar, and cannot mutate the counter. An accepted action below the goal leaves its attempt `progressed`. > > Set `repeat: "repeat"` to award another achievement for each separate batch of `goal` actions. An action never belongs to two achievements. Calendar counters deduplicate within their bucket; lifetime and rolling counters deduplicate for the lifetime of the quest. Manual actions use submission admission time; tracker actions use the record's occurrence time. Rolling days mean 24 elapsed hours each. Calendar days follow daylight-saving changes and the project calendar. > > Calendar evidence can arrive until seven days after its bucket ends. Set `calendar("day", { latenessMs })` to choose zero through 90 days. Rolling evidence older than its trailing window is retained but cannot count; future activity cannot count. Contributions and achievements have no automatic retention expiry. A later submission cannot reuse an expired distinct key. > > A budget-denied achievement is final and retains its actions and budget ID. It does not create a completion, grant rewards or qualify a referral, and it is not moved to a later budget period. Counter history exposes that result. Restored actions are considered on the next accepted action. Settled evidence stays immutable even if its completion is revoked. > > Revocation does not recycle counter evidence or reopen a settled counter slot. The completion command's `freeOccurrence` option releases its completion key, but it cannot override the counter's distinct-action and once-per-window rules. Repeat counters can still earn later achievements from new disjoint actions; a calendar counter can earn in its next bucket. See [counter progress](/docs/use/progress#distinct-action-counters) for member and operator reads, and [qualified referrals](/docs/concepts/referrals#qualification-with-counters) for composition. ## Quests that run on their own [#quests-that-run-on-their-own] Not every quest waits for a member to submit something. Domino can start one itself: * **From a tracker.** A new post on X, a Discord message or reaction, or a record your server sends starts a check for the member behind it. See [tracker-triggered quests](#tracker-triggered-quests). * **From creator content.** Each post a [content quest](#content-backed-quests) collects is checked and rewarded at most once. * **When a member qualifies.** A quest with only prerequisites is checked as soon as the member meets them, such as reaching 100 points. See [outcomes for prerequisite-only automatic quests](#outcomes-for-prerequisite-only-automatic-quests). ### Content-backed quests [#content-backed-quests] A quest can read a [tracker's dataset](/docs/concepts/trackers) while it checks, or be started by it. A content quest turns each collected item that belongs to a member into its own try, checked again when its body changes and rewarded at most once. See [UGC programs](/docs/concepts/ugc) for admission, performance gates, and engagement points. > [!DEV] > Declare one with `content({ tracker, ...policy })`. Content quests omit `completion`; X and custom sources with declared content bodies and metrics use the same stages. ### Tracker-triggered quests [#tracker-triggered-quests] A tracker-triggered quest checks each new record a [tracker](/docs/concepts/trackers#tracker-triggers) collects, for the member it belongs to: by default the author, or for example the person an X post replies to. When a record is skipped, Domino records why, and your team can retry it. > [!DEV] > `automatic({ tracker })` starts one attempt for each record observation it selects: its firing mode `on` picks new records, every new observation, or changed data, and an optional `where` filter checks the record's data. The attempt belongs to the member behind one of the record's accounts, its `beneficiary` role: the author by default, the account an X post replies to, quotes or reposts with `"inReplyTo"`, `"quoted"` or `"reposted"`, the author of the message a Discord reaction is on with `"messageAuthor"`, or a named account a custom record carries. Its evaluator reads the observation as `ctx.tracker`. Use `once()` for a one-time threshold, `keyed(...)` for one completion per occurrence, with a budget to cap it, or `streak(...)` for consecutive [calendar days](#calendar-day-streaks) of activity. A skipped observation records its code and reason. ## Checks that wait [#checks-that-wait] Some checks can't be decided right away, such as a post that needs more likes. Domino then checks again later, up to 24 hours from the first wait and 100 times, and can show the member how far they are, such as three of five. Showing progress never completes the quest, spends a budget or grants rewards on its own: only a later acceptance can. If the wait runs out, the attempt is rejected. Pausing or archiving the quest stops new tries but lets waiting ones finish under their original rules. Someone on your team with `review` permission can reject a waiting attempt to stop it. While an attempt waits, the member can't send another for the same quest. > [!DEV] > The evaluator returns `decision.defer` or `decision.progress` (an absolute amount). Each scheduled check uses fresh reads and a fresh evaluation clock, while retries of that check replay its captured reads. The attempt keeps its original release, member, submission, and activity time. Progress becomes visible only when the decision commits. A reached deadline rejects with `defer-expired`; another deferral after the count limit rejects with `defer-limit-exceeded`. Human review ends the timer loop. > > A new manual submission for the same member and quest returns `409` with code `attempt-pending` while the attempt waits for its next evaluation, runs it, or awaits the resulting human review. Reusing the original action ID with the same request still returns that attempt. Manual completion keys are computed during evaluation, so this guard also applies to keyed quests. > > See [defer evaluation or report progress](/docs/reference/quest-rules#defer-evaluation-or-report-progress) for the limits and capture rules, and [show evaluator progress](/docs/use/progress#show-evaluator-progress) for member fields. ## Attempts and completion [#attempts-and-completion] This section is the precise model behind the pages above, for your developer. An attempt is one try under a particular release of the quest. Completion is a successful attempt. | Term | Meaning | | ------------ | ----------------------------------------------------------------------------------------------------------------- | | Attempt | One check of a submission, tracker record, prerequisite change or staff confirmation, under a particular release. | | Completion | The successful result of an attempt. | | Prerequisite | A typed completion, balance, or group condition that controls eligibility. | | Quest type | Reusable behavior and configurable settings shared by quest instances. | | State | Meaning | | ------------------------ | -------------------------------------------- | | Checking evidence | Domino is verifying the submission. | | Waiting for human review | The submission waits for a human reviewer. | | Rejected | The evidence did not qualify. | | Accepted | The attempt succeeded; this is a completion. | An accepted attempt keeps its original release and promised rewards. New publication does not rewrite an attempt already in progress. See [progress and review](/docs/use/progress) for recovery and review behavior. > [!DEV] Completion rules > | Rule | Allows | > | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | > | `once()` | One successful completion per member. | > | `keyed(...)` | One successful completion per stable occurrence key, such as an order or Discord message. Retrying the same occurrence must keep the same key. | > | `keyed(..., { across: "members" })` | One successful completion per key across all members, such as a single-use code or a post that pays once. Another member's attempt with a used key is rejected with `key-used`. See [keys shared by members](#keys-shared-by-members). | > | `streak(...)` | One milestone per member after a number of consecutive calendar days. See [calendar-day streaks](#calendar-day-streaks). | > | `progress(...)` | One achievement per batch of distinct accepted actions. See [distinct-action counters](#distinct-action-counters). | > | Named budgets | Limits on successful completions or awarded points across quests, per member, per tracker record author ([what one giver gives](/docs/build/trackers#cap-what-each-giver-gives)) or across the project, for all time or per day, week (starting Monday) or month of the [project calendar](/docs/concepts/calendar). | > [!DEV] Triggers and prerequisites > | Trigger | Starts with | > | --------- | --------------------------------------------------------- | > | Manual | A member submission or staff confirmation. | > | Automatic | A tracker record, including a collected post for a [content quest](#content-backed-quests), or a change to prerequisite eligibility. | > | Tracker | A record observation a tracker trigger selects by firing mode and optional data filter. It runs for the member behind the record account it pays. | > > - `completed(quest)` requires a past completion, including one completion of a repeatable quest. > - `completed(quest, { scope: "period", period })` requires a completion in the current project-calendar day, week, or month. > - `balanceAtLeast(balance, minimum)` observes the member's current point balance. > - `inGroup(group)` observes current membership in a [group](/docs/concepts/groups). `defineGroup(...)` names the group by its key; publishing creates it, empty, if the environment lacks it, and operators add members in Console **Groups** or with `management.groups`. ```ts import { balanceAtLeast, completed, defineGroup, defineLedger, defineQuest, inGroup, manual, once, settings, } from "@domino-sdk/relay/authoring"; const community = defineLedger({ key: "community", name: "XP" }); const vips = defineGroup({ key: "vip", title: "VIP" }); const welcome = defineQuest({ key: "welcome", title: "Welcome", settings: settings.object({}), trigger: manual(), completion: once(), rewards: [], }); const dailyCheckIn = defineQuest({ key: "daily-check-in", title: "Daily check-in", settings: settings.object({}), trigger: manual(), completion: once(), rewards: [], }); requires: [ completed(welcome), completed(dailyCheckIn, { scope: "period", period: "day" }), balanceAtLeast(community, 100), inGroup(vips), ] ``` ### Trigger and completion rules [#trigger-and-completion-rules] The trigger decides which completion rules a quest can use. TypeScript checks authored quests, and publication validates the same rules for every client. | Trigger | Completion | Prerequisites | | ------------------------------- | -------------------------------------------------------- | ------------- | | Manual or tracker activity | Once, keyed, streak or counter | Optional | | Prerequisite-only `automatic()` | Once | At least one | | Content stage | Implied once per content item; omit `completion` in code | Optional | Only content stages can declare performance evaluation. Reusable prerequisite-only quest types may leave the prerequisite list for Console instances; each instance must supply it before publication. Tracker beneficiary roles follow the source: choosing an account the source does not name fails validation. ### How the check runs [#how-the-check-runs] The code that checks a submission is the quest's evaluator. Its runtime rules (repeated runs, the frozen clock and randomness, what a missing account or a provider outage returns, and which accounts are pinned) are in the authoring reference: * See [evaluation time and randomness](/docs/reference/quest-rules#evaluation-time-and-randomness). * See [evaluator readers: null, errors and pinning](/docs/reference/verification#evaluator-readers-null-errors-and-pinning). * The [testing kit](/docs/build/testing) runs an evaluator locally under the same rules. A missing or broken Discord connection is a setup problem, not evidence that a member failed the quest. Such an attempt ends as failed straight away with a clear reason. Fix the connection, then retry it. > [!DEV] > An uncaught `integration-required` capability error ends the attempt as `failed` immediately with a reason and code. Publication does not wait for the connection: a quest that needs Discord publishes, a deployment reports it in its `warnings`, and its attempts fail this way until Discord is connected. Connecting or repairing Discord admits the failed attempts of automatic quests without a tracker again. See [recover a configuration failure](/docs/build/integrations#recover-a-configuration-failure). ### Declared integrations [#declared-integrations] A quest declares provider modules with `integrations: [discord, x]`. The declaration controls which provider readers its evaluator receives: `ctx.discord` and `ctx.x` exist only for declared integrations. The SDK types and the hosted evaluator enforce the same rule. A quest using Discord role rewards or resource selectors also declares `discord`. Wallet, tracker, member, image and review readers remain available; an image sourced from X still needs the X declaration. A quest can also name a module by its provider, such as `{ provider: "future-chat" }`, for a module Domino adds before your SDK knows it. Such a quest builds, and publishing it refuses a module Domino does not have with `unknown-integration-module`. > [!DEV] > A quest's `integrations` lists only the modules this SDK knows (`discord`, `x`). `integrationModules` is its open sibling and lists every declared module, so read it to see modules a newer Relay adds. In the same way, `rewardEntries` beside a release's or a catch-up preview's `rewards` lists every reward as `{ type, data }`, where `type` is the reward's kind and `data` the rest of it. A reward kind your SDK does not know appears only in `rewardEntries`. Use `discord.grantRole(roleId)` for a declared role reward. Evaluators read provider state; durable reward delivery happens after completion. See [integrations](/docs/build/integrations) and the [v1 upgrade steps](/docs/reference/upgrade-to-v1#declared-integrations). ### Announcements [#announcements] An announcement is a message Domino's bot posts in one of the project's connected Telegram chats or Discord channels when a completion pays, such as "@ana just received +10 XP by getting a ⚡ from @sam". It isn't a reward: it is posted after the completion's rewards are saved, and a post that fails never undoes them. Each post is retried, shows on the member's page in Console with its status, and can be retried there once it fails for good. Posts in one chat or channel wait in line, paced to what Telegram and Discord allow. The message comes from a template with a few placeholders: the member who earned, whoever triggered the completion (such as the admin who tipped), the points paid and the quest's title. People are mentioned through the account they linked on that platform. A Test environment posts only in its own connected chats and channels. See [Announce tips in the group](/docs/build/telegram#announce-tips-in-the-group) and [Announce in a channel](/docs/build/integrations#announce-in-a-channel). > [!DEV] > Declare them in `announcements`, beside `rewards`, with `telegram.postMessage()` and `discord.postMessage()`; a quest posts at most three. In SDK 1.x they are a quest field of their own because the reward union is closed. Each post is a reward delivery with `action: "post-message"`. ### Shared SDK runtime [#shared-sdk-runtime] Quests import the installed SDK instead of each carrying their own copy, so a published quest is a few kilobytes and always runs with the SDK version it was built against. Each published bundle also declares the evaluator protocol it expects. See [shared SDK runtime](/docs/reference/authoring#shared-sdk-runtime) and [evaluator protocol](/docs/reference/verification#evaluator-protocol). ### Activity and webhooks [#activity-and-webhooks] Your app can show members what just happened, such as a review decision, an accepted attempt or a reward change, including completions of quests that ran on their own. > [!DEV] > Subscribe with `relay.me.activity.observe()` or React's `useActivity({ onEvent })` instead of inferring a new completion by comparing snapshots. React's `` follows the feed itself: each event reads again only the hooks it changes, and the whole quest experience is read every 30 seconds for changes no event announces. An accepted attempt and its reward deliveries remain separate events; acceptance does not mean an integration reward has arrived. > > Events are scoped to the member's project and environment and contain public outcome fields. Review notes and provider diagnostics remain operator information. The feed starts with events created after its migration; existing progress and rewards remain the source for earlier state. See the [participant API reference](/docs/reference/participant-api#participant-activity) for cursor polling and recovery. > > Operator backends can subscribe to `completion.created` and `review.pending` [webhook events](/docs/reference/webhooks). A completion event identifies the committed completion, the attempt, and its completion key in `key`, with `source.kind` set to `quest`. A leaderboard prize sends the same event with `source.kind` set to `leaderboard`. Pending review identifies the attempt that needs a decision. Events are delivered independently, with retries and no ordering guarantee. ## Resource lifecycle [#resource-lifecycle] The other things your quests use, such as trackers, leaderboards and point ledgers, can be archived too. Archive one when new activity should stop but its history should stay. Archive state belongs to one project and environment, and live changes it only through [promotion](/docs/concepts/environments). | Resource | While archived | What remains | | ---------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | Tracker | Scheduled collection, Discord collection and custom ingestion stop. | Records, observations and captured evaluator reads. Restoring retains a separate pause setting. | | Referral program | New qualifications and bonus accrual stop. | Relationships, previously earned rewards and correction history. | | Leaderboard | Members cannot list or read it. | Operators can inspect standings and history. | | Engagement rule | New settlements stop, including results computed before archive but not yet committed. | Accounts and accounting watermarks. Restoring can settle growth since the retained watermark. | | Collection slot | The slot leaves member experiences. | Its selected quests and ordering. Those quests remain available through other paths. | | Quest type | New quest instances cannot use it. | Existing quests and their pinned type versions. | | Image | A new evaluator read cannot capture the image by name. | Stored image bytes and evidence already captured by an attempt. | | Point ledger | New definitions cannot choose it. | Existing published rewards, balances and accounting entries. | Removing a declaration from source does not archive it, and deploying it again does not restore it. Unarchive it in **Settings → Resources**. A quest has its own [archive controls](/docs/build/publishing#archive-and-restore-resources). Archiving a reusable quest type does not archive its existing quest instances. Changes to archive state invalidate an earlier publication preview; review again before publishing. > [!DEV] > Unarchive through the management API too. Live refuses archive and restore with `promotion-required`. Tracker definitions and point ledger names and descriptions are current operator configuration, not immutable release histories. Captured evaluator facts, quest releases and point accounting entries retain their existing historical guarantees. Redeployment preserves lifecycle state independently of configuration changes. ## Outcomes for prerequisite-only automatic quests [#outcomes-for-prerequisite-only-automatic-quests] A quest with only prerequisites checks a member again whenever something changes that could qualify them: a completion, joining a group, enrolling in a campaign, a publication, or a freed occurrence. Any change to their points, such as a quest or referral award, a leaderboard prize, spending, a refund or an operator adjustment, also queues a check of their current balance. Each check is recorded as admitted or skipped: | Outcome | What happens next | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `admitted` | The linked attempt evaluates the quest. Admission does not guarantee a reward. An attempt with status `rejected` and code `budget-exhausted`, or `failed` with code `integration-required`, can run again after another member event or reviewed operator recovery. | | `prerequisites-incomplete` | A later prerequisite change checks the current rules again. | | `quest-paused` | Resuming keeps the skipped event unchanged. An operator can preview and confirm recovery. | | `already-completed` | No new attempt is created for the completed occurrence. | | Another skip code | Show its reason. Current windows, campaign enrollment and budgets can also block admission. | Your team can recover up to 50 paused or budget-blocked outcomes at once, after a preview of the new checks, rewards and budget capacity. See [Recover automatic quest outcomes](/docs/use/quests#recover-automatic-quest-outcomes). > [!DEV] > Duplicate delivery preserves an outcome; later transitions can clear `prerequisites-incomplete`. Several transitions while a once-quest attempt is pending reuse the same attempt. If an admitted attempt loses a race for budget while claiming its completion, a later transition checks current rules and creates one new attempt for the uncompleted occurrence. Duplicate or concurrent transitions reuse that retry. An attempt that failed with `integration-required` retries the same way, and Domino admits it again on its own once the integration becomes ready. Evaluator, staff and revoked-occurrence rejections do not retry. Freeing a revoked occurrence also permits one new attempt if the current rules allow it. > > Members read these decisions with `relay.quests.outcomes()` alongside tracker outcomes. Prerequisite outcomes have an `automatic` ID and null `tracker`, `record`, and `observedAt`; they are never member-claimable. Tracker activity still checks completion prerequisites at the record's occurrence time. > > Recovery selects at most 50 paused outcomes, or admitted outcomes whose attempt has status `rejected` and code `budget-exhausted`, or status `failed` and code `integration-required`. The preview shows new evaluations, existing attempts, declared rewards, each budget's remaining capacity and its fixed or dynamic cost. Confirmation checks the preview against current rules before admitting. Admission reserves no budget: rewards and budget consumption are decided when evaluations settle. ## Where next [#where-next] - [Your first quest](/docs/build/first-quest) — Start here to author and publish a quest. - [Test quests locally](/docs/build/testing) — Unit-test an evaluator before you publish it. - [Compose a campaign](/docs/composition) — Connect quests with points, referrals, and a leaderboard. - [Manage quests](/docs/use/quests) — Run quests from Console. - [UGC programs](/docs/concepts/ugc) — Quests that qualify and reward creator content. - [Authoring](/docs/reference/authoring) — Configuration details. # Referrals (/docs/concepts/referrals) A referral program rewards members for bringing friends. You decide when an invited friend counts and what the inviter, the friend, or both earn. For example: * **"Invite a friend, you both get 100 XP"** once the friend signs up. * **A friend counts only after their first quest**, so empty sign-ups earn nothing. * **A bonus at 5 and 10 friends**, on top of the per-friend reward. * **10% of the XP your friends earn** for their first month, paid to you as extra points. Each member has at most one inviter in a project and environment. ```mermaid flowchart LR I["Invitation code"] --> A["Attribution"] A --> Q["Qualification"] Q --> F["Fixed rewards and milestones"] Q --> B["Percentage bonuses"] ``` ## What members see [#what-members-see] A member shares their invitation code or link. The friend opens it, signs in, and accepts the invitation; Domino records who invited them. The inviter can see how many friends they've invited, how many qualified, and which milestones they've reached. Rewards follow once a friend qualifies. Where [member profiles](/docs/build/profiles#compose-a-profile-page) are visible, a signed-in member can see another member's progress in a program: how many friends they invited, how many qualified, and which milestones they reached. It never shows referral codes or accounts. ## Attribution and qualification [#attribution-and-qualification] Inviting and qualifying are two steps. Your app carries the invitation code through sign-in and accepts it; Domino checks it and records the inviter. The friend then **qualifies** under the program's rule: | Qualification | The invited friend qualifies when… | | ------------- | ------------------------------------------------------------- | | Signup | They sign up and accept the invitation. | | Named quest | They complete the named quest. | | External | Your server records that they qualified, after its own check. | One quest completion can do several jobs at once: pay the friend points, unlock their next quest, and qualify the referral. The quest checks the activity; the referral program rewards the invitation. ## Rewards and corrections [#rewards-and-corrections] | Reward | What it grants | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Fixed rewards | Points or tier rewards for the inviter, the invited friend, or both. Badges and group memberships come from quests. | | Milestones | Rewards for reaching a number of distinct qualified friends. | | Percentage bonuses | A share of the points the friend earns in one ledger, optionally only from the quests you choose, and optionally for a set number of days or up to a cap. [Engagement](/docs/concepts/ugc#engagement-credits-growth) payouts of a covered quest count too. The inviter's bonus is extra: it never reduces the friend's own award. | Bonuses don't stack on bonuses. Fixed referral rewards and bonus payouts don't trigger further bonuses, and points from a [leaderboard prize](/docs/concepts/leaderboards#prize-settlements) earn none. Where [member profiles](/docs/build/profiles#compose-a-profile-page) are visible, a signed-in member can see another member's progress in one program: how many friends they invited, how many qualified, and which milestones they reached. It never shows referral codes or accounts. > [!DEV] > A quest's `evaluate` reads the same progress for the member it evaluates, as inviter, with [`ctx.member.referrals(program)`](/docs/reference/verification#evaluator-readers-null-errors-and-pinning). Things can change after a reward is paid: * **Corrections** can withdraw a friend's qualification and take back the rewards it paid. * **Revoking the qualifying completion** moves a named-quest qualification to another accepted completion of that quest, or unqualifies the friend and reverses the inviter's reward. See [revoke a completion](/docs/concepts/quests#revoke-a-completion). * **Pausing a member's referral earnings** keeps who invited whom and stops future payouts. * **Resuming** doesn't pay what was missed while paused. A [member import](/docs/use/imports#what-an-import-records) can bring over who invited whom from another platform, with the programs each friend already qualified in. Imported referrals pay no fixed reward, milestone or bonus at import; they count toward the inviter's qualified friends, and the friend's later points pay the bonus. > [!IMPORTANT] Referral earnings can change > Because of corrections and pauses, don't present referral earnings as final. > [!DEV] From the CLI > `domino referral-programs list` lists published programs, `domino referral-relationships list` pages relationships with their qualifications and the rewards waiting for review, and `domino referral-relationships history MEMBER` pages one member's referral points. `domino referral-programs qualify PROGRAM MEMBER --reason "…"` qualifies a member after an external check (`--disqualify` withdraws it), and `domino referral-rewards resolve ENTITLEMENT --reason "…"` marks a held reward reconciled. Changes need `publish`. > [!DEV] Notify your backend > When a referral qualification becomes active, the `referral.qualified` [webhook event](/docs/reference/webhooks) can notify your backend. It identifies the program, member, inviter, and any associated completion. ## Where next [#where-next] - [Publish a referral program](/docs/build/referrals) — Configuration, acceptance, testing, and corrections. - [Compose a campaign](/docs/composition) — Connect a program to quests and a leaderboard. ## Qualification with counters [#qualification-with-counters] A friend can be required to do several things before they count, such as three verified actions, without a separate referral counter. Use a quest that completes after three actions, and name it as the program's qualifying quest. Only that quest's awarded completion qualifies the referral. Rejected actions, unfinished counts and achievements a budget refused don't. > [!DEV] > Define a quest with `completion: progress({ distinct, goal: count(3) })`, then pass its typed handle to `questCompleted(qualificationQuest)` in `defineReferral`. The `qualified-referrals` snippet connects a custom activity tracker, a lifetime qualification quest and inviter/invitee rewards. Install it into a project that already has the SDK and CLI, then follow its `docs/snippets/qualified-referrals.md` to register the tracker, program and ledger: ```sh pnpm dlx shadcn@4.21.0 add https://snippets.domino.run/r/qualified-referrals.json ``` The snippet also includes a continuous Discord membership alternative: a manual claim checks the current server `joinedAt`, accepts after seven days, and rejects early claims with a not-yet-eligible reason. Leaving and rejoining starts a new interval. The member submits again later to capture fresh evidence; this example does not run a seven-day defer loop. Continuous server membership does not establish continuous role possession or screening status throughout the interval. ## Archive a referral program [#archive-a-referral-program] Archive a program with **Archive referral** on its card in **Referrals**, or in **Settings → Resources**, when it should stop qualifying new referrals and paying bonuses. Its relationships, past rewards and history stay readable, and corrections can still reverse earlier rewards. Members see the program as disabled. Unarchive it to start qualifying and paying again; archiving never erases accepted invitations. See [resource lifecycle](/docs/concepts/quests#resource-lifecycle). ## Pause a member's referral earnings [#pause-a-members-referral-earnings] Pause one member's referral earnings, with a reason, when you need to look into them. Their invitations stay; new payouts stop until you resume. > [!DEV] > Use `management.referrals.pause(member, { reason })` or `domino referral-relationships pause MEMBER --reason TEXT` to suspend the relationship. Use `resume` with a reason to restore it. These commands retain the existing `publish` permission and audited operator history. `referrals.suspend({ suspended })` remains a deprecated compatibility method. # Rewards (/docs/concepts/rewards) A reward is what a member gets for taking part. Most rewards come from completing a quest; members can also buy some with their points. For example: * **50 XP** for answering a quiz. * **An "OG" badge** for joining in the first week. * **A Discord role** that opens a members-only channel. * **A tote bag** collected at your booth, or bought for 500 points. A quest's rewards go to the member who completes it. A [referral program](/docs/concepts/referrals) can reward both the inviter and the friend they invited. ```mermaid flowchart LR C["Qualifying result"] --> P["Points award"] C --> E["Tier entitlement"] C --> R["Role delivery request"] O["Offer redemption"] --> S["Points spend"] O --> E E --> H["Staff handover"] E --> A["Automatic fulfillment"] R --> G["Role granted, with retries"] ``` ## Reward kinds [#reward-kinds] | Reward | What the member gets | Granted by | | ---------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------- | | Badge | An achievement they hold once, however many quests award it. | Quests and reviewers | | Group membership | A place in a [group](/docs/concepts/groups), kept while the completion stands. | Quests only | | Points | Points in one of your [point ledgers](/docs/concepts/points). | Quests, referrals and leaderboard prizes | | Tier reward | A prize, such as merch or a ticket, with levels it can improve through before they collect it. | Quests, referrals and leaderboard prizes | | Discord role | A role in your Discord server, granted by Domino's bot. | Quests only | | Offer | A tier reward bought with points, from stock you set. | The member, by redeeming it | Offers sell tier rewards. Referral programs and leaderboard prizes pay points and tier rewards. Badges, group memberships and Discord roles come only from quests (and reviewers, for badges): to give one for a referral or a leaderboard place, reward it from a quest instead. See [referrals](/docs/concepts/referrals) before choosing which one owns an award. ### Match tiered rewards in your app [#match-tiered-rewards-in-your-app] Reads name a tiered reward by its stored ID, not the key you gave `defineTieredReward`. An entitlement's `reward.id` is `reward_…` even when the source says `key: "tote"`. 1. Read the project's tiered rewards with `relay.tieredRewards.list()`, or `useTieredRewards()` in React, whose `data` holds the list. Each entry has `id` and `key`. 2. Find your reward by key, then compare IDs: `rewards.find((r) => r.key === "tote")?.id`. 3. Show the reward's `title` from the read itself, never its ID. Quest views already carry the key: a tier reward has `reward: { id, key, title }` and the `tier` it grants. ### Retire a tiered reward [#retire-a-tiered-reward] When a tiered reward is done, such as merch from a finished campaign, archive it so nothing new hands it out. In test, open **Settings → Resources**, choose **Archive tiered reward** on it and give a reason, then promote to live. * **Nothing new can grant it.** A deploy that would make a quest, offer or leaderboard prize grant it fails until you unarchive it or remove it from that code. * **What already grants it keeps working.** A quest, offer or prize that granted it before you archived it keeps granting it, a new version of it included. To stop those grants too, archive them. * **Members keep what they earned.** Rewards they earned can still be handed over, and their apps still show them. * **Console stops listing it.** **Rewards** leaves it out of its rewards, but still shows the ones members earned. * **Its key stays taken.** A new tiered reward with the same key must have the same definition. Give a replacement its own key. **Unarchive tiered reward** brings it back. Badge and group membership rewards aren't listed here: archive the badge or group instead. > [!DEV] > `domino tiered-rewards list` lists them with their `archivedAt`, and `domino tiered-rewards archive|unarchive KEY --reason "…"` archives and restores one in test, which needs `publish` permission. `management.resources.list()` returns them in `tieredRewards`, and `management.tieredRewards.archive(key, { reason })` and `unarchive` archive and restore one. The commands and methods are experimental and can change in a minor release. A deploy or promotion that would newly grant an archived one fails with 422 `tiered-reward-archived`, naming it in `reward`. ## Completion and delivery [#completion-and-delivery] Completing a quest and receiving its reward are two separate moments. Points arrive with the completion. A Discord role or a tote bag can take longer. * **Discord roles arrive on their own.** Domino records the role request with the completion, then keeps trying to grant it. It always goes to the Discord account and server the member had when they earned it. If you [revoke the completion](/docs/concepts/quests#revoke-a-completion), Domino removes the role, unless something else still earns it. Domino only removes roles it granted itself; roles members already had, or that you chose to keep, stay. * **Physical rewards wait for pickup.** Earning a tote bag and handing it over are separate steps. A collection pass lets staff look the member up and start the pickup; confirming they took it is a separate action. * **Digital rewards can be collected at once.** With automatic fulfillment, Domino marks the reward collected the moment it's granted, with no staff step. * **Buying with points is all or nothing.** Domino holds one unit of stock and the member's points before granting the reward. A failed purchase spends nothing and takes no stock. * **Promised rewards stay fixed.** Publishing new quest rules does not replace the reward an existing attempt was promised. Keep reward identity stable when updating source. > [!IMPORTANT] Completed does not mean delivered > A completed quest does not mean the role is already in place. Show delivery separately from completion in your app. > [!DEV] Role delivery status > The original delivery outcome is history. Its optional `reconciliation` reports current membership: `pending`, `granted`, `removed`, `preserved` or `failed`, with ownership `unclaimed`, `relay` or `external`. A revoked grant can still report `granted` when another accepted completion justifies the role. Operators can preserve an independently owned assignment and retry a failed reconciliation in [Console or the management API](/docs/build/integrations#recover-or-preserve-a-role). How Domino reconciles roles and orders point refunds is in [reward delivery internals](/docs/reference/authoring#reward-delivery-internals). > [!NOTE] Two kinds of collection > A reward collection pass is unrelated to a [collection](/docs/concepts/collections) of quests. See [Discord role rewards](/docs/reference/authoring#discord-role-rewards) for authoring and the [participant API](/docs/reference/participant-api) for progress and delivery reads. > [!DEV] Notify your backend > Operator backends can subscribe to [webhook events](/docs/reference/webhooks) for completion, terminal external delivery failure, and confirmed entitlement collection. These notifications represent separate business changes and may arrive out of order. ## Offers [#offers] An offer lets members spend points on a reward, like a shop shelf. You set the price, how many there are, and how many one member can buy. Members see whether an offer is available, paused, sold out, or already at their limit. * **Each purchase grants one reward.** Rewards that need staff wait for pickup; automatic ones are collected straight away. * **Pause without editing.** You can pause an offer at any time without changing it. * **Retire an offer by archiving it.** In test, choose **Archive offer** on its card in **Rewards** or in **Settings → Resources**, then promote. Members stop seeing it and can't buy it. Its past purchases stay as they are and can still be canceled. Console labels it Archived, and **Unarchive offer** brings it back. Deleting an offer from your code doesn't archive it. * **Sell only to some members.** An offer can require what quests can: a completed quest, a minimum balance, or membership of a group. Members who don't qualify still see the offer, along with what they're missing, and can't buy it until they meet every requirement. Console shows an offer's requirements on its card. The loyalty starter works this way: its members-only offer unlocks once a quest's group membership reward adds the member to its members' group. * **Cancel with a refund.** Canceling a fulfilled purchase refunds the points the member paid, returns one unit to stock, and voids the reward. Canceled purchases stay visible in Console. > [!DEV] > Source declares the offer's stock and per-member limit. Redemption creates one durable record and one entitlement. From the CLI, `domino offers list` reads offers and `domino redemptions history [--member MEMBER] [--cursor CURSOR]` reads redemptions newest first, a page at a time, with their revisions, and `domino redemptions cancel REDEMPTION --expected-revision N --reason "…"` cancels one at the revision you read. Canceled redemptions remain visible in management reads too. An offer's `requires` takes `completed`, `balanceAtLeast` and `inGroup`; redemption refuses a member who misses one with 409 `prerequisites-incomplete` and `missing`, spending nothing, and member offer reads list what they lack in `missing`. `domino offers archive OFFER --reason "…"` and `unarchive` archive and restore one in test; management reads show `archivedAt`, and redeeming an archived offer fails with 409 `offer-paused`. In test, `domino test-members redeem MEMBER OFFER` (`management.testMembers.redeem`) redeems as a [test member](/docs/use/quests#try-quests-with-test-members), with the same price, stock, limit and refusals, so you can rehearse an offer without a member session. See [offers](/docs/reference/authoring#offers) for authoring. ### Keep an uncertain redemption pending [#keep-an-uncertain-redemption-pending] An offer redemption spends points and consumes stock once for its action ID. If the answer is lost, a new ID represents another purchase. Keep the original ID and payload until the result is known. `relay.action()` and `useRedeemOffer()` retain them and offer a replay-safe retry; the helper is kept in memory, so recovery across reloads needs a persisted key. See [Retry after an uncertain answer](/docs/reference/participant-api#retry-after-an-uncertain-answer). ## Correct a tiered reward [#correct-a-tiered-reward] A tier reward moves from `available` to `handing-over` when staff start a handover, and to `collected` when they confirm it. Your team can undo each step, void the reward, or restore a voided one, with a reason. Each correction acts on the version the operator saw, so a reward that changed in the meantime is refused until they reload it. | Correction | From | To | Permission | Effect | | ------------------ | -------------- | --------------------- | ---------- | ------------------------------------------------------------------------------------------------------------ | | Cancel handover | `handing-over` | `available` | `handover` | Releases the handover. A pickup size it took goes back into stock. | | Void reward | `available` | `void` | `review` | The member can no longer collect it. Settles any referral review on it. | | Restore reward | `void` | `available` | `review` | The member can collect it again at the tier they still earn. Brings back a referral review the void settled. | | Correct collection | `collected` | `available` or `void` | `review` | Returns a pickup size to stock. A recovery flag with no remaining grant makes it void. | **Needs recovery.** If you revoke a completion after the member already collected its reward, Console shows **Needs recovery** in Rewards and explains why in the reward and member details. The item still counts as collected until you resolve it. Get the item back, or fix a mistaken collection, then choose **Correct collection** with a reason. The correction either voids the reward or makes the tier the member still earned available, and records it in history. > [!DEV] Recovery record > Revoking a completion can leave a collected entitlement with a `recovery` record: the revoked completion, reason, flag time and `remainingTier`. The flag stays after a reload. If `remainingTier` is null, **Correct collection** voids the reward; otherwise it makes the remaining tier available. The correction clears the flag and records its resolution in history. A later legitimate grant can update the remaining earned tier but does not silently clear the recovery flag. A voided reward stays in the member's rewards with status `void` and the time it was voided. If a quest grants the member the same reward again, it becomes `available` as a fresh grant at that quest's tier. A voided referral reward stays void until you restore it. **Restore reward** undoes a void your team made. It can't undo a void that something else made, so the reward can't drift from the cause: * **A revoked completion voided it.** Restore the completion instead. * **A completion revoked after the void earned it.** Restoring gives back the tier another accepted completion still earns. If none does, restore the completion instead. * **Canceling an offer redemption voided it.** The redemption's price went back to the member, so the reward stays void. * **A referral reward voided before restore existed.** Its referral review wasn't kept, so it stays void. In Console, open the member and find the reward under **Earned rewards**. A project with in-person pickup also lists every earned reward in the **Earned rewards** tab on **Rewards**. **Audit history** lists each correction under **Rewards** with its reason and who made it. These are corrections to member state, so they work in live too. > [!DEV] > A script uses `handover.cancel`, `entitlements.void`, `entitlements.restore` and `entitlements.correctCollection` in the [management client](/docs/reference/management#management-client), or `domino entitlements restore --expected-revision --reason ` from the CLI. An API key with the permission can make them in live. A restore that is refused answers 409 with code `entitlement-status`; a refusal for a revoked completion also names the `completion`. ## Inventory corrections and retirement [#inventory-corrections-and-retirement] Your team can rename a stock item, correct how many are left, archive it and unarchive it. Use Console **Rewards** → **Pickup & stock** → **Inventory items**, including stock owned by offers, and choose **Rename item**, **Correct stock**, **Archive item** or **Unarchive item**. A project with no rewards handed over in person has no tabs, and lists **Inventory items** on **Rewards** itself. * **Stock already promised stays.** A correction cannot remove units already reserved or redeemed. Pickup labels already assigned stay the same when you rename the item. * **Archive only unused items.** You can't archive an item while an offer or pickup uses it, including a paused or archived offer, so an item behind an offer stays as it is; archive the offer instead. For pickups, move the pickup to another item first. Existing handovers and their history stay available. * **Live takes stock corrections only.** Creating, renaming, archiving and restoring an item, and pickup setup, are definition changes, so they reach live by [promotion from test](/docs/concepts/environments). > [!DEV] > Archiving refuses with `inventory-in-use` while any offer or pickup variant references the item; the refusal detail names the blocking offers and pickups. Pickup configuration refuses an archived item with `inventory-archived`; unarchive it before using it again. Live refuses definition changes with `promotion-required`. Management scripts use `inventory.rename`, `inventory.adjustStock`, `inventory.archive` and `inventory.unarchive`, with the item's current `expectedRevision` and a reason. Read current fences with `inventory.items({ cursor, limit })`. Its `{ items, nextCursor }` page includes archived and offer-backed items; pass `nextCursor` back as `cursor` until null. The corresponding CLI commands are `domino stock-items rename`, `adjust-stock`, `archive` and `unarchive`. ## Participant reward reads [#participant-reward-reads] Your app can show members what they earned, whether it's waiting for pickup, collected, or voided, and whether a Discord role has arrived. > [!DEV] > Members read their earned tiers with `relay.rewards.entitlements()` or `useEntitlements()`. The response includes collection state and pickup allocation when assigned. `useExperience().data.entitlements` includes the same entitlements with the quest snapshot. Available, handing-over, collected and voided states remain distinct; collection recovery stays visible until an organizer resolves it. > > `relay.rewards.deliveries()` and `useDeliveries()` combine point awards, current tier entitlements and integration delivery states in an `items` list. Point deliveries show the awarded and reversed amounts. Tier deliveries describe the current earned reward, not a separate fulfillment for each tier improvement. Integration deliveries use current reconciliation state when available, so a role removed after revocation does not appear granted because its original delivery succeeded. Unknown kinds and states remain readable. These member-only reads do not grant or fulfill rewards. ## Badges [#badges] A badge marks an achievement, like "Early supporter" or "Attended the meetup". It has a name, a description and an image. A member holds each badge once, even when several quests award it. Badges have no built-in levels: if you want bronze, silver and gold, your app builds them from badge keys, points or ledgers. | Action | Result | | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | | Complete a quest with a badge reward | The member gets the badge with the quest's other rewards. | | Revoke that completion | The badge goes, unless another accepted completion or a manual award still earns it. | | Manually award a badge | A reviewer gives the badge, with a reason that's kept in the audit history. | | Manually revoke a badge | A reviewer takes the badge away, with a reason. A later legitimate completion may award it again. | | Archive a badge | It disappears from the catalog and new reward choices. Members keep it, and published quest rewards keep working. | Manage the catalog in Console **Rewards**. **Archive badge** and **Unarchive badge** sit beside each badge and ask for a reason. In a Console quest's editor, choose **Add badge**. Reviewers award and revoke badges in the member drawer. Live badges come from a promotion from test; awards and revokes work in live. A member you [erased](/docs/use/member-erasure) keeps their badges, and neither works for them. > [!DEV] > Define badges with `defineBadge` and attach them to quest rewards with `badge`. Each catalog definition has a stable key. Definition changes require `publish` permission; manual awards and revokes require `review` permission. Live refuses definition changes from Console and the API with `promotion-required`. Read the active catalog with `relay.badges.catalog()`, which answers `{ items }`, and the signed-in member's awards with `relay.me.badges()`. `relay.profiles.badges(profileId)` applies the same project and member visibility rules as other profile reads. Member reads return `items`, each containing `badge` and `awardedAt`; the badge has its stored `id` and authoring `key`. Match keys for application rules and show names to members. Offers, referral programs and leaderboard prizes cannot carry a badge: `defineOffer`, `defineReferral` and `defineLeaderboard` throw `Badge rewards can only be granted by quests or manually`, and a deployment refuses one with 422 `validation-failed` and the same message. See [authoring](/docs/reference/authoring#badges) and [participant reads](/docs/reference/participant-api#badges). ## Group membership [#group-membership] A group membership reward adds the member to a [group](/docs/concepts/groups) when they complete the quest. It's the way to run an approved program: members apply through a quest your team reviews, approval puts them in the "Approved creators" group, and the program's quests are open only to that group. Approving the application is the one step that lets someone in. ```ts import { defineGroup, groupMembership } from "@domino-sdk/relay/authoring"; export const creators = defineGroup({ key: "approved-creators", title: "Approved creators" }); // In the application quest's definition: const rewards = [groupMembership(creators)]; ``` | Action | Result | | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Publish a quest with the reward | Creates the group, with no members and the title your code gives it, if the environment doesn't have it: deploying creates it in test, approving the promotion in live. An archived group is refused, with 422 `validation-failed` in a deployment. | | Deliver the completion's rewards | Add the member, recording the quest and completion as the reason in **Audit history**, and check the prerequisite-only automatic quests they can now enter. Someone already in the group stays as they were. | | Revoke that completion | Remove the member unless an operator's add or another unrevoked completion also keeps them in the group. An archived group keeps them. | | Remove the member in Console **Groups** | Removes them whatever added them, as for any member. | | Archive the group | Refused with 409 `group-in-use` while a quest that is not archived rewards it. | > [!DEV] > The program's quests require `inGroup` of the group. Participant quest reads show the reward as a `tier` reward whose `label` is the group's title and whose `reward.group` is `{ key, title }`, with the title the code's handle gives. It never creates a tiered-reward entitlement. Offers, referral programs and leaderboard prizes cannot carry it: `defineOffer`, `defineReferral` and `defineLeaderboard` throw `Group membership rewards can only be granted by quests`, and a deployment refuses one with 422 `validation-failed` and the same message. See [authoring](/docs/reference/authoring#group-membership-rewards). # Trackers (/docs/concepts/trackers) A tracker watches for activity outside Domino and keeps a record of it, so your quests can reward it. For example: * **Posts on X** that mention your launch hashtag. * **Messages and reactions** in your Discord server. * **Messages and reactions** in your Telegram group. * **YouTube Shorts** your members post on their own channels. * **TikTok videos** your members post on their own accounts. * **Token balances** in the wallets your members linked, sampled once a day. * **On-chain events or game results** your own server sends in. Collecting is the tracker's only job. It keeps what it finds in a dataset for one project and environment, separate from members, quests and rewards. A quest then decides what counts and who earns what. ```mermaid flowchart LR X["X search"] --> T["Tracker"] G["Discord server"] --> T C["Your indexer"] --> T T --> D["Dataset"] D --> Q["Quest evaluator"] D --> S["Your server"] ``` ## What members see [#what-members-see] Members don't interact with a tracker directly. They link their X, Discord or Telegram account, their YouTube channel or TikTok account, or their wallet, and take part as usual: post, react, play. When a quest rewards that activity, they see the quest complete. When it doesn't, your app can show them why, such as "No member has linked this X account", and for some quests they can claim activity from before they linked. Domino only ever matches activity to a member through an account they proved they own. A username or a verification badge is never enough. ## Words on this page [#words-on-this-page] | Term | Meaning | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Tracker | A published definition: a stable key, a source, and a collection window. | | Dataset | The records a tracker has collected. | | Record | One collected item, such as an X post, a Discord reaction, or a chain event, with occurrence, first-seen, and observation times. | | Record data | The JSON object a record carries: the provider's post for X, a fixed shape for Discord, and for a custom source the shape its schema declares. A record stored before the schema changed that no longer matches it never reaches a quest as typed data. | | Content revision | The stable body of an X post: text, media, references, and author ID. | | Observation | One sample of a record at one time: its data, accounts, and removal status, and for an X post its engagement counters and author profile and verification. | ## Sources [#sources] | Source | Who collects | | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | X search | Domino polls the search on a schedule and refreshes posts it already knows during their first week. The X credential stays with the platform. | | Discord messages, Discord reactions | Domino's Discord gateway records each new message or reaction in the server connected to the project, as it happens. It keeps a record only when one of its accounts is a Discord account a member of the project has linked. Message edits update text when the tracker keeps it. Message deletes remove the record and erase its text. | | Telegram messages | Domino's Telegram bot records each message a person posts in the groups connected to the project, as it happens, without its text. It keeps a record only when the poster is a Telegram account a member of the project has linked. Bots', service and channel messages are never recorded, and a deleted message stays recorded. | | Telegram reactions | Domino's Telegram bot records each reaction added in the groups connected to the project, as it happens. It keeps a record only when one of its accounts is a Telegram account a member of the project has linked. A removed reaction removes the record. | | YouTube Shorts | Domino reads the Shorts on each YouTube channel a member of the project linked as a [creator account](/docs/concepts/participants#creator-accounts), with that member's permission, on a schedule. It finds new Shorts and refreshes the views, likes and comments of the ones it knows. A Short that is deleted or made private is removed. A channel that is unlinked is no longer read; its records stay. | | TikTok videos | Domino reads the public videos on each TikTok account a member of the project linked as a [creator account](/docs/concepts/participants#creator-accounts), on a schedule, only ever with that member's own permission. It finds new videos and refreshes the views, likes, comments and shares of the ones it knows. A video the account no longer returns, for example because it was deleted, is removed. An account that is unlinked, or whose access needs reconnecting, is not read; its records stay. | | Wallet balances | Domino reads the balance of one token in every wallet linked by a member who joined the project, once per project-calendar day, at a time only Domino knows, every wallet at one block on EVM and Cosmos chains (on Solana, every read reaches at least the slot the sample started at). Each wallet is one record, and each day's sample is a new observation of it. A day whose reads all fail has no sample: Domino never records a balance it didn't read. An unlinked wallet is no longer read; its records stay. | | Custom source | Your server or indexer sends records in. It owns decoding and finality, including blockchain reorganizations. The source declares the shape of its records, and Domino refuses a batch with a record that does not match it, naming the record and the field. | > [!DEV] Custom sources > Custom records are sent with a management key that has `ingest` permission, such as a key limited to ingestion, or a hosted app's own key. See [custom source ingestion](/docs/build/trackers#feed-another-source-including-a-blockchain-indexer). ## Collecting is not qualifying [#collecting-is-not-qualifying] * **Every match is kept.** An X or custom tracker keeps unverified authors and people who have not joined your project. Apply filters, such as `verified` or `member`, when you read. Discord, Telegram, YouTube, TikTok and wallet balance trackers are the exception: they keep only activity that involves a member's linked account. * **A tracker has no reward policy.** Collecting never awards anything. A quest that declares a [tracker trigger](#tracker-triggers) evaluates the record observations it selects for the member behind one of the record's accounts. * **Each rule decides.** Quests and [engagement rules](/docs/concepts/ugc) read the dataset, and each can qualify the same post differently. * **Quests read a fixed moment.** When a quest checks a tracker, it sees the records as of when the activity happened, and a retry sees the same answer. See [maintained tracker datasets](/docs/reference/trackers#maintained-tracker-datasets) for how those reads are captured. ## Attribution [#attribution] Each record says who took part, in roles: who wrote the post, who it replied to, who reacted. Domino pays the member behind one of those accounts, and only when that member has linked and verified the account. The tables below list the roles and account kinds each source has. | Source | Roles | Account kinds | | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | | X search | `author` (who wrote the post), `inReplyTo` (whose post it replies to), `quoted` (whose post it quotes) and `reposted` (whose post it reposts), from the captured post | Stable X user IDs | | Discord messages | `author` (who posted the message) | Stable Discord user IDs | | Discord reactions | `author` (who reacted) and `messageAuthor` (who wrote the message), when Discord names the message's author | Stable Discord user IDs | | Telegram messages | `author` (who posted) | Numeric Telegram user IDs | | Telegram reactions | `author` (who reacted) and `messageAuthor` (who wrote the message), when the bot saw the message | Numeric Telegram user IDs | | Wallet balances | `author` (the sampled wallet) | CAIP-10 wallet accounts on the sampled chain | | Custom source with `identities` | `author`, plus the named accounts the source declares in `accounts`, such as `recipient` | The declared identity kinds | | Custom source without `identities` | None: the author is an ID your code chooses, attributed to nobody | — | | Identity kind | Account ID | Attributed to | | ------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `wallet` | A canonical CAIP-10 account ID, such as `cosmos:cosmoshub-4:cosmos1…` | The member who currently links that wallet in the project | | `x` | A stable numeric X user ID | The member whose selected, verified X account has that ID | | `discord` | A stable numeric Discord user ID | The member whose selected, verified Discord account has that ID | | `member` | A Domino member ID | That member, unless it was [erased](/docs/use/member-erasure): then no one | | `app:` | Your app's own user ID, such as a Telegram or game account ID | The member your [app login](/docs/build/app-login#link-your-apps-user-ids) integration of that kind signed in with that ID in the project | > [!DEV] > Trackers with identities refuse a record without an author, an account of an undeclared kind or role, and an ID in any other form, such as an X handle or a non-canonical wallet address. ## Tracker triggers [#tracker-triggers] A quest can turn each new record into a chance to earn: every new post with your hashtag, every reaction to your announcement, every game your server reports. You choose which records count, who gets paid, whether members must join the quest first, and what happens to activity from before a member linked their account. In code, a quest with `automatic({ tracker })` turns tracker records into attempts. These choices compose it: | Choice | Options | | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `on`: which observations fire | `"new-record"`: a record's first observation in this tracker (the default for X and Discord trackers). `"new-observation"`: every observation that becomes a record's current one (the default for custom trackers, which in SDK 1.x include Telegram, YouTube Shorts, TikTok and wallet balance trackers: each refresh or daily sample fires again unless you set `on`). `"data-changed"`: a new record, or a current observation whose `data` differs from the previous one. | | `where`: which data qualifies | Optional. A condition on a path in the record's `data`, or a list that must all hold: `equals` a string, number, or boolean, or compare a number or an integer string with `gt`, `gte`, `lt`, and `lte`. A bound can name one of the quest's integer settings. Paths and values are checked against the tracker's record data. | | `beneficiary`: who it pays | The record account whose member the attempt belongs to: `"author"` (the default), `"inReplyTo"`, `"quoted"` or `"reposted"` on an X tracker, `"messageAuthor"` on a Discord reactions tracker, or a named account of a custom record. | | `enrollment`: who takes part | `"none"` (the default): every member who has joined the project. `"required"`: only members who enrolled in the quest. | | `since`: the earliest activity counted | `"activation"` (the default): records that occurred since the quest was published or last unarchived. `"window"`: every record in the tracker's window. `"enrollment"`, with required enrollment: records that occurred since the member enrolled. | | `historical`: who releases historical activity | `"staff"` (the default): only a reviewer's retry. `"claim"`: also the member, who claims it. `"automatic"`: Domino admits it like any other activity. | ```mermaid flowchart LR R["Record observation"] --> F{"Fires and matches?"} F -- no --> N["Nothing recorded"] F -- yes --> A{"Attributed and eligible?"} A -- yes --> T["Attempt for the beneficiary's member"] A -- no --> S["Skipped, with a code and reason"] T --> E["Quest evaluator decides"] ``` * **Firing and filtering are not decisions.** An observation the mode or filter does not select records nothing and costs no attempt. Older observations only add history. * **Every decision is recorded.** Each selected observation gets exactly one trigger outcome: an admission, or a skip with a stable `code`, such as `unlinked-account`, and a `reason` for people, such as "No member has linked this X account". A `once()` quest the member already completed records `already-completed`. Operators see outcomes in Console and through the management API; members read their own with `relay.quests.outcomes()`. * **A record never pays its author's member through another role.** When the beneficiary account is the record's author account, such as a reply to one's own post or a reaction to one's own message, or both accounts belong to the same member, the observation is skipped. * **A skip waits for the event that clears it.** Domino decides a skip again, under the quest's current rules, only when the member event that clears its code happens: linking the account clears `unlinked-account`, removing a conflicting link clears `ambiguous-account`, joining the project clears `not-joined`, and enrolling in the quest, or in its campaign, clears `not-enrolled`. Every other skip is final unless staff with `review` permission retry it. A [banned member](/docs/use/member-bans)'s records are skipped with `member-banned`, which is never decided again: an unban counts only what they do afterwards, and Console's trigger outcomes say there is nothing to do. * **Activity is never backfilled by publishing.** With the default `since`, observations that occurred before the quest was published are skipped. * **X posts pay approved creators.** X does not allow apps that reward users for posting, so a quest that pays an X post's author, with `automatic({ tracker })` or `content({ tracker })`, needs an `inGroup(group)` prerequisite naming a [group](/docs/concepts/groups) of creators the operator approves. Posts by anyone else are skipped with `prerequisites-incomplete`. A deployment that publishes such a quest without the prerequisite warns with `x-post-rewards-ungated`. * **The attempt keeps what it saw.** Once a record starts an attempt, later updates or the member unlinking the account don't change it. Quest code cannot redirect who it pays. See [tracker triggers](/docs/reference/trackers#tracker-triggers) for what the evaluator receives. ### Historical activity [#historical-activity] Activity from before a member joined in is **historical**: by default it doesn't pay automatically, so linking an old account can't sweep up months of past posts. Activity is historical when it occurred before the member linked the account it pays, or before the member enrolled. A link time Domino does not know, such as a Discord connection made before selections existed, counts as later than the activity. Accounts of kind `member` are never historical: the server that ingests the record vouches for the member. A linked account or an enrollment decides earlier skips again, and historical activity then becomes a `historical` skip that names the member. The quest's `historical` policy says who releases it: | Policy | What happens to historical activity | | ------------- | ---------------------------------------------------------------------------------- | | `"staff"` | It stays skipped until a reviewer retries it. | | `"claim"` | The member can claim it with `relay.quests.claim()`. Their outcome is `claimable`. | | `"automatic"` | It is admitted, including when the member links the account or enrolls later. | A retry or claim releases only historical activity: every other rule still applies. Activity that occurred before the member completed a prerequisite never counts. ## Revisions and observations [#revisions-and-observations] Domino keeps history rather than overwriting a record. An edit to a post's body creates a new content revision. A refreshed like count or author profile creates a new observation with the same revision. Returning to an earlier body reuses that body's revision. Trackers in the same project and environment that collect the same X post share its latest facts. Each tracker keeps its own matches and first-seen times, so another tracker's discovery never adds a match to yours. > [!IMPORTANT] Ownership comes from connected accounts > A post belongs to a member when their selected, verified X account has the author's stable ID. Usernames and verification badges never establish ownership. ## Coverage and changes [#coverage-and-changes] * **Complete coverage is not a census.** It means a collection cycle exhausted the provider's results and refreshed known posts. It does not prove the dataset holds every matching post. * **Changes do not backfill.** Changing a query or window keeps collected records and discovers new posts from publication time. Use a new tracker key for a deliberate historical collection: its first publish searches from `window.start`, even when that is in the past, as far as the provider's history reaches. * **Each cycle searches the whole window.** An X tracker searches from its discovery start to now, or to `window.end` once that has passed, on every cycle, up to `maxPages` search pages. A long, busy window can reach that limit every cycle and report `partial` coverage with a page-limit warning. * **Removal keeps data and collection.** Removing a definition from the manifest does not delete its dataset, and it does not stop collection: a deployment publishes only the trackers it lists and leaves the others as they are. Pause or archive a tracker to stop collecting. Pausing stops collection without freezing shared post facts. * **Code or data owns each key.** A tracker is code, published by a deployment from `defineTracker`, or data: a tracker of a source Domino collects itself, such as an X search or Discord reactions, created in Console or with `management.trackers.create`. Only its owner changes the definition, and a deployment never changes or removes a data tracker. Data trackers are created and changed in test, and a [promotion](/docs/concepts/promotion) carries them to live, still owned by data. A promotion never copies the Discord server or Telegram chats a tracker reads; live connects its own. See [Create a tracker without a deployment](/docs/build/trackers#create-a-tracker-without-a-deployment). * **Test first.** The CLI publishes trackers to test only. A [promotion](/docs/concepts/promotion) carries them to live once a person with publish permission approves it in Console. ## Where next [#where-next] - [Track content and events](/docs/build/trackers) — Define, preview, publish, and read a tracker. - [Reward Discord activity](/docs/build/trackers#reward-discord-messages-and-reactions) — Messages and reactions as tracker records. - [UGC programs](/docs/concepts/ugc) — Qualify tracked posts and reward their engagement. - [Quests](/docs/concepts/quests) — How evaluations become completions. - [Leaderboards](/docs/concepts/leaderboards) — An external score, such as a tracked balance, gets its own ledger. ## Tracker-scoped ingestion keys [#tracker-scoped-ingestion-keys] A personal ingestion key can carry a list of allowed custom tracker IDs. It still belongs to one project and environment, and its owner must still hold ingestion permission. The restriction applies to writes, not reads or other permissions on the same key. Choose only Ingest for an indexer that should not read project data. Existing keys without a tracker restriction keep project-wide ingestion. Operators can inspect, edit or revoke their own keys in Console **Settings → API keys**. See [custom source ingestion](/docs/build/trackers#feed-another-source-including-a-blockchain-indexer). ## Read and feed trackers from the CLI [#read-and-feed-trackers-from-the-cli] `domino trackers list` and `domino trackers get TRACKER` show each tracker's collection status (see [Collection status](#collection-status)), `domino trackers records TRACKER` pages its records newest first with the same filters as `management.trackers.records`, `domino trackers summary TRACKER` counts them, and `domino trackers outcomes TRACKER` lists its trigger outcomes. Reads need `read`. `domino trackers ingest TRACKER records.json` stores up to 100 records in a custom tracker with `ingest` permission, and `domino trackers retry-outcome TRACKER --quest QUEST --record RECORD --observed-at MS` runs admission again for one skipped record. Both are saved before they are sent, so an uncertain answer resumes with the error's `operation.resume` command. See [CLI automation](/docs/reference/automation#resource-commands). ### Collection status [#collection-status] `domino trackers get` and `management.trackers.get` return the published definition, `paused`, `archivedAt` for an archived tracker, and these collection fields: | Field | Meaning | | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `state` | `idle` between cycles, `syncing` during one, `error` after a failed cycle (retried in about a minute), `complete` once the final cycle after `window.end` has finished. | | `lastStartedAt` | When the latest cycle started. | | `lastCompletedAt` | When a cycle last finished in full. A failed or still-running cycle leaves it unchanged. | | `nextSyncAt` | When the next cycle is due, or `null` when nothing more is scheduled: the window has ended, or the source receives records as they happen. | | `coverage` | `complete` when the last X cycle exhausted the search and refreshed known posts without a warning, otherwise `partial`. It is `unknown` after the first publish, or a publish that changes the definition, until collection reports; and stays `unknown` for sources that receive records as they happen. | | `error` | The failure of an `error` cycle, or a warning from a cycle that finished, such as "X search reached the configured page limit". Read it together with `state`: a warning with `idle` or `complete` means the cycle succeeded with `partial` coverage. | | `records` | How many records the tracker holds. | | `pauseReason` | Why a paused tracker is paused, `null` otherwise: `operator`, `x-budget-exhausted` (the monthly X budget is spent) or `test-window-ended` (a test X tracker collected for 14 days). See [How often Domino reads X](/docs/build/trackers#how-often-domino-reads-x). | | `resumesAt` | When a tracker paused on its X budget resumes on its own: the start of the next UTC month. | | `effectiveRefreshSeconds` | The interval a scheduled tracker runs at: its `refreshSeconds`, but no shorter than the project's plan allows, which is six hours on every plan but Enterprise. This holds for X search, YouTube Shorts and TikTok trackers alike; only an X search tracker's deployment warns when its `refreshSeconds` is shorter. | ## Archive a tracker [#archive-a-tracker] Archive a tracker with **Archive tracker** on its page in **Trackers**, or in **Settings → Resources**, to stop collecting: scheduled X searches, Discord activity, and records your server sends. Everything already collected stays readable. Choose **Unarchive tracker** in either place to collect within its window again; a separate pause stays until you resume it. Quest checks that already read the tracker keep what they saw. See [resource lifecycle](/docs/concepts/quests#resource-lifecycle). ## Withdraw and restore quest enrollment [#withdraw-and-restore-quest-enrollment] Your team can take a member out of a quest that requires enrollment with **Withdraw enrollment** in Console member details, giving a reason. New activity stops counting for them; their existing attempts, completions and rewards stay. **Re-enroll** brings them back after confirming consent and starts a new enrollment time. Repeating an active enrollment keeps its current time. This differs from campaign enrollment, which keeps its original time through withdrawal. > [!DEV] > Withdrawing needs `enroll` permission and the revision the operator read. Use `management.quests.enrollments(member)` or `domino quests enrollments MEMBER` to read the state and `management.quests.withdraw(quest, member, { expectedRevision, reason })` or `domino quests withdraw QUEST MEMBER --expected-revision N --reason "…"` to withdraw. Re-enrollment uses the existing `management.quests.enroll({ quest, member })` or `domino quests enroll QUEST MEMBER`. It redecides `not-enrolled` skips under the current quest's history policy. ## Relink retained identities [#relink-retained-identities] If a member's account was unlinked, a reviewer can restore it with **Relink**, under the member's **Identities** or **Wallets** in Console. Activity Domino skipped because nobody had linked the account is then looked at again. > [!DEV] > Scripts use `management.members.identityStates.relink` (see [operator identity changes](/docs/concepts/participants#operator-identity-changes)). Relink redecides `unlinked-account` observations under the current release: `historical: "automatic"` may admit old activity; `"claim"` or `"staff"` keeps earlier activity historical, assigned to the named member. Stored observations and already admitted attempts stay unchanged. Wallets retain their first-link time; OAuth selections receive a fresh link time. # UGC programs (/docs/concepts/ugc) A UGC program rewards members for the content they create about you, and for how well it does. For example: * **100 XP for a launch post on X**, once your team approves it. * **A bonus once a video reaches 1,000 views.** * **One point for every ten likes**, credited each day the post keeps growing. A program is three pieces you combine: * **A [tracker](/docs/concepts/trackers)** collects the posts. * **A content quest** checks each post and rewards it once. * **An engagement rule**, if you want one, credits points every day as the post's likes or views grow. ```mermaid flowchart LR T["Tracker"] --> A["Admission"] A --> Q["Qualification"] Q --> P["Performance gate (optional)"] P --> R["One-time reward"] P --> E["Engagement rule"] E --> L["Daily points"] ``` | Term | Meaning | | ---------------------- | -------------------------------------------------------------------------------------------- | | Content item | A creator's identified post, independent of which tracker found it or which program uses it. | | Admission | Letting a collected post into one quest, for the member who owns it. | | Content qualification | One quest's decision about a post and its evidence. | | Performance stage | An optional threshold, such as 100 likes, that must also pass before the one-time reward. | | Engagement reward rule | A formula that turns growth in a qualifying post's likes or views into points. | | Credit watermark | The highest count already paid for, so the same growth is never paid twice. | > [!IMPORTANT] Reward X posts only for approved creators > X does not allow apps that reward users for posting. A content quest on an X tracker that pays, through its own rewards or an engagement rule, needs an `inGroup(group)` prerequisite naming a [group](/docs/concepts/groups) of creators the operator approves; posts by anyone else are not admitted. A deployment that publishes one without it warns with `x-post-rewards-ungated`. ## Admission [#admission] A content quest completes once per post. X search supplies each post's text and counters, [YouTube Shorts](/docs/build/trackers#reward-youtube-shorts) each Short's title, description, tags, length and views, likes and comments, from the channels members linked, and [TikTok videos](/docs/build/trackers#reward-tiktok-videos) each video's title, caption, length, size, link and views, likes, comments and shares, from the accounts members linked. Your own source can feed in videos, posts or other creator content in the same way, with its own fields and metrics. > [!NOTE] YouTube view counts changed twice > YouTube counts every play of a Short as a view from 2025-03-31, and counts every format's view when playback starts from 2026-08-24. View counts from either side of those dates are not comparable, so set view thresholds and engagement formulas for content published after them. Each newly collected post waits until all of these hold: * **Ownership.** The post's author account belongs to one member of your project, through an account they linked. * **Enrollment.** The member joined the quest, when the quest requires it. * **Dates.** The post was published within the tracker's window, and by default after the quest was published. * **Historical policy.** A post published before the member linked their account, or before they enrolled, is historical. Depending on the quest, it's let in automatically, when the member claims it, or when a reviewer retries it. * **Prerequisites and pause.** The quest's prerequisites are met and the quest is active. Posts collected before a content quest is published are not backfilled. > [!DEV] > A content quest uses `content({ tracker, ...policy })` and needs no separate completion policy. A custom source declares its own body schema and metrics. Ownership resolves through the source's declared identity kinds. `since: "activation"` is the default; `"window"` counts from the tracker window, while `"enrollment"` counts from each member's enrollment and requires enrollment. See [automatic content admission](/docs/reference/trackers#automatic-content-admission). ## Qualification follows the content item [#qualification-follows-the-content-item] * **Edits are checked again.** If the creator changes the post's text, the quest checks it again under the rules it was let in with, for the same member. New like counts or profile changes don't trigger a recheck. * **Out-of-date decisions can't pay.** Domino checks the current post and owner before applying any decision. * **One reward per quest and post.** The first accepted check pays. Later qualifying edits don't pay again, and different quests can reward the same post separately. ## Performance gates the one-time reward [#performance-gates-the-one-time-reward] A content quest can wait for the post to perform, such as 100 likes or 1,000 views, before it pays. The post has to be approved and hit the threshold. * **Fresh numbers only.** Each check reads one fresh, complete sample. Missing or old counters wait for a refresh; a real zero counts. * **It can pass later.** A post rejected at 20 likes can pass at a later sample of 150. * **Paid stays paid.** A later drop below the threshold doesn't take the reward back. > [!DEV] > Declare a `performance` stage over the source's declared metrics, such as `likes` or `views`. Content qualification and performance must both accept before the reward resolves. See [content performance gates](/docs/reference/trackers#content-performance-gates). ## Engagement credits growth [#engagement-credits-growth] An engagement rule pays points as a post keeps growing, on top of the quest's one-time reward. It reads a qualifying quest and credits a point ledger directly. When the quest has a performance threshold, a post earns engagement points only while it is approved and above the threshold. | Behavior | What happens | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Formula | A running total over the post's counters, such as one point per ten likes. | | Settlement | At most once per [project calendar](/docs/concepts/calendar) day, starting at the next midnight after the first eligible sample. | | Watermark | Counters of 100, 130, 120, then 135 likes pay for at most 135. A drop and recovery never pays twice. | | Budgets | Optional limits per project or per member, for each day, week, or month of the project calendar, or for the rule's lifetime. Growth over a limit never carries into the next period. | | Scope | Posts first found after the rule was published. Earlier posts are not backfilled. | | Formula change | Applies from an observed boundary forward. Past engagement is never repriced. | Pausing the quest, a pending review, lost approval, a removed post, or a change of owner stops further points until it is resolved. > [!DEV] > The formula is a cumulative target over the declared counters. It runs without network, clock, or member access. See [daily engagement points](/docs/reference/trackers#daily-engagement-points) for authoring, budgets and settlement timing. > [!IMPORTANT] Awards are not reversed automatically > Rejection, deletion, or a lower count stops future rewards and accrual. Points and rewards already credited remain. ## Current scope [#current-scope] * **Operator view.** Console shows each post's admission, qualification, performance, and engagement outcomes under **Community → Trackers**, and reviewers can retry historical admission there. Enrollment and member claims use the API. * **Publication.** The CLI publishes content quests and engagement rules to test environments. ## Where next [#where-next] - [Track content and events](/docs/build/trackers) — Build a UGC program with content quests and engagement rules. - [Trackers](/docs/concepts/trackers) — How posts are collected, revised, and observed. - [Points](/docs/concepts/points) — Where engagement points are credited. # Outbound webhooks (/docs/concepts/webhooks) A webhook lets your other tools hear about what happens in Domino as it happens. You give Domino a web address, pick the events you care about, and Domino sends a signed message there each time one occurs. For example: * **Post in your team's Slack** when a member completes a quest. * **Add a qualified referral to your CRM.** * **Ship merch** when a member earns a prize. Each endpoint belongs to one project and environment. Creating one doesn't send earlier activity. An event says what happened, not who made it happen: it names no operator, and no tool such as the CLI, Console or an [AI assistant connected](/docs/agents/connect) through Domino's MCP server. The project's audit history records the operator. > [!DEV] > Subscriptions select exact event type strings. The event is committed with the business operation and queued for each enabled endpoint whose filters match. ## Events and deliveries [#events-and-deliveries] An event describes one change, such as a completed quest. A delivery is one endpoint's attempt to send that event, including its automatic retries. Replaying a finished delivery sends the same event again, with its original ID and body. ```mermaid flowchart LR Change[Business change] --> Outbox[Transactional outbox] Outbox --> Delivery[Signed HTTPS request] Delivery --> Receiver[Operator backend] Delivery --> Retry[Retry with backoff] Retry --> Delivery ``` * **The same event can arrive twice.** Delivery is **at least once**: a timeout can happen after your receiver accepted a request. Each event has an ID, so your receiver can spot a repeat. * **Order isn't guaranteed**, even for the same member. When it matters, read the current state through the management API. * **Completed doesn't mean delivered.** A completed quest doesn't mean its Discord role or prize has arrived. `completion.created` and `reward.delivery.failed` describe different changes. ## Scope and recovery [#scope-and-recovery] Test and live have separate endpoints, signing secrets, subscriptions, and delivery logs. Both send real requests, so point a test endpoint at a test receiver. If deliveries keep failing, Domino turns the endpoint off. In Console **Settings → Webhooks** you can see why, fix your receiver, turn the endpoint back on, and replay the deliveries Domino kept. > [!DEV] > The same controls are available through the management API and CLI. Disabled endpoints receive no newly created events; a queued delivery encountered while disabled becomes `skipped`. A request already in flight can still arrive after disablement. See [Receive outbound webhooks](/docs/build/webhooks) to configure a receiver and [Webhook reference](/docs/reference/webhooks) for events, permissions, and limits. ## Delete an endpoint [#delete-an-endpoint] Choose **Delete endpoint** in Console **Settings → Webhooks** and give a reason. Deleting stops new deliveries and retries and frees up room for another endpoint. Its delivery history stays, read-only, under **Deleted endpoints**. A deleted endpoint can't be turned back on, rotated or replayed; create a new one to send again. > [!DEV] > Deleting needs publish permission and the endpoint's current revision. An HTTP request already in flight may finish. Use `management.webhooks.state(id)` for the current fence, then `management.webhooks.delete(id, { expectedRevision: endpoint.revision, reason })`. `management.webhooks.states({ cursor, limit })` pages active and deleted endpoints, and `deliveries(id, query)` retains their history. The CLI provides `domino webhooks get ID`, `domino webhooks states` and `domino webhooks delete ID --expected-revision N --reason TEXT`. Mutation inputs accept an optional action ID for identical retries. These controls retain the existing publish permission and credential rules in Test and Live. # Compose a campaign (/docs/composition) A campaign works best when one thing a member does moves several things at once. This page builds a launch campaign where a single welcome quest does four jobs: * **Earns XP.** The member gets 10 XP for completing it. * **Unlocks the next quest.** A follow-up quest opens once welcome is done. * **Pays the friend who invited them.** Completing welcome qualifies the referral, and the inviter gets 25 XP. * **Moves the weekly leaderboard.** XP from the campaign's quests ranks members each week, and the top member wins 100 XP. ## What members see [#what-members-see] A new member joins the launch campaign, completes the welcome quest and sees 10 XP land. The next quest unlocks. The friend who invited them gets their own 25 XP. Both of them can watch the weekly leaderboard, and when the week ends your team approves the prize. Your developer or coding agent builds the campaign in code, following the steps below. Once it's published, your team runs it from Console: arrange the quests in a collection, watch the standings, and approve the weekly prize. ## Before you start [#before-you-start] Start with a [connected project](/docs/start/existing-app) or [new app](/docs/start/new-app) that has the SDK and CLI installed. Use its test environment and two real member accounts for the referral check. ## Choose the connections [#choose-the-connections] One welcome completion drives every other part of the campaign: ```mermaid flowchart LR W["Welcome quest"] -->|"10 XP"| L["Community ledger"] W -->|unlocks| N["Next-step quest"] W -->|qualifies| R["Friends referral"] N -->|"5 XP"| L R -->|"25 XP to inviter"| L L -->|"earned this week"| B["Weekly leaderboard"] ``` | Source | Connection | Result | | ----------------------------------- | ----------------------- | -------------------------------------------------------- | | Welcome quest completion | Quest reward | The member earns 10 community XP. | | Welcome quest completion | Completion prerequisite | The follow-up quest becomes available. | | Invited friend's welcome completion | Referral qualification | Their inviter earns 25 community XP. | | Community ledger entries | Weekly earned score | Both members' eligible earnings contribute to standings. | > [!NOTE] Collections and claims in this example > A collection can display both quests together. It does not enforce their dependency. This example uses a direct claim to show how the pieces connect; it does not check any activity outside Domino. ## Build the campaign [#build-the-campaign] Define the shared campaign in `campaigns/launch.ts`: ```ts import { defineCampaign } from "@domino-sdk/relay/authoring"; export const launch = defineCampaign({ key: "launch", title: "Launch campaign", window: { from: Date.parse("2026-10-01T00:00:00Z"), until: Date.parse("2026-11-01T00:00:00Z"), }, enrollment: "required", }); ``` Both quests below import this value as `launch` and set `campaign: launch`. The member enrolls once for both quests with `relay.campaigns.enroll({ actionId, campaign: launch.key })`. Use dates appropriate for your program. ### 1. Define the ledger [#1-define-the-ledger] Save the typed ledger handle as `ledgers.ts`: ```ts import { defineLedger } from "@domino-sdk/relay/authoring"; export const community = defineLedger({ key: "community", name: "XP", description: "Community participation", }); ``` Merge these entries into `relay.json`, preserving the project's connection, app configuration, and existing resources. Keep authoring keys stable. If your starter already has a `welcome` quest, update that file instead of creating a second definition. ```json { "pointLedgers": [ { "entry": "ledgers.ts", "exportName": "community" } ], "discover": { "quests": ["quests/**/*.quest.ts"], "questTypes": ["quests/**/*.quest-type.ts"] }, "referrals": [{ "entry": "referrals/friends.ts" }], "leaderboards": [{ "entry": "leaderboards/weekly.ts" }] } ``` Rewards, referrals, and the leaderboard import the same ledger handle. > [!DEV] > Each environment resolves the ledger's key to its own opaque ledger ID. See [match ledgers in your app](/docs/concepts/points#match-ledgers-in-your-app). ### 2. Award points for the welcome quest [#2-award-points-for-the-welcome-quest] Save this as `quests/welcome.quest.ts`: ```text import { defineQuest, settings, manual, once, points } from "@domino-sdk/relay/authoring"; import { community } from "../ledgers"; import { launch } from "../campaigns/launch"; export const welcome = defineQuest({ key: "welcome", title: "Welcome to the community", campaign: launch, settings: settings.object({}), presentation: () => ({ instructions: "Claim your welcome XP." }), trigger: manual(), completion: once(), rewards: [points(community, 10)], }); export default welcome; ``` Domino records completion and awards the points. Your app displays that result through the [participant API](/docs/reference/participant-api). ### 3. Unlock the next activity [#3-unlock-the-next-activity] Save this as `quests/next-step.quest.ts`: ```text import { defineQuest, settings, manual, once, completed, points } from "@domino-sdk/relay/authoring"; import { community } from "../ledgers"; import { launch } from "../campaigns/launch"; import { welcome } from "./welcome.quest"; export const nextStep = defineQuest({ key: "next-step", title: "Take the next step", campaign: launch, settings: settings.object({}), presentation: () => ({ instructions: "Claim after completing the welcome quest." }), requires: [completed(welcome)], trigger: manual(), completion: once(), rewards: [points(community, 5)], }); export default nextStep; ``` This checks completion of a particular quest. It does not check whether the member currently holds 10 XP. Spending points does not erase that completion. Keep a manual trigger when the member must take another action. For an automatic milestone after prerequisites are met, use `automatic()` instead. For consecutive days, use a [streak](/docs/concepts/quests#calendar-day-streaks). For several separate actions, such as three different posts, use a [distinct-action counter](/docs/concepts/quests#distinct-action-counters). `completed(...)` means ever completed. ### 4. Qualify an invitation from the same completion [#4-qualify-an-invitation-from-the-same-completion] Save this as `referrals/friends.ts`: ```text import { defineReferral, points, questCompleted } from "@domino-sdk/relay/authoring"; import { community } from "../ledgers"; import { welcome } from "../quests/welcome.quest"; export default defineReferral({ key: "friends", title: "Invite a friend", qualification: questCompleted(welcome), rewards: { inviter: [points(community, 25)] }, }); ``` Wire invitation-code acceptance through sign-in using [the referral guide](/docs/build/referrals). A quest completion alone does not identify an inviter. Accept the invitation before the friend completes any other quest; completing the welcome quest first is fine, since it qualifies the referral. The invited friend earns the quest's 10 XP. Their inviter receives a separate 25 XP award when the referral qualifies. The friend's award is not reduced. ### 5. Rank weekly earnings [#5-rank-weekly-earnings] Save this as `leaderboards/weekly.ts`: ```text import { defineLeaderboard, earned, points } from "@domino-sdk/relay/authoring"; import { community } from "../ledgers"; import { nextStep } from "../quests/next-step.quest"; import { welcome } from "../quests/welcome.quest"; export default defineLeaderboard({ key: "weekly", title: "This week's contributors", ledger: community, score: earned({ window: { kind: "calendar", period: "week" }, quests: [welcome, nextStep], }), settle: { at: "window-end", ties: "shared", prizes: [{ ranks: [1, 1], rewards: [points(community, 100)] }], }, }); ``` The week starts on Monday in the [project calendar](/docs/concepts/calendar)'s timezone. Set the project timezone once, in `relay.json` as `"calendar": { "timeZone": "Europe/Budapest" }`; streaks and budgets follow it too. The quest filter makes this a campaign ranking: referral awards do not count. Spending does not reduce an earned score; corrections can. See [Choose what counts](/docs/build/leaderboards#choose-what-counts). Render standings with [the leaderboard reads for members](/docs/build/leaderboards#read-standings-in-your-app). The board reads existing points; no extra score write is needed. ### 6. Publish to test [#6-publish-to-test] ```sh pnpm exec domino deploy --preview --environment test --json pnpm exec domino deploy --environment test --json ``` Arrange the quests in a collection through [Console](/docs/use/quests#arrange-collections) if your app displays collections. ## Verify the connections [#verify-the-connections] Sign in as two members of the same test project, Alice and Bob, then check each connection in order: | # | As | Do | Expect | | - | ----- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | | 1 | Alice | Sign in and obtain her invitation code through your app's referral flow. | An invitation code. | | 2 | Bob | Sign in, enroll in `launch`, and accept Alice's invitation. | Welcome is available; before Bob completes it, the next-step quest is locked. | | 3 | Bob | Complete welcome. | A single completion, a 10 XP award, and availability of next-step. | | 4 | — | Check the referral summary. | Bob is qualified, and Alice has a separate 25 XP referral award. | | 5 | — | Read weekly standings. | Bob has 10. Alice's referral award is outside the board's campaign-quest filter. | | 6 | Bob | Refresh the app and revisit welcome. | Completion and points persist without a second award. | | 7 | Bob | Complete next-step. | His earned score becomes 15 under the same conditions. | > [!TIP] Totals differ from the table? > Existing test data persists between development sessions, and restarting the app does not reset the ledger. Inspect point history if totals differ. Follow [publishing](/docs/build/publishing) when the test behavior is ready for live. ## Approve the weekly prize [#approve-the-weekly-prize] At the end of the week, Console **Leaderboards** shows a frozen prize settlement. Review its eligible winner and select **Approve and deliver**, then confirm, to grant the 100-point prize. Equal first-place scores share the rank and each receive the prize. See [Settle prizes after a window](/docs/build/leaderboards#settle-prizes-after-a-window) for review, API approval, and correction. ## Extend the campaign [#extend-the-campaign] - [Quest types](/docs/concepts/quests#quest-types-and-settings) — When operators need multiple configurable activities with shared behavior. - [Verified identities](/docs/concepts/participants#verified-identities) — When verification needs a provider account or a wallet. - [Reward delivery](/docs/concepts/rewards#completion-and-delivery) — For Discord roles, show delivery separately from completion. ## Add external content [#add-external-content] Want members to post about the launch on X too? An [X tracker](/docs/build/trackers) collects launch posts, and a quest checks each one against the member's connected X account, with its own approval and engagement thresholds. Its reward can feed the same XP, leaderboard and referral used above. Collecting posts awards nothing until a quest acts on them. X doesn't allow rewards for posting, so require a group of creators you approve on these quests. Domino doesn't enforce this: a deployment with a quest that rewards X posts from anyone still publishes, with a warning. See [UGC programs](/docs/concepts/ugc). # Klaviyo (/docs/plugins/klaviyo) ## What it does [#what-it-does] * **Connection.** A project connects to Klaviyo once per environment, through Klaviyo's own sign-in and consent, with the settings below. * **Destination.** Domino syncs its members' data and the events below to Klaviyo. A destination syncs data only: it never sends messages or campaigns from your Klaviyo account. ## Connect it [#connect-it] Each environment has its own connection, and a promotion never copies it. Anyone with read access sees its status; connecting, editing and disconnecting need publish permission. * **Console.** On **Integrations**, the **Klaviyo** card: **Connect with Klaviyo**, fill in the settings, and grant Domino access at Klaviyo, which sends you back to Console. **Edit** changes the settings; **Disconnect** revokes Domino's access at Klaviyo and deletes it. * **CLI.** `domino connections authorize klaviyo --config-json '{}'` prints an `authorizationUrl`. Open it within ten minutes and grant access; the connection is ready when Klaviyo sends you back. It works once. Domino asks Klaviyo for `profiles:read`, `profiles:write`, `events:write`, `lists:read`, `lists:write`, `subscriptions:write`. Domino keeps Klaviyo's tokens sealed, refreshes them before they expire, and never shows them. If Klaviyo stops accepting them, for example because someone removed Domino's access in Klaviyo, the connection needs attention with a `reconnectReason`: **Reconnect** on the card, or `domino connections authorize klaviyo` again, restores it with the same settings. The card shows **Not configured** when this Domino deployment doesn't have its Klaviyo app yet; nothing can connect Klaviyo until it does. ### Dry run in test [#dry-run-in-test] In the test environment Klaviyo can run as a dry run, without a Klaviyo account: **Connect as dry run** on the card, or `domino connections connect klaviyo --dry-run --config-json '{}'`. Domino then sends nothing to Klaviyo. It records each request it would have sent, with the member it is about, and keeps the latest 500: the card's **Dry-run log**, or `domino connections log klaviyo`. Connecting a Klaviyo account in test, such as a separate test account, turns the dry run off and starts the sync again. Live has no dry run. ## Settings [#settings] | Setting | Accepts | Required | Description | | ----------------------- | --------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `listId` | Text, at most 64 characters | No | List ID: The Klaviyo list to subscribe consenting members into, from the list's settings in Klaviyo. Without one, Klaviyo's account default opt-in applies. | | `includeWithoutConsent` | Yes or no | No | Include members without consent: Also send the profiles, properties and events of members with a verified email who haven't granted email consent. They are never subscribed. | Relay refuses any other setting, with 422 `invalid-config` naming it in `path`. ## Events [#events] Klaviyo receives these events, each as a [webhook](/docs/reference/webhooks) receives it, from when the connection is made: * `attempt.rejected` * `entitlement.granted` * `reward.delivery.updated` * `completion.created` * `points.awarded` * `review.pending` * `reward.delivery.failed` * `entitlement.collected` * `referral.qualified` ## Sync status [#sync-status] The card and `domino connections activity klaviyo` show when Klaviyo last synced, how many members were synced and how many were skipped and why, the latest backfill or resync with its progress, and the latest 20 errors with Klaviyo's answer. Connecting sends every member's profile and attributes to Klaviyo, in pages; events go only from the connection on, so old completions set nothing off. **Resync**, or `domino connections resync klaviyo`, sends every member's profile and attributes again, never past events, to repair anything that drifted. One runs at a time. After 10 failed deliveries in a row, or a day of failing deliveries without one success, the connection needs attention with the latest error. It keeps retrying, and the next delivery Klaviyo accepts sets it back to ready; a resync then fills anything missed. ## Reference [#reference] ### What Domino sends to Klaviyo [#what-domino-sends-to-klaviyo] Domino never sends messages. It keeps Klaviyo profiles current and records events on them; your Klaviyo flows decide who hears what, and send it. Members are matched on their verified email, the only key Klaviyo merges on besides phone. A member without one is skipped and counted. Without email consent a member is skipped too, unless the connection sets **Include members without consent**; those members are never subscribed. ### Profile properties [#profile-properties] | Property | Value | | --------------------------- | ------------------------------------------------------------------------------ | | `external_id` | The member's first linked app identity (`app:…`): your user ID | | `domino_member_id` | The member's Relay member ID | | `domino_user_id` | Your user ID, as `external_id`, when the member linked one | | `domino_points_` | The balance in each point ledger, by its key (or ID) | | `domino_rank_` | The rank on each active leaderboard, by its key (or ID); empty without a score | | `domino_quests_completed` | Quest completions that count | | `domino_unredeemed_rewards` | Rewards earned and not collected yet | | `domino_campaigns` | The IDs of the campaigns the member is enrolled in | Ledger and leaderboard keys are lowercased, and any run of characters other than `a-z`, `0-9` and `_` becomes `_`. ### Subscriptions [#subscriptions] A member who granted email consent is subscribed to email marketing, into the connection's list when it sets one. Withdrawing consent unsubscribes them. Connecting backfills existing members' profiles and subscriptions with the time each member consented. Domino never subscribes someone Klaviyo already has unsubscribed or suppressed on the strength of consent they gave before the connection was made, since subscribing would lift that: they stay unsubscribed until they opt in again in your app. ### Metrics [#metrics] Events published after the connection was made are recorded on the member's profile; a referral's event is also recorded on the inviter's. Each carries the Domino event's data plus `domino_event_type` and `domino_role` (`member` or `inviter`). Connecting never replays older events. | Domino event | Klaviyo metric | | ------------------------- | ------------------------------ | | `attempt.rejected` | Domino Attempt Rejected | | `entitlement.granted` | Domino Reward Earned | | `reward.delivery.updated` | Domino Reward Delivery Updated | | `completion.created` | Domino Quest Completed | | `points.awarded` | Domino Points Awarded | | `review.pending` | Domino Review Pending | | `reward.delivery.failed` | Domino Reward Delivery Failed | | `entitlement.collected` | Domino Reward Redeemed | | `referral.qualified` | Domino Referral Qualified | # Telegram (/docs/plugins/telegram) ## What it does [#what-it-does] * **Connection.** A project connects to Telegram separately in each environment, through Telegram's own flow below. * **Announcements.** A quest's [announcements](/docs/concepts/quests#announcements) can post in what the project connected to Telegram. * **Trackers.** `telegram.messages()` and `telegram.reactions()` [trackers](/docs/concepts/trackers) record what happens in what the project connected to Telegram. Only Domino's Telegram bot writes their records: ingestion refuses them. * **Quest code.** `ctx.telegram` reads Telegram in a quest's `evaluate`, declared or not: a quest may list the `telegram` integration module, and doesn't need to. * **Sign-in.** Members sign in with Telegram: Telegram's own sign-in, `relay.auth.start({ provider: "telegram", mode })`, when Domino configured it; and Telegram Mini App sign-in, `withTelegram(relay).telegram.miniApp` from `@domino-sdk/relay/telegram`, once the project sets `miniAppBotId` in its sign-in settings. Every way in reaches the same Telegram account, so it is one member. ## Connect it [#connect-it] Each environment has its own connection, and a promotion never copies it. A project connects Telegram groups and channels, each through Domino's bot. In Console, **Add chat** under **Telegram** on **Integrations** opens the bot in Telegram, which asks you to choose the chat; `management.telegram.connect()` makes the same link. You must be an administrator of the chat, and the bot becomes one there. Removing a chat (`management.telegram.remove(chat)`) makes the bot leave it once no project uses it. The [Telegram guide](/docs/build/telegram) covers the rest. `domino connections get telegram` and `management.connections.get("telegram")` read its status: `ready` once the project has connected something in this environment, `attention` with an `issue` saying what to fix, and `disconnected` before. They don't connect it: `domino connections connect telegram` answers 409 `plugin-managed`. # Measure a project and build dashboards (/docs/reference/analytics) [Analytics](/docs/concepts/analytics) explains what each metric counts, and [Dashboards](/docs/build/dashboards) walks through defining one. Use this reference to answer how a project is doing, to pick the metrics that measure what it is for, and to build, check and explain dashboards. Command flags and result shapes are in [project analytics](automation.md#project-analytics), [metric catalog](automation.md#metric-catalog) and [dashboards](automation.md#dashboards); the `defineDashboard` fields and deploy refusals are in [authoring dashboards](authoring.md#dashboards). ## Answer "how is it going" [#answer-how-is-it-going] 1. For the overview, run `domino analytics get --range 7d|30d|90d|all --json`. Pick the range the question implies; `30d` is the default. 2. For a narrower question (one quest, one program, a group of members, rejections by reason), run `domino analytics query --metric ID --json` with the breakdown or filter that asks it. List the catalog with `domino analytics metrics` when you don't know the ID. 3. Quote the read's numbers: the `total`, the `previousTotal` it compares with, and the rows that explain it. Every number in your answer comes from a read. When the question needs a number no read returns, such as a ratio of two metrics, say the catalog doesn't have it and propose it as a new metric; arithmetic on read results stays out of the answer. An answer is done when each number it gives names the metric and range it came from. ## Choose metrics from the project's goals [#choose-metrics-from-the-projects-goals] Start from what the project is for, as its brief (CAMPAIGN.md or the app's project document) states it, and measure that. Common goals map to these metrics: | Goal | Metrics | | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | Growth through referrals | `referrals.qualified` and `referrals.qualificationRate`, each by `program`; `members.signUps` for the whole intake | | Tracker-driven quests, such as staking or holding | `quests.completions` with a `tracker` filter, and `quests.attempts` with the same filter to see who starts | | A points economy | `points.awarded`, `points.spent` and `points.net` for each ledger, and `points.membersEarning` for reach | | Content and review load | `quests.needsReview` by `quest`, and `quests.rejections` by `reason` | | Rewards that get claimed | `offers.redemptions` by `offer` | | Engagement | `members.active` and `quests.completions` by `quest` | The catalog is the source of truth: read `domino analytics metrics` for the live list, each metric's breakdowns, filters and charts, and whether it needs a ledger. Points metrics read one ledger each, so a project with two ledgers needs a panel per ledger. ## Build a dashboard [#build-a-dashboard] Build one when the operator wants a recurring view of a goal, or after you build a feature whose success they will want to watch, such as a referral program or a tracker quest. Offer it then, in the same handoff. * **One dashboard per goal.** Give it the goal's name and a description that states the goal. * **A few panels that each answer a question.** Three to six is typical. A `number` panel answers "how many"; a `line` answers "is it growing"; a `bar` or `table` with a breakdown answers "which one". * **Breakdowns over pinned lists.** Use `breakdown: "quest"` or `breakdown: "program"` instead of one panel or filter per quest, so new quests and programs appear without editing the dashboard. Filter by key only when the goal is about those resources. * **Rates as `number` or `line`.** A rate can't be drawn as `bar`, and it is empty below its `minimumDenominator`. Write the definition with `defineDashboard`, list it under `dashboards` in relay.json, and follow [authoring dashboards](authoring.md#dashboards) for the fields. ### Check before you hand over [#check-before-you-hand-over] 1. Run `domino deploy` in test. A refusal has a code and names the panel it is about; fix the definition and deploy again. 2. Run `domino dashboards get ` and read every panel. 3. Fix each panel `error`. `group-not-found` means the filter names a group this environment doesn't have: create the group or drop the filter. `panel-timeout` on a long range is worth naming to the operator. The dashboard is ready when `domino dashboards get ` shows a result for every panel. Only then tell the operator it exists, with its key. ## Explain a dashboard [#explain-a-dashboard] Describe each panel with its metric's definition from `domino analytics metrics`, in plain words, and say what its breakdown splits by. Name these where they apply: * **A `group` filter** counts members who are in the group now, whatever their membership was when they acted. A member who leaves the group drops out of past buckets too. * **A rate** is empty below its minimum denominator (10), so a quiet range shows no value rather than 0%. * **Records count as they stand now:** a completion revoked or a redemption canceled later leaves the bucket it was in. * **Live gets the dashboard by promotion.** It exists in test after a deploy; it reaches live when the operator promotes, with its archive state. ## Limits [#limits] * **No formulas.** A panel shows one catalog metric. If the catalog can't express the number the operator wants, say so and propose the metric (ADR 0021 keeps the catalog closed). * **Group keys are local to an environment.** Promotion doesn't copy groups, so a panel filtered on a group that live doesn't have reads `group-not-found` there until the operator creates it. * **Caps.** A dashboard holds at most 24 panels, and a project at most 20 dashboards that aren't archived. * **Retire by archiving.** Removing a dashboard from relay.json doesn't delete it. Run `domino dashboards archive --reason "…"` in test, and `domino dashboards unarchive --reason "…"` to restore it. * **Console shows dashboards but can't edit them.** Operators pick one in the switcher on **Analytics** (it has its own address, `/analytics/`), or read it with `domino dashboards get`. Changes go through code and a deploy. # Authoring and publication (/docs/reference/authoring) This reference covers writing and publishing quests: snippets, upgrades, resource keys, settings, offers, leaderboards, external points, referral programs, rewards, announcements, hosted server applications, the project calendar, presentation and dashboards. The references listed under [More authoring references](#more-authoring-references) cover trackers, verification, quest rules and operating published resources. Read the installed `@domino-sdk/relay/authoring` declarations and the project's existing quest modules. Define the requested behavior with the supported SDK helpers rather than inventing capabilities. Authored entries in relay.json resolve relative to that manifest and can select a named export with `exportName`. The `discover.quests` and `discover.questTypes` arrays in relay.json select default-exported resource files. New projects use `quests/**/*.quest.ts` and `quests/**/*.quest-type.ts`. Add matching files without individual registration. Existing manifests without `discover` keep explicit-entry behavior. Omitting either discovery array or setting it to `[]` disables discovery for that resource kind while preserving explicit entries. An explicit entry overrides the matching discovered default export; omitted providers inherit `discover.provider` for those files. Discovery does not execute authored modules on the build host, publish them, or archive omitted resources. ## More authoring references [#more-authoring-references] * [Trackers](trackers.md#declare-trackers): read before declaring a tracker (X, Discord, Telegram, YouTube Shorts, TikTok or wallet balances), triggering a quest from one, gating content on performance, paying engagement points, or checking a member's Telegram account and chat membership (`ctx.telegram`). * [Verify member actions](verification.md#evaluator-readers-null-errors-and-pinning): read before a quest's `evaluate` checks what a member did: Discord membership, X, linked wallets, wallet balances, NFT holdings, contract state, Cosmos delegations, on-chain actions, text and URL submissions, AI text review, image checks, staff confirmation, or entry after app-owned pass verification. It also has the rules every evaluator reader follows: null results, errors, the call limit and pinning. * [Quest rules and evaluation](quest-rules.md#campaigns-windows-and-prerequisites): read before shaping how a quest decides and repeats: campaigns, availability windows and prerequisites, automatic and repeatable quests, reject codes, deferred evaluation and progress reports, distinct-action counters, retry and completion cooldowns, evaluation time and randomness, calendar-day streaks, test members, and local quest tests. * [Operate published resources](management.md#management-client): read before operating a project from a script or server: the management client and its error contract, project identity and settings, resource lifecycle, canonical lifecycle commands, recovering a quest lifecycle change, operator undo commands, and scheduled hosted jobs. ## Find a snippet [#find-a-snippet] Before writing a new quest, fetch the public catalog. It requires no authentication: ```sh curl -fsS https://snippets.domino.run/r/registry.json ``` 1. Match the requested behavior against each item's `title`, `description`, and `categories`. Use the catalog's `name` to fetch `https://snippets.domino.run/r/.json`; inspect its `docs` and `files` contents, targets, and prerequisites before installing. The live catalog is the source of available recipes, so newly published snippets need no skill update. 2. If a recipe fits, install it from the project root containing `relay.json` and `package.json`. For example, when the catalog lists `photo-quest`: ```sh pnpm dlx shadcn@4.21.0 add https://snippets.domino.run/r/photo-quest.json ``` Preserve existing customized files; adapt those in place when the recipe is already installed. Use the project's package manager equivalent when it is not pnpm. 3. Read the installed `docs/snippets/.md`. Adapt the source to the requested behavior, choose unique authoring keys for new quests, and configure settings, rewards, and verification prerequisites. Keep existing authoring keys stable and verification source out of the browser bundle. 4. Confirm the source matches `discover` in `relay.json`. Move it to a matching directory for custom layouts, or add an explicit entry if discovery is disabled or unsupported by the installed CLI. Run the project's checks and `domino build --json`, then exercise the requested participant behavior using the verification workflow in the Domino skill. Installation only copies source; publication is a separate step below. If no recipe fits or the catalog is unavailable, author with the installed SDK declarations and existing project examples. Report an unavailable catalog as unchecked, rather than claiming no matching snippet exists. Snippets are editable starting points; check compatibility with the project's installed SDK before changing package versions. ## Upgrade existing authoring code [#upgrade-existing-authoring-code] When an upgrade fails on removed APIs, follow the [SDK v1 upgrade guide](/docs/reference/upgrade-to-v1). Start with [typed handles and settings references](/docs/reference/upgrade-to-v1#primitive-handles), then [typed prerequisites](/docs/reference/upgrade-to-v1#prerequisites-are-typed-eligibility-rules). Apply the same changes to code generators, not only their generated files. Authoring declarations use `key`; the old `id` alias is removed. Keep the old value when renaming `id` to `key`: it names the source declaration, not the server resource ID. Nested budget, tier, milestone, and bonus declarations use `key` too. Run these commands from the project directory after upgrading the SDK and CLI: ```sh domino migrate domino migrate --write domino check --json ``` `migrate` previews a diff without writing files or contacting Domino. `--write` applies the changes. It renames `id` to `key` on authoring constructors, referral bonuses and milestones, tiers, `budget()` calls, and collection declarations, renames `balance` to `ledger` on referral bonuses, leaderboards, engagement rewards, and `budget()` costs, and proposes `pointLedgers` entries for statically known ledger keys. Existing server IDs are not renamed. Review proposed ledger names before deploying. The codemod does not finish typed-handle conversion: migrate raw references to imported handles, or use `settings.pointLedger()` for a Console-selected ledger that already exists remotely. A remote ID remains a string for management methods; it is not an authoring handle. The migration follows configured and discovered entry modules and their local imports. It recognizes SDK import aliases and namespace imports, keeps every renamed value, and does not execute source. Indirect objects, spreads, and dynamic ledger references receive manual instructions. A string where v1 takes a handle (a referral bonus or leaderboard `quests` entry, a referral `qualification.quest`, an engagement `qualifiedBy`, a `ledger`, a `requires` entry, or `completed()`) gets a `HANDLE_REFERENCE_REQUIRED` error naming the field; convert it to a handle import by hand. A budget cost defined in another module gets `MANUAL_MIGRATION_REQUIRED`; rename its `balance` to `ledger` there. It does not rewrite dependencies, unconfigured source, or files outside the project directory. Resolve conflicts before applying; a conflicting identity or ledger prevents all writes. `HANDLE_REFERENCE_REQUIRED` and `MANUAL_MIGRATION_REQUIRED` errors do not prevent the write, so apply the renames, then convert the references. Repeating the command after a completed migration produces no further diff. `domino check --json` includes a `diagnostics` array with `code`, `severity`, `file`, `line`, `column`, `message`, and `suggestion` for source findings. `AUTHORING_ID_DEPRECATED` and `AUTHORING_BALANCE_RENAMED` are warnings for renames `migrate --write` applies. `AUTHORING_IDENTITY_CONFLICT`, `AUTHORING_LEDGER_CONFLICT`, `AUTHORING_KEY_MISSING`, `HANDLE_REFERENCE_REQUIRED`, `MANUAL_MIGRATION_REQUIRED`, `BUDGET_DECLARATION_CONFLICT`, and `AUTHOR_BUDGET_TRACKER_REQUIRED` fail the check. `LEDGER_REFERENCE_DYNAMIC` (a ledger reference that cannot be determined without running code) and `LEDGER_DECLARATION_MISSING` (a ledger with no local declaration) are warnings because the selected ledger may already exist remotely. `AUTHORING_IDENTITY_DYNAMIC` warns about a declaration built from an indirect object or spread, which `migrate --write` leaves unchanged. `LEADERBOARD_TIES_DIFFER` warns about a leaderboard whose live `ties` differs from its `settle.ties`: its standings show one order and its prizes pay another. `TRIGGER_REFIRES_ON_REFRESH` warns about a quest on a YouTube or TikTok tracker that runs on every observation, as `automatic()` does by default there: each refresh evaluates it again for every video ([trackers](trackers.md#youtube-shorts)). A `defineLedger` handle, defined in the same module or imported, is a static reference to its key, and a ledger published in `pointLedgers` as `{ "entry", "exportName" }` counts as a local declaration. A ledger read from a setting is dynamic. Manifest validation errors appear on stderr with `code: "MANIFEST_INVALID"` and field paths in `details`; a relay.json that is not valid JSON reports `MANIFEST_INVALID` without them. A `--config` file that does not exist reports `PROJECT_NOT_FOUND`, and a quest, type, engagement rule or image that fails to bundle reports `BUNDLE_FAILED`. Each exits 1. Agents should follow the reported suggestion and rerun the check; never invent a replacement key for an existing resource. ## Resource IDs and authoring keys [#resource-ids-and-authoring-keys] Use the IDs returned by Domino when referencing projects, quests, point ledgers, rewards, and inventory items. Creation requests do not accept a new resource's ID. The server assigns an opaque, prefixed ID. Existing stored IDs remain valid after upgrade. In source, give every authoring constructor a stable `key`. Constructors return typed handles that carry that key. Pass those handles between authoring helpers; for example, `completed(welcome)`, `points(xp, 100)`, and `defineLeaderboard({ ledger: xp, ... })`. Domino maps each key to an opaque resource ID within the project and environment. Redeploying the same key updates the same resource; changing the key creates another resource. Display titles are independent of identity. Participant, management, Console, and stored wire values continue to use strings where their APIs accept IDs or keys. ## Offers [#offers] An offer exchanges points from one ledger for a tiered reward. Define the reward once, then default-export the offer from its own module: ```ts import { automaticFulfillment, defineLedger, defineOffer, defineTieredReward, points, } from "@domino-sdk/relay/authoring" const community = defineLedger({ key: "community", name: "Community" }) const badge = defineTieredReward({ key: "supporter-badge", title: "Supporter badge", tiers: [{ key: "standard", title: "Standard" }], inventory: { kind: "untracked" }, fulfillment: automaticFulfillment(), }) export default defineOffer({ key: "supporter-badge-offer", price: points(community, 500), reward: badge.tier("standard"), stock: 100, limit: { perMember: 1 }, }) ``` Add `{ "entry": "src/supporter-badge.offer.ts" }` to `offers` in `relay.json`. `title` defaults to the reward's title. `stock` (required, 0 to 1,000,000, never unlimited) is the total supply: increasing it adds units and decreasing it succeeds only while enough unredeemed units remain. `limit.perMember` (1 to 10,000) defaults to 1. Use `staffHandover()` instead of `automaticFulfillment()` when staff must confirm physical delivery. A staff-handover reward with pickup variants (sizes, configured on the reward's pickup policy) is not split by variant at redemption: participants see neither the variants nor their stock. Staff choose an in-stock variant when they hand it over, and from then on the participant's entitlement carries it as `allocation` (`item` and `label`). The reward must be a tier from `defineTieredReward`: `badge()` and `groupMembership()` type-check but `defineOffer` throws (`Badge rewards can only be granted by quests or manually, not by offers`; `Group membership rewards can only be granted by quests, not by offers`), and a deployment refuses them with 422 `validation-failed` and the same message. To limit who can redeem, give the offer `requires`: the prerequisites quests take, `completed(quest)`, `balanceAtLeast(ledger, minimum)` and `inGroup(group)`, at most 30 (see [prerequisites](quest-rules.md#campaigns-windows-and-prerequisites)). For example, `requires: [inGroup(members), balanceAtLeast(community, 1000)]` sells only to group members holding at least 1,000. A balance prerequisite is a threshold, not a cost: the price is spent separately, and both read the balance when the member redeems. Publication refuses a `completed` quest or a ledger the deployment doesn't know. A required group the environment doesn't have is created, empty, with the handle's title: deploying creates it in test and approving the promotion creates it in live (see [groups](quest-rules.md#campaigns-windows-and-prerequisites)). Relay checks the prerequisites when the member redeems and refuses a member who misses one with 409 `prerequisites-incomplete` and `missing`, the same shape quests use, spending no points or stock. Meeting them later reserves nothing, and leaving a group later leaves earlier redemptions alone. Offer reads for a signed-in member list what they lack in `missing`, so an app can show the offer locked and why; otherwise `missing` is empty. Omitting an offer entry from a later deployment does not delete or pause the published offer. Pause it in Console, or call `management.offers.pause` with its current revision and a reason. Participants list offers with `relay.offers.list()` or `useOffers()` and redeem with `relay.offers.redeem(offer.id)` or `useRedeemOffer()`. A redemption spends points, consumes one unit, and grants the reward atomically. Show the returned availability and `missing` instead of calculating limits, stock or eligibility in the app. Create point ledgers in Console **Rewards → Point ledgers → New ledger**, with `management.pointLedgers.create({ name, description })`, or define a module such as `ledgers.ts`: ```ts import { defineLedger } from "@domino-sdk/relay/authoring" export const community = defineLedger({ key: "community", name: "XP", description: "Lifetime progress", }) ``` Publish that export from `relay.json`: ```json { "pointLedgers": [ { "entry": "ledgers.ts", "exportName": "community" } ] } ``` Merge this property into the existing manifest, import `community`, and use `points(community, 100)`. A `points()` amount is a whole number from 0 to 2,147,483,647, in a quest reward, an evaluator decision, a leaderboard prize, or a referral reward. An offer price takes the same range but starts at 1. A deployment previews ledger declarations with the other resources and publishes them together. An unknown or archived ledger reference fails validation instead of creating a balance. `management.pointLedgers.list()` lists every ledger (`management.points.ledgers()` is its deprecated alias); management entries include nullable `archivedAt`. Creating, editing or archiving a ledger directly (`management.pointLedgers.create`, `management.pointLedgers.update`, `management.pointLedgers.archive`) needs `publish`. Live refuses these for every credential, Console included, with 422 `promotion-required`; a live ledger comes from a `defineLedger` declaration in a reviewed deployment. Archive hides a ledger from new reward choices and new published definitions; existing published rewards, balances, history and operator corrections keep working. For a selectable ledger, use `settings.pointLedger({ label: "Point ledger", default: community })`. The optional default is a ledger handle. Without a default, the setting is empty until an operator chooses a Console-created ledger, and the quest stays paused. Guard rewards from the empty value: `settings.balance ? [points(settings.balance, 100)] : []`. The management SDK generates an `actionId` when a keyed mutation omits it, including point changes, offer controls, reward corrections, enrollments, and resource creation. Typed methods whose input has an `actionId` send the same request again after a transient failure (see [Management client](management.md#management-client)). If a write still fails, the `ManagementError`'s `operationId` is that action ID, to retry the same input explicitly; see [Management client](management.md#management-client). For retries across process restarts, generate and persist an action ID before calling the helper. Direct HTTP creation requests still require `actionId`. Use `actionId` to make a creation request retryable. Reuse the same action ID and payload after an uncertain response; a different payload with the same action ID fails. The action ID is not the resource ID. Provider subjects, tracker record IDs, completion keys, and nested quiz, tier, budget, and referral-rule keys keep their own scopes. For pickup inventory, supply a label for each new variant and omit `item`; saving returns its assigned item ID. Reuse that ID to share existing stock. For standalone stock, type a name in the Console Stock item picker and choose **Create item “NAME”**, or use `management.inventory.createItem({ name })`. Both return a server-assigned item ID. Stock adjustments accept existing item IDs only. Pickup configuration and item creation are definitions, so live refuses them with `promotion-required`. ## Resource and date/time settings [#resource-and-datetime-settings] Build every quest setting with a `settings.*` builder (`integer`, `text`, `boolean`, `choice`, `datetime`, `resource`, `pointLedger`, `quiz`, `telegramChat`, `amount`, `chain`) inside `settings.object({ ... })`. A plain zod schema, such as `z.number().default(1)`, is a type error, and `defineQuest` throws `Setting "" is not a quest setting` for untyped callers: Console edits only what the builders describe. ### Secret settings [#secret-settings] Use `settings.text({ label, secret: true })` for a value members must not see and that should not sit in source, such as a shared scan code. The operator sets it in Console (or with `management.quests.updateSettings`), and the quest's code reads it in `settings` as usual. It takes no `default`: it reads `""` until set, so guard the empty value. Its `minLength` defaults to 0. * **Never in source.** A secret setting with a non-empty `default`, or a value from a deploy entry's `settings`, fails: `defineQuest` throws, and publication refuses with `Secret setting "" must default to ""`. Reusable quest types refuse secret settings; use a quest written in code. * **Never in responses.** Every management response leaves the key out of `values` and `configuration` (`domino deploy --json` and `--preview --json`, `quests list`/`get`, releases, deployments, attempts, members). A release's `withheldSettings` lists the secret settings that hold a value; its `fields` entry still says `secret: true`. An attempt capture leaves it out of `context.settings`, so `domino quests replay` runs it empty. Withholding follows each release's own `fields`: releases, deployments and captures from before a setting became secret still show its old value, so set a new value after marking an existing setting secret. * **Write-only in Console.** Console shows **Set** or **Not set**, takes a new value in a password field, and **Clear** resets it. It never shows the current value. The quest's title and presentation come from describing its code with the settings, so don't put a secret in them. A capability call records its request in the attempt's capture, so a secret passed into one, such as an AI review prompt, shows in `domino quests replay` captures; compare it in the evaluator instead. Use `settings.resource({ label, source: discord.channels() })` for a Discord channel selector. Import `discord` alongside `settings` from `@domino-sdk/relay/authoring`. The connected project integration supplies the server; never add a guild ID setting. Filter with `discord.channels({ types: ["announcement"] })` when only announcement channels qualify. The default includes text and announcement channels. Resource values are stable IDs, not labels. Omit the resource default until the operator chooses a value in Console. Its empty string represents missing configuration. Code-authored quests publish paused when any resource setting is empty. Publishing a configured value activates the code-authored quest; clearing it pauses the quest again. Console-created quests must have every required resource configured before activating. Existing operation-level pauses remain independent. Live refuses settings changes with `promotion-required`, so in live an empty resource setting cannot be filled: the quest stays paused unless the deployed source supplies a default. Use `settings.amount({ label, default, decimals? })` for a token amount: a non-negative base-unit integer string without leading zeros, such as `"1000000000000000000"`, exact at any scale. With `decimals`, Console shows and edits it in whole tokens (`formatUnits`/`parseUnits`). A tracker filter may compare with it (`gte: { setting }`). Use `settings.chain({ label, default })` for a chain `ctx.wallet.balance()` reads: `"solana"`, `"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"`, or an EVM CAIP-2 ID such as `"eip155:8453"`; its value is typed as the balance query's `chain`. Use `settings.nft({ label, default })` for an NFT collection `ctx.wallet.nfts()` reads, such as `"eip155:1/erc721:0x…"` or `"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/nft:"` ([NFT holdings](verification.md#nft-holdings)). Use `settings.contractCall({ label, default })` for a contract function `ctx.wallet.call()` reads, such as `"eip155:8453:0x… function stakedOf(address) view returns (uint256)"` ([Contract state](verification.md#contract-state)). Use `settings.contractEvent({ label, default })` for an EVM contract event `ctx.wallet.events()` reads ([On-chain actions](onchain.md#evm-events)); read a busy wallet, or a long window on a fast chain, with `newest` so the read returns the newest events instead of throwing at its caps. Use `settings.solanaProgram({ label, default })` for a Solana program `ctx.wallet.transactions()` reads ([Solana transactions](onchain.md#solana-transactions)). All six are text settings (`picker: "amount"`, `"chain"`, `"nft"`, `"contract-call"`, `"contract-event"` or `"solana-program"`, and `decimals`), and publication and Console edits refuse other values; an invalid `default` throws when the quest module loads. Use `settings.datetime({ label, default: "2026-09-21T00:00:00+02:00" })` for an exact cutoff instant. Values normalize to UTC ISO timestamps. Console displays date and time with the operator's local timezone explicitly labeled. Offset-free timestamps and invalid calendar dates are rejected. This is not a date-only setting. Resource selections are checked on the backend for Console and CLI publication, including final deployment publication after preview. The selected resource must belong to the current connected integration and be usable by its bot. Lookup caches do not replace publication validation. A temporary provider outage rejects the new publication without changing the existing release. Deleted selections remain visible for the operator to replace. Replacing a text field with a resource or datetime field preserves Console overrides when their values pass the new validation. Keep the field key stable. Invalid overrides must be updated or reset explicitly; they are not silently discarded. ## Publish the authored resources [#publish-the-authored-resources] Source owns reusable quest definitions, collection slots, and deployment defaults. Console owns operator-created quest instances, their placement, and custom setting values. Keep identifiers stable. Registering a new reusable type version does not upgrade existing instances. Entry `settings` supplies deployment defaults. Remote custom values take precedence on subsequent deployments. A removed or incompatible setting can block the batch. Use `settingRenames` for a deliberate rename, or have the operator reset the custom value in Console under **Quests**. Changing source defaults does not clear overrides. Use `domino build --json` or `domino deploy --dry-run --json` for offline bundling. Quests and quest types import the installed SDK's [shared runtime](/docs/concepts/quests#shared-sdk-runtime) instead of bundling it, so each quest's code is a few kilobytes plus whatever else it imports. A `BAD_REQUEST` saying the Relay does not accept quests built against an SDK runtime means the Relay predates runtimes: update it, or restart an outdated local stack. One project holds up to 1,000 quests and 200 quest types; above that `domino build` fails with `BUNDLE_FAILED` ([limits](/docs/build/publishing#publish-behavior-to-test)). To save the bundle, use `domino build --out .domino-build/deployment.json --json`; the CLI creates missing parent directories. These checks do not validate effective remote settings. Use `domino deploy --preview --json` to resolve and validate the remote batch without publishing. Check the selected scope before publication. A hosted activation can save its receipt and copy reference images before a later description fails. Such a failure is 502, even if the dependency reported 503. Treat these outcomes as uncertain and inspect the build before starting another. A 503 promises that the entire request made no committed write or external effect. See [write recovery](/docs/reference/upgrade-to-v1#treat-failed-writes-as-uncertain-when-relay-cannot-prove-refusal). Use `domino deploy --environment test --json` when test publication is part of the task. It publishes configured quests, types, collections, and supported interactions together. After a lost test publication response, use the returned `domino deploy --resume ` recovery command. Starting another deployment may create another immutable release. To undo the latest test deployment, run `domino deploy rollback --environment test --json` (`management.deployments.rollback(id)` with a test client; publish permission). It publishes the previous published test deployment again at once, as a new deployment with `source: { kind: "rollback", deployment }`; the result names it in `rolledBackTo`. Only that deployment, the second entry of `management.deployments.history()`'s `published`, can be rolled back to, and the API refuses any other ID with `validation-failed`; running it again undoes the rollback. Only what the earlier deployment contains goes back: quests and other definitions only the later deployment added stay, and the build `domino stage` made keeps serving staging. Once a hosted app build has published to test since that deployment, the API refuses with 409 `conflict`; stage the earlier commit instead. The CLI saves it before sending; after an uncertain response, run the error's `operations resume` command. The CLI refuses live with `ENVIRONMENT_UNSUPPORTED` and `apiCode: "promotion-required"`: roll back test, then promote. Deleting or no longer discovering a quest file does not archive the published quest. Run `domino quests archive --reason "…" --json` (publish permission, test only; `management.quests.archive(key, { reason, expectedRelease? })`) to stop new attempts and hide it from collections; history stays, and it no longer counts toward `supportedInteractions`, so archive retired quests before deploying fewer interactions. Deploying its code again keeps it archived. `domino quests unarchive --reason "…"` reopens it, paused while a resource setting is empty, and fails when the app no longer supports its interaction or the prerequisite graph rejects it. Quests created in Console change participation in their Console editor instead. To take test's definitions to live, run `domino promote --environment test --json` (`management.promotions.prepare()` with a test client; publish permission in test). It compares every definition in test with live by authoring key, code-defined and Console-authored alike, and saves a pending promotion: `changes` lists each `kind`/`key` live would change as `added`, `changed`, `archived` or `unarchived`; `warnings` lists `binding-missing` integrations, webhook endpoints, sign-in methods and app secrets live lacks, which a promotion never copies, `live-only` definitions it leaves alone, and `group-created` groups approval creates; `build` is the hosted app build serving staging, which it carries. Live already matching fails with `promotion-empty`. Nothing changes in live until a person signed in to Console with publish permission chooses **Approve and apply** in **Live → Deployments**; an agent cannot approve. Console's **Promote to live** prepares the same promotion. Approval applies it in one transaction and fails with `promotion-changed` when test or live changed since it was prepared; then prepare again. Preparing again supersedes an older pending promotion. Approval creates, empty, each group a quest, offer, leaderboard or referral program names by key and live lacks (members are never copied); a group archived in live is never unarchived, and a `groupMembership` reward or a leaderboard exclusion-group binding naming it fails approval with 409. `domino promotions list|get` read them; `management.promotions.rollback(id)` with a live client prepares a promotion of an earlier applied promotion's definitions and build, archiving every live definition that promotion did not carry; a pickup policy it did not carry goes back to untracked. The CLI saves `domino promote` before sending it; after an uncertain response, run the error's `operations resume` command. Existing attempts retain their pinned releases and promised rewards. When the promotion carries a hosted app build, approval deploys it as the live app; `management.liveApp.get()` (`read`, either environment) answers its address and `state` (`none`, `deploying`, `live` or `failed` with `failure`). An organization admin can serve the live app at their own domain with `management.domains.add(hostname)` and `list`, `get(id)`, `recheck(id)` and `remove(id)` (`read` lists them); see [custom domains](hosted.md#custom-domains). Code reaches live only through a promotion. Live refuses `domino deploy --environment live`, with or without `--preview`, and `management.deployments.preview` with a live client: 422 `promotion-required` from every credential, before the bundle is checked, which the CLI reports as `ENVIRONMENT_UNSUPPORTED`. Deploy to test, then promote. Do not use `--resume` for live approval. Use `domino promote --environment test` (`management.promotions.prepare()`). The deprecated `domino deploy --promote TEST_DEPLOYMENT_ID --environment live` and `management.deployments.promote(id)`, removed in 2.0.0, check that the test deployment was published, then prepare the same promotion of all of test, answered as a pending deployment record whose first warning, code `environment-promotion`, names the promotion; its `id` is the promotion's, which `promotions.get` reads and `deployments.get` and `publish` reject. `deployments.submit` and `deployments.reject` are deprecated as well: they act only on live deployments made before the refusal. Fixture providers simulate verification. `workers-ai` requires a configured service binding. A fixture success demonstrates the flow but does not establish that a real external activity was verified. ## Leaderboard definitions [#leaderboard-definitions] Default-export `defineLeaderboard({ key, title, ledger, score, access, group, includeUnscored, ties, settle })` from `@domino-sdk/relay/authoring`. Pass handles from `defineLedger`, `defineQuest`, and `defineGroup`; add `{ "entry": "src/leaderboard.ts" }` to `leaderboards` in `relay.json`. Use `domino deploy --preview` to inspect definitions and standings before publishing to test. Rows show generated names such as `Participant 1a2b3c4d` while the project's [profile settings](participant-api.md#profiles) keep profiles private; with profiles visible, a member shows the name they chose, else their first X or Discord name the settings list as defaults. `domino stage` and `domino doctor --remote` warn while profiles are private or list no default. Ask the owner whether to show members' X or Discord names, then set it in test (`domino profiles configure`) and run `domino promote`; the owner's steps are in [show member profiles](/docs/build/profiles#turn-profiles-on). ```text import { defineLeaderboard, points } from "@domino-sdk/relay/authoring" import { community } from "./ledgers" export default defineLeaderboard({ key: "weekly", title: "Weekly contributors", ledger: community, access: "participants", score: { kind: "earned", window: { kind: "calendar", period: "week" }, }, settle: { at: "window-end", ties: "shared", prizes: [{ ranks: [1, 3], rewards: [points(community, 100)] }], }, }) ``` Score defaults to `{ kind: "balance" }`. Use `earned({ window, quests })` for an earned score, or `{ kind: "net", window }` for net changes. `earned` takes quest handles. Windows are `all-time`, `fixed` with epoch-millisecond `start`/`end`, `rolling` with `days`, or `calendar` with `period: "day" | "week" | "month"`: the current period of the [project calendar](#project-calendar), so it matches the project's budgets. A calendar window takes no `timeZone`. Access defaults to `participants`; `public` allows anonymous reads through the configured project endpoint. A group handle narrows eligibility. `includeUnscored: true` also lists eligible members with no qualifying points, at score 0 and one shared rank after every member who scored, ordered by member ID; settlements never include them. `ties: "earliest"` orders live equal scores as settlement `earliest` does. `settle` is available only on fixed or calendar earned/net boards. Prize ranges must be ordered and cannot overlap. Rewards are points or tiers from `defineTieredReward`; a prize cannot repeat the same point ledger or tiered reward. A `badge()` or `groupMembership()` prize makes `defineLeaderboard` throw and preview refuse it, naming the reward kind. `shared` awards every member sharing a winning rank. `earliest` orders equal scores by the last qualifying entry that produced the score, then member ID. At cutoff Relay freezes the board version, eligible rows, exclusions (the exclusion group bound at the cutoff, with its members then), ranks and assigned prizes. Review and deliver the frozen result in Console **Leaderboards**, or read it with `management.leaderboards.settlements(board)` and approve it with `management.leaderboards.approveSettlement(settlement, { reason })` (`review` permission). A board that counts an engagement rule's points (the rule pays into its balance and, with `quests`, qualifies through one) first takes a closing measurement: its settlement reads as `pending` with no rows and a `closing` field (`startedAt`, `deadline`) while Relay asks scheduled trackers to resample the content, a sample observed within 30 minutes after the cutoff counts as measured at it, and the settlement freezes when everything is measured or the wait ends, listing content frozen at its last good sample in `unmeasured` (`content`, `member`, `observedAt`). Approval refuses a closing settlement (`settlement-closing`); `retrySettlementMeasurement(settlement)` or `domino leaderboard-settlements retry-measurement` closes it again for another 30 minutes. Each winner receives a completion; its prize points earn no referral bonus. `settle.pool` (optional; `prizes` may then be empty, or left out in `defineLeaderboard`) splits a fixed amount over the frozen eligible rows in proportion to score: `{ amount: "", unit: "points" | { code, decimals }, ledger?, minScore? }`. `ledger` (a ledger handle or key) is required for and only allowed on a points pool, which holds at most 2,147,483,647 points. Members scoring 0 or less, or at or below `minScore`, get nothing; each pool share is `floor(amount × score / total)` in bigint, and leftover base units go one each to the largest remainders, earlier `position` first, so shares sum exactly to the pool. With no eligible score the whole pool is `unallocated`. Settlement reads carry `pool` (`amount`, `unit`, `allocated`, `unallocated`, …) and each row's `share` in base units. Approval pays a points pool's shares as points into its ledger in each member's prize completion (merged into a rank prize's points for that ledger; revocation reverses them). Any other unit moves nothing: Relay never holds or moves tokens, and `management.leaderboards.settlementExport(id)` or `domino leaderboard-settlements export SETTLEMENT` (CSV; `--json` for rows with `share`, formatted `amount` and verified `wallets` `{ chain, network, address }`) is the operator's payout list; a rejected settlement's export lists every share as 0. Correct one with `management.completions.revoke({ completion, reason, freeOccurrence: false })`; leaderboard prize occurrences cannot be freed and paid again. Leaderboards and points remain project/environment-scoped. Custom scoring runs before points ingestion. Management keys with `points` permission can award, spend, adjust, reverse, or set totals through `management.points.record`. Every command requires an action ID and reason; exact retries return the original result. `set` requires the current `expectedTotal` and explicit `earned`/`spend` classification. Reversals reference an original award and correct its original period. Console member detail shows every ledger balance and offers history, award, adjustment and reversal controls to operators with `points`. Do not implement negative clamping, direct database writes, browser awards, or shared organization balances. ## Leaderboard settlement cutoff [#leaderboard-settlement-cutoff] Settlement points must take effect inside the window and be received by its inclusive cutoff. New entries use database time for receipt, independently of app clocks and test-member clock advances. Relay waits for in-flight point writers to commit or roll back before freezing, so an entry received before the cutoff still counts if its transaction commits just afterward. A backdated entry received after the cutoff does not count. Existing frozen settlements remain unchanged. ## External points [#external-points] Run this on a trusted backend with a management key scoped to the project and environment. In a Domino-hosted app's Worker, use `hostedManagementClient(env)` instead of pasting a key; it reads the platform-bound `DOMINO_MANAGEMENT_KEY` and scope. The member must already be a project participant. Quest awards to the board's balance count automatically; use this API for externally verified activity or corrections. ```ts import { createManagementClient } from "@domino-sdk/relay/server" const management = createManagementClient({ baseUrl: "https://relay.domino.run", organization: "my-organization", project: process.env.DOMINO_PROJECT_ID!, environment: "test", getToken: async () => process.env.DOMINO_MANAGEMENT_KEY!, }) await management.points.record({ kind: "award", actionId: "external-activity-42", member: "member_existing", balance: "community", amount: 100, reason: "Verified external activity 42", }) ``` Persist the action ID with the source event and reuse the same payload after an uncertain response. Amounts are safe whole-number integers. Backdating uses `effectiveAt` in epoch milliseconds; future dates fail. Deductions cannot make the balance negative or consume reserved points. Read operations such as `management.points.balance(member, balance)` and `history(member, balance)` require `read` permission. For externally owned totals, dedicate a balance to that source. Read its current total before a `set` command; on an `expectedTotal` conflict, read again and decide whether to issue a new command with a new action ID. A set replaces the whole balance, including any quest awards written there. ## Verify a leaderboard [#verify-a-leaderboard] Publish to test and read through the participant endpoint described in [participant API](https://console.domino.run/docs/reference/participant-api#leaderboard-reads). Award known points to existing test participants and verify the expected ordering, shared ties, signed-in `me`, and access after sign-out. Confirm that retrying one external event does not award twice. For a period board, check an entry inside and outside the window. Report the tested project, environment, board, and observed scores. Treat top-rank prizes as a separate reward workflow with an explicit tie policy and finalization time. Board exclusion changes eligibility only. A board leaves out the members of its exclusion group: its own generated group (key `domino--exclusions--`), created by its first exclusion, or a group several boards share once an operator binds it with `management.leaderboards.bindExclusionGroup(board, { group, reason })`. `management.leaderboards.exclude` and `exclusions` change and read that group, as `management.groups` does; `exclusionGroup(board)` names it. Console or management calls with `publish` permission handle exclusions and [group](/docs/concepts/groups) membership; binding an exclusion group is a definition, made in test and carried to live by a promotion. A group bound as an exclusion group cannot be archived (409 `group-in-use`), and keys starting with `domino--exclusions--` are reserved. Exclusions and group membership are live operator controls: a personal API key with `publish` can change them in live, and Console **Audit history** records each change under the key's owner. A `domino login` session cannot change them in live. Rows show board aliases until the project enables participant profiles (`POST /management/v1/profile-settings`, `publish`); moderating a member's profile (`POST /management/v1/members//profile`) needs `review`. See [profiles](participant-api.md#profiles). ## Referral programs [#referral-programs] For `defineReferral`, invitation qualification, fixed rewards, and selective inviter bonuses, read [referrals](/docs/reference/referrals). Register modules in the manifest's `referrals` array; they publish atomically with quests and leaderboards. Referral bonuses use the existing project points ledger. Inviter, invitee, and milestone reward lists accept points and tiers. A Discord role there fails preview with `Referral programs can grant points and tiered rewards; Discord roles need a quest`; put the role on the qualifying quest. A `badge()` or `groupMembership()` there makes `defineReferral` throw and preview refuse it, naming the reward kind; put it on the qualifying quest too. ## Discord role rewards [#discord-role-rewards] Import `discord` from `@domino-sdk/relay/authoring`. Declare `rewards: [discord.grantRole(roleId)]`. Install `discord-role-reward` for a complete prerequisite-gated example. Its resource setting uses `discord.roles({ assignable: true })`; ordinary eligibility roles use `discord.roles()`. The ledger commits one durable delivery per completion and role. Delivery retries separately from quest evaluation using an idempotent role assignment. Never write to Discord inside evaluate. Pending delivery is bound to the original account/server, so reconnecting a different identity cannot redirect it. Restoring the original connection and permissions permits recovery. The bot needs Manage Roles and a role above the target. Managed/everyone roles cannot be reward roles. Use `relay.me.rewardDeliveries()` for the current member or `management.rewardDeliveries({ member })` for operator inspection. Both answer a page of up to 500 deliveries, newest first (`limit` sets fewer); pass the answer's `nextCursor` back as `cursor` for older ones until it is `null`. Each record contains quest, completion, action, role, status, attempts and retry details. The pages also hold quest announcements: `action` is `grant-role` for a role and `post-message` for an announcement (its role is empty), so show only `grant-role` rows as roles. `management.discordRoles.list()` (`GET /reward-deliveries/roles`, `read` permission) answers every role the project's deliveries have granted in the environment, however old, as `{ roles: [{ roleId, quests, deliveries: { pending, delivered, failed } }] }`. Status is `pending`, `delivered` or `failed`. Relay retries transient Discord errors for about a day. A delivery fails at once when Discord reports a permanent problem: an unknown role, a member who left the server, or missing bot permissions. After fixing the cause, call `management.retryRewardDelivery(id)` with `review` permission. For revoked grants this reconciles removal without restoring the grant. `management.discordRoles.preserve(id, { reason })` (deprecated name: `management.preserveRewardRole`) marks an independently owned assignment as external, preventing automatic removal across shared projects/environments. Console exposes both actions. See [role recovery](/docs/build/integrations#recover-or-preserve-a-role) for CLI routes and ownership limits. Participant UI should show pending/granted/failed status without operator error details. Referral reward definitions still accept points and tier rewards; qualify a role-granting quest if composing them. ### Discord role delivery [#discord-role-delivery] People's guide: [Grant a Discord role](/docs/build/integrations#grant-a-discord-role). * **Recorded with completion.** Completion records a delivery request atomically with the other rewards. A Discord outage does not erase the completion or duplicate points. * **Retried with backoff.** Console's member details and Rewards page show whether a role is granting, retrying or granted. * **Bound to the original account.** Delivery stays bound to the original Discord account and server. A different connection cannot receive an old award. Restoring the original account/server and permissions lets pending delivery retry. * **One membership across projects.** Relay serializes role changes by Discord server, account and role. All accepted, unrevoked completions across projects and Test/Live environments contribute to the desired membership. Revoking one completion cannot remove a role still granted by another. * **Reconciled after revocation.** When the last grant is revoked, Relay removes assignments it owns. It preserves assignments already present before its first grant, including older grants without ownership evidence. The original `status`, `deliveredAt`, `dispatchedAt` and `revokedAt` remain delivery history; `reconciliation` reports the current provider state. * **Verified after changes.** Relay reads membership before and after each change, checks again after 30 seconds, and checks settled memberships hourly. Discord calls have a 10-second timeout. A late provider effect can temporarily disagree with the desired state; later checks repair it. Provider failures retry up to 33 times, with permanent failures stopping earlier. * **Recovery.** A Discord permission rejection while reading membership stops that retry round, as a rejected role write does. Reconciliation after revocation never restores that completion's grant. * **Preserving an external assignment.** Preservation transfers removal ownership across every project and environment sharing that Discord membership. Discord does not identify an independent assignment to an already-present role, so Relay cannot discover that ownership change itself. Preservation prevents automatic removal until the role is absent and there are no accepted grants. ## Announcements [#announcements] `announcements` (beside `rewards`; an array or a function of `{ settings }`) lists `telegram.postMessage({ chat, text, replyToTriggeringMessage })` or `discord.postMessage({ channel, text, replyToTriggeringMessage })` (declare `discord`). Relay posts after the rewards commit, as a `post-message` reward delivery. Placeholders and rules: [Telegram guide](/docs/build/telegram#announce-tips-in-the-group). ## Reward delivery internals [#reward-delivery-internals] * **Role reconciliation.** A Discord role reward commits its delivery request with the completion and retries delivery independently, bound to the original account and server. One writer reconciles each server, account and role against accepted grants across all projects and environments. It removes only Relay-owned assignments after the last justification disappears; preexisting or explicitly preserved assignments stay. * **Redemption ordering.** An offer redemption reserves one declared unit and the member's points before granting the tier entitlement; a failed redemption spends nothing and takes no stock. Redemption and cancellation use the same member payout order as referral reconciliation, so a concurrent referral payout or correction waits instead of partially applying the offer's point spend or refund. ## Hosted server applications [#hosted-server-applications] Quest discovery and private resource artifacts work the same way for static and Worker apps. Keep quest definitions outside the public asset directory. For server-rendered applications, configure the Worker module output and browser asset output separately in `relay.json`; see [hosted apps](/docs/reference/hosted). With `app.routing="transparent"`, staging reserves only its own `/__domino/` paths (see [hosted apps](/docs/reference/hosted)). Omitting the setting preserves legacy routing. The starter owns its `/relay/*` proxy as ordinary application code; custom handlers can take precedence. Ledger names can repeat. Edit names and descriptions in Console, or call `management.pointLedgers.update(ledger, { name, description })` to update an existing ledger in test; in live it fails with 422 `promotion-required` for every credential, Console included, so rename a ledger in test and promote. `settings.pointLedger` also renders a searchable picker; existing text settings need a new type version and instance upgrade. ## Project calendar [#project-calendar] Each project environment has one timezone that sets where days, weeks and months begin for budgets, engagement budgets, streaks and calendar leaderboard windows. It is UTC until set. Declare it in `relay.json` as `"calendar": { "timeZone": "Europe/Budapest" }`, or change it in Console **Settings → General → Project calendar**, or with `management.calendar.configure({ timeZone, reason })` (`domino api POST /calendar body.json` with a body of `actionId`, `timeZone` and `reason`, `publish` permission; live refuses it with `promotion-required`, so change the live calendar through a reviewed deployment of relay.json). `management.calendar.read()` (`GET /calendar`) returns `timeZone`, `today`, `nextBoundary`, `pending` and `declared`. Use a named IANA zone; fixed offsets such as `+02:00` or `Etc/GMT-2` are rejected. A deployment applies relay.json's zone only when it differs from the last deployed declaration, so a later Console change stays until relay.json is edited; the deploy preview's `calendar` shows the scheduled change or `null`. No quest, budget, streak or board names its own zone. A change takes effect at the next midnight of the current zone and never rewrites history. The open day, week or month finishes in the old zone; the next one ends at the new zone's next boundary, so it can be shorter than usual. Streak days stay consecutive across the change. Saving another zone replaces a pending change; saving the current zone cancels it. ## Shared SDK runtime [#shared-sdk-runtime] A quest or quest type bundle imports the SDK instead of carrying its own copy. When the installed `@domino-sdk/relay` ships a runtime, `domino build`, `deploy`, `dev` and hosted builds leave `@domino-sdk/relay/authoring`, `@domino-sdk/relay/schema` and `zod` out of each bundle. `zod` stays out only when your app resolves the version the SDK runtime carries and the quest imports it as `zod`; subpaths such as `zod/v4` or `zod/mini` are bundled into the quest. The bundle records the runtime it imports, and the deployment carries that runtime once. Relay stores it by content and runs every release with the exact runtime it was built against, so upgrading the SDK never changes a published quest. A quest bundle shrinks from about 170 KB to a few kilobytes. Other imports, such as a chain library, are still bundled into each quest that uses them. An older SDK release that ships no runtime builds self-contained bundles, as before. A Relay that predates SDK runtimes refuses a deployment that carries one; `domino deploy` then says to update the Relay, such as restarting an outdated local stack. Trackers, leaderboards, referrals, engagement rules, offers, badges and point ledgers always bundle their dependencies. ## Quest and collection presentation [#quest-and-collection-presentation] A quest's `presentation` callback returns optional `steps`, `image`, `imageAlt` and `requirements` alongside instructions, video and quiz. Each ordered step is `{ title, description?, action?: { kind: "link", url, label } }`; link actions require HTTPS. Images use HTTPS or a same-site `/` path. Steps and account/wallet hints never replace evaluator verification. Use `requirements: [{ kind: "account", provider: "google" }, { kind: "wallet" }]` for hints not derivable from `connections`. Account providers use the existing sign-in providers; wallet hints may include `chain` with an existing wallet family. Participants read these through `view.presentation.requirements`. `relay.json` collection declarations accept `{ key, title, description?, image?, imageAlt? }`. Console's published placement controls each collection's quest order. Deploying explicitly authored optional metadata replaces its current value; omitting it retains the current value. Console edits presentation on both type instances and code-authored quests. `management.quests.updateSettings({ quest, expectedRelease, set: {}, reset: [], presentation: { steps: [], image: "/image.png", imageAlt: "Entrance" } })` replaces steps, image and account hints together while retaining instructions, video and quiz. Omitted fields clear the corresponding override; `presentation: null` restores the code defaults. These overrides persist across deployments. Type instances carry the same optional `presentation` object in a catalog draft's quest changes; omitting it restores the type defaults. Collection draft changes carry their optional description/image fields with title and ordered `quests` IDs. Use the existing preview and publish workflow. ## Badges [#badges] Define a project achievement, then attach it to quest rewards alongside points or Discord roles: ```ts import { defineBadge, badge, defineQuest, manual, decision, settings, once } from "@domino-sdk/relay/authoring" export const firstStep = defineBadge({ key: "first-step", name: "First step", description: "Complete your first activity", image: "https://example.com/badges/first-step.png", }) export const welcome = defineQuest({ key: "welcome", title: "Join an activity", settings: settings.object({}), completion: once(), trigger: manual(), rewards: [badge(firstStep)], evaluate: async () => decision.accept(), }) ``` Include the definition in `relay.json` through a module entry or inline data. Module entries accept `exportName` when the file exports several resources: ```json { "badges": [{ "entry": "./quests/welcome.ts", "exportName": "firstStep" }], "quests": [{ "entry": "./quests/welcome.ts", "exportName": "welcome" }] } ``` Run `domino deploy --dry-run` to check the bundle, then `domino deploy --yes` to publish it to test. Then run `domino promote` and have a person approve the promotion in Console for live. Promotion copies definitions and rewards, not member awards. Redeploying a key updates its name, description and image without changing identity or reopening an archived badge. Each member holds a badge once. A completion retry cannot duplicate it. Completion revocation removes only that completion's claim; other quest or manual claims keep the badge. Manual revoke removes all current claims. A new legitimate completion can award the badge again. Archiving prevents new reward choices and manual grants while preserving existing awards and published quests. Use Console **Rewards** to create, edit, archive or restore definitions, or these CLI commands: ```sh domino badges create first-step --name "First step" --image https://example.com/badge.png domino badges list --json domino badges update first-step --name "First activity" --image https://example.com/badge.png --reason "Clarify the achievement" domino badges archive first-step --reason "Retire this achievement" domino badges unarchive first-step --reason "Resume this achievement" domino badges member MEMBER --json domino badges award MEMBER first-step --reason "Verified by a reviewer" domino badges revoke MEMBER first-step --reason "Correct an award" ``` Definitions require `publish` permission; live refuses direct definition changes from every credential, Console included, with `promotion-required`, so live badges come from a promotion from test. Manual awards and revokes require `review` permission and a reason, and work in test and live. The management client exposes `management.badges.list/create/update/archive/unarchive/member/award/revoke`. CLI writes retain their operation ID for uncertain-answer recovery. Badges are independent achievements. Apps compose tiers and levels from badges, points or ledgers. In SDK 1.x, the serialized reward uses the existing `kind: "tier"` envelope with `reward.badge`; use `badge(...)` to author it rather than constructing that envelope. Quest participant reads include the badge record at `reward.badge`. ## Group membership rewards [#group-membership-rewards] `groupMembership(group)` is a quest reward that adds the member to a [group](/docs/concepts/groups). Use it so approving an application is the one step that admits someone to a curated program, such as approved creators that an `inGroup(...)` prerequisite gates: ```ts import { defineGroup, groupMembership, defineQuest, settings, manual, evidence, once, decision, } from "@domino-sdk/relay/authoring" export const creators = defineGroup({ key: "approved-creators", title: "Approved creators" }) export default defineQuest({ key: "creator-application", title: "Apply to the creator program", settings: settings.object({}), trigger: manual({ input: evidence.url() }), completion: once(), rewards: [groupMembership(creators)], review: () => ({ enabled: true }), async evaluate() { return decision.review({ reason: "Check the applicant's portfolio" }) }, }) ``` * **The group.** Publishing creates the group if the environment lacks it, empty and titled from the handle: a deployment in test, an approved promotion in live. A release whose reward names an archived group fails with 422 `validation-failed` (409 `conflict` on promotion approval); unarchive it first. Publishing never renames an existing group or changes its members. * **Delivery.** When the completion's rewards are delivered (on acceptance, or when a reviewer approves), Relay adds the member as an operator's add does: the audit history records `group-member-added` with the quest and completion as the reason, prerequisite-only automatic quests the member can now enter are checked, and a member already in the group stays as they were. * **Revocation.** Revoking the completion (`management.completions.revoke`) removes the membership only when nothing else backs it: no operator added the member and no other unrevoked completion's group reward did. The result's `groups` lists `{ group, stillMember }`. An archived group keeps the member. Operators can always remove members in Console **Groups** or with `domino groups remove`. * **Archiving.** A group that the current release of a quest that is not archived rewards cannot be archived: 409 `group-in-use`, with `quests` naming them. Remove the reward or archive the quest first. * **Reads.** The reward uses the stable `kind: "tier"` envelope with `reward.group` (the key) in SDK 1.x; author it with `groupMembership(...)`. Participant quest reads show it with `label` set to the group's title and `reward.group` as `{ key, title }`, using the title the handle gives. It never becomes a tiered-reward entitlement. Only quest rewards can add members: offers, referral programs and leaderboard prizes refuse it. ## Dashboards [#dashboards] Default-export `defineDashboard({ key, title, description?, defaultRange?, position?, sections })` from `@domino-sdk/relay/authoring` and add `{ "entry": "dashboards/growth.ts" }` to `dashboards` in `relay.json`, with optional `exportName`, at most 20 entries. Each section has an optional `title` and its `panels`; each panel has `key`, `title`, optional `description`, `metric` (an ID from `domino analytics metrics`), optional `breakdown`, optional `filter` (`{ quest: ["a", "b"], group: "vip" }`), `ledger` for points metrics, `chart` (`number`, `line`, `bar` or `table`) and optional `width` (1 to 4 on a 4-column grid). `defaultRange` (`7d`, `30d`, `90d` or `all`) defaults to `30d`; `width` defaults to 1 for `number`, 2 for `line` and `bar`, and 4 for `table`. `position` orders dashboards in lists, those without one last, then by title. The types accept only the breakdowns, filters and charts the panel's metric allows, and a `ledger` or filter key can be a key or a handle from a `define…` function. ```ts import { defineDashboard } from "@domino-sdk/relay/authoring" export default defineDashboard({ key: "growth", title: "Growth", sections: [ { panels: [ { key: "sign-ups", title: "Sign-ups", metric: "members.signUps", chart: "number" }, { key: "xp", title: "XP awarded", metric: "points.awarded", ledger: "xp", chart: "line" }, { key: "by-quest", title: "Completions by quest", metric: "quests.completions", breakdown: "quest", chart: "table" }, ], }, ], }) ``` `domino deploy` refuses a dashboard with a 422 and its own code (see [errors](/docs/reference/errors)): an unknown metric (`unknown-metric`); a breakdown or filter the metric doesn't take, or a `number` panel with a breakdown (`breakdown-not-accepted`, `filter-not-accepted`); a `table` panel without one (`breakdown-required`); a chart the metric can't draw (`chart-not-accepted`); a points metric without `ledger`, or `ledger` on another metric (`ledger-required`, `ledger-not-accepted`); a quest, tracker, ledger, offer, referral program or leaderboard key the environment doesn't have (`unknown-reference`); a referral metric in a project with no referral program (`referral-program-required`); two dashboards, or two panels of one dashboard, with one key (`duplicate-key`); more than 24 panels (`too-many-panels`); or more than 20 dashboards that aren't archived (`too-many-dashboards`). Archived resources are valid references, and their history keeps counting. Groups are not checked at deploy: a panel filtered on a missing group reads `group-not-found` while the other panels read. The preview's `dashboards` lists each one with `change`: `created`, `updated`, or `unchanged`, which publishes no new version. Omitting a dashboard never deletes it; archive it with `domino dashboards archive KEY --reason …` in test. Deploying an archived dashboard's code leaves it archived. A promotion carries dashboards to live by key, with no environment bindings. Read one back with `domino dashboards get KEY` to check its numbers; see [dashboards](automation.md#dashboards). # CLI automation contract (/docs/reference/automation) Scripts, CI jobs and agents can rely on everything on this page: command names, options, exit statuses, error codes and the JSON result and error shapes. From 1.0 these follow semver. New commands, options, error codes and result fields can arrive in a minor release, so ignore fields you do not know. Human-readable output, messages and help text are prose and may change in any release; branch on `code` and the exit status, never on a message. The machine-readable contract ships with the CLI in `node_modules/@domino-sdk/relay-cli/cli/contract/`. `contract.json` lists every command with its arguments, options, stability and `recovery` (see [Recover by command](#recover-by-command)), plus the exit statuses, error codes, environment variables and scope precedence. `schemas/` holds JSON Schemas (draft 2020-12) for each command's result (`schemas/results/.schema.json`), the error and the stderr events, and for the files the CLI reads: `files` in `contract.json` maps `relay.json` and the checkout's `domino.json` to their schemas in `schemas/files/`. Both files are strict, so a key the CLI does not know fails; a later release still accepts every file an earlier one wrote. Result schemas accept fields they do not name, so a JSON Schema validator such as Ajv accepts a result a later release extends; only the variants of a `oneOf`, such as a quest's `requires` entries, reject unknown fields. `fixtures/` has an example of each. `index.mjs` exports the same schemas as zod objects by name (`schemas`), with the registry of exit statuses and error codes, for tooling that runs in Node. ## Client identification [#client-identification] Management API calls and CLI device sign-in calls send `X-Domino-Client: @domino-sdk/relay-cli/ (node)`, using the installed CLI's manifest version. The server records validated identities in public request logs. Client metrics use fixed family/runtime labels; exact versions do not create metric labels. This header grants no access and does not pin an API version. CLI calls retain their existing JSON contracts and do not claim the SDK's `tolerant-v1` response capability. Server additions must remain compatible with older and unidentified clients. ## Run a command for automation [#run-a-command-for-automation] `domino storage list --json` returns `{ "stores": [...] }` in test, or live's stores with `--environment live`. Each store includes `kind`, `name`, `declared`, and `createdAt` (null until provisioned); active declarations also include `description`. Unknown future store kinds are preserved in results; consumers should tolerate them. Provider IDs are never returned. See [hosted storage](hosted.md#app-storage) for declaration and retention rules. The result schema and example ship as `storage-list.schema.json` and `storage-list.json` in the contract's result directories. With `publish`, `domino storage tables NAME`, `rows NAME TABLE [--cursor C]`, `keys NAME [--prefix P] [--cursor C]` and `get NAME KEY` print the management API's browse answers (`storage-tables`, `storage-rows`, `storage-keys`, `storage-get` schemas); `rows` pages by 50 and `keys` by 100, passing a page's `nextCursor` back as `--cursor` for the next page. `domino storage delete KIND NAME` removes a store the serving build no longer declares and asks for confirmation unless `--yes` is given; a declared store fails with 409. `domino storage clear KIND NAME` empties SQL tables (`--table`, or `--all-tables` with `--keep`) or deletes KV keys (`--prefix` or `--all-keys`) in test only; `--dry-run` counts, and without `--yes` it fails with `INVALID_ARGUMENT` when it cannot prompt. Its result is the API's clear answer; for KV, `keys` sums every batch. Pass `--json`. The CLI then never prompts, prints the result as one JSON value on stdout, and reports a failure as one JSON object on the last line of stderr: ```sh if result=$(domino deploy --preview --environment test --json 2>errors.log); then echo "$result" | jq .deployment else status=$? tail -n 1 errors.log | jq -r .code fi ``` * **stdout** carries only the result. With `--json` that is exactly one JSON value followed by a newline, including `[]` for an empty list and `null` when the API answered with no content. Keep stdout and stderr apart when parsing (no `2>&1`). A reader that closes stdout early, as `| head` does, ends the CLI with status 0. * **stderr** carries progress, warnings, prompts and the error. With `--json`, lines before the error are prose or JSON events (see [Events](#events)); only the last line is the error. * **Secret settings** never appear in output. `deploy` prints each release's `deploymentDefaults`, `customValues` and `effectiveSettings` without them, and lists the set ones in `withheldSettings`; `quests list`, `releases get` and the other reads do the same ([secret settings](authoring.md#secret-settings)). A deploy log is safe to keep. * **Prompts** appear only when stdin and stderr are both terminals, `CI` is unset, and neither `--json` nor `--no-interactive` is given. A missing value that would be prompted for fails with `INPUT_REQUIRED` instead. `deploy`, `stage` and `secrets delete` ask for confirmation only when they can prompt; `--yes` skips it. `members erase`, which can't be undone, asks when it can prompt and otherwise fails with `INVALID_ARGUMENT` unless `--yes` is given; so does `members ban`, which blocks the member at once (its `--dry-run` previews without asking). ## Exit statuses [#exit-statuses] | Status | Outcome | Meaning | | ------ | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 0 | `success` | The command completed. The result is on stdout. | | 1 | `failed` | The command failed and nothing it was asked to change is uncertain. `doctor`, `check` and `migrate` also print their result on stdout, so read both. | | 2 | `failed` | Usage error: unknown command or option, or a missing or invalid argument. The CLI stopped before contacting the API or changing files. Fix the invocation; do not retry it. | | 3 | `uncertain` | A change was sent, but whether it took effect is unknown. Resume it with the error's `operation`, or check the state before retrying. | | 130 | `cancelled` | Cancelled by SIGINT (Ctrl-C) or at a prompt. If a change had already been sent, the status is 3 instead; see [Cancellation](#cancellation). | | 143 | `cancelled` | Cancelled by SIGTERM. If a change had already been sent, the status is 3 instead. | Every error code maps to one status, except `CANCELLED`: 130 for SIGINT or a prompt, 143 for SIGTERM. `outcome` in the error and the exit status always agree. ## Errors [#errors] With `--json`, a failure's last stderr line is: ```json { "error": "Publication of dep_1 was not confirmed: The API did not confirm the change (ECONNRESET). It may have taken effect. Resume with domino deploy --resume dep_1.", "code": "OUTCOME_UNCERTAIN", "outcome": "uncertain", "details": { "cause": "NETWORK_ERROR" }, "operation": { "kind": "deploy.publish", "id": "dep_1", "scope": { "apiUrl": "https://relay.domino.run", "organization": "org_acme", "project": "spring", "environment": "test" }, "resume": [ "deploy", "--resume", "dep_1", "--api-url", "https://relay.domino.run", "--organization", "org_acme", "--project", "spring", "--environment", "test" ] } } ``` | Field | Present | Meaning | | ----------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `error` | always | A message for people. It may change. | | `code` | always | One of the stable codes below. | | `outcome` | always | `failed`, `uncertain` or `cancelled`, matching the exit status. | | `status` | API answers | The management API's HTTP status. | | `apiCode` | API answers | The management API's own code, listed in [API error codes](/docs/reference/errors). Unknown API codes are kept here; `code` still classifies them. | | `details` | some codes | Code-specific data, such as the fields of `MANIFEST_INVALID` or the Git phase of `CHECKOUT_GIT_FAILED`. | | `operation` | resumable change | Only on `OUTCOME_UNCERTAIN` and `CANCELLED`: what was sent and how to finish it. See [Resume an uncertain change](#resume-an-uncertain-change). | Without `--json` the same failure is `Error: `, then, for a field error such as `MANIFEST_INVALID`, one indented `: ` line per invalid field, then `Resume: domino …` when there is an operation. A failure from the typed management client follows the same codes. A refusal is classified by its HTTP status, with the API's code in `apiCode`. An `apiCode` of `promotion-required` is classified as `ENVIRONMENT_UNSUPPORTED` instead. A change that was sent and then lost, timed out or cancelled, or that was answered with 500, 502 or 504, is `OUTCOME_UNCERTAIN`. Input the client refused before sending is `INVALID_ARGUMENT`. Minor releases can add error codes and operation kinds, and the schemas accept them. Treat a code you do not know by its `outcome` and the exit status. ### Error codes [#error-codes] | Code | Exit | Meaning | | ------------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `UNKNOWN_COMMAND` | 2 | No command or subcommand has this name. | | `UNKNOWN_OPTION` | 2 | The command does not accept this option. | | `INVALID_ARGUMENT` | 2 | An argument, option value or piped input is invalid, there are too many arguments, or options conflict. | | `INPUT_REQUIRED` | 2 | A required argument, option, subcommand or piped input is missing, and the CLI cannot prompt for it. | | `AUTH_REQUIRED` | 1 | No credential. Run `domino login`, or set `RELAY_MANAGEMENT_TOKEN`. If a sign-in is saved only for another API URL, `details.savedApiUrls` lists those URLs; pass one as `--api-url`. | | `AUTH_INVALID` | 1 | The API rejected the credential (HTTP 401), or it belongs to another organization. | | `PERMISSION_DENIED` | 1 | The credential lacks permission for this action (HTTP 403). | | `LOGIN_FAILED` | 1 | Browser sign-in failed, expired, or was approved for another organization. | | `UNATTENDED_LOGIN_UNAVAILABLE` | 1 | Browser sign-in needs a person, and `DOMINO_AGENT_MODE=unattended` says none is present. Set `RELAY_MANAGEMENT_TOKEN` instead. | | `SCOPE_INVALID` | 1 | A scope value from the environment, relay.json or the checkout is malformed. `details.source` says where. | | `SCOPE_REQUIRED` | 1 | Several organizations or projects match, or a resume lacks part of its pinned scope. Pass the option in `details.option`. | | `SCOPE_NOT_FOUND` | 1 | The credential cannot reach the selected project in the selected environment, or any project. | | `SCOPE_PLACEHOLDER` | 1 | relay.json still names a starter's placeholder organization (`local`). Run `domino checkout`, or pass `--organization` and `--project`. `details.file` names the manifest. | | `PROJECT_MOVED` | 1 | The project moved to another organization. See [troubleshooting](troubleshooting.md#scope-and-installation). | | `ENVIRONMENT_UNSUPPORTED` | 1 | The command does not run in this environment, such as `deploy --promote` in test, `promote` in live, or `deploy --resume` and `deploy rollback` in live. A definition change in live gets it too, with `apiCode: "promotion-required"` and `status: 422`; make the change in test. | | `PROJECT_NOT_FOUND` | 1 | No relay.json was found, or the `--config` file does not exist. | | `MANIFEST_INVALID` | 1 | relay.json is invalid. `details` lists each field with a suggestion. | | `PROJECT_INVALID` | 1 | The project lacks configuration the command needs, such as `app` in relay.json. | | `BUNDLE_FAILED` | 1 | Bundling the configured quests, types, engagement rules or images failed. | | `LOCAL_CONFLICT` | 1 | Local files are in the way: a directory exists, a file would be overwritten, or changes are uncommitted. | | `CHECKOUT_GIT_FAILED` | 1 | A Git step of `checkout` or `create` failed. See [troubleshooting](troubleshooting.md#scope-and-installation). | | `CHECK_FAILED` | 1 | `check` found blocking diagnostics, listed on stdout, or the app's check script failed. | | `DOCTOR_UNHEALTHY` | 1 | `doctor` found a check with status `error`. Its result is on stdout. A missing relay.json is a warning, not an error. | | `MIGRATION_BLOCKED` | 1 | `migrate` found conflicts and wrote nothing. Its result is on stdout. | | `BAD_REQUEST` | 1 | The management API rejected the request as invalid (HTTP 400). | | `NOT_FOUND` | 1 | The resource does not exist (HTTP 404). | | `CONFLICT` | 1 | The resource is not in a state that allows the change (HTTP 409). | | `REJECTED` | 1 | The API refused the change (HTTP 422 or another 4xx). | | `RATE_LIMITED` | 1 | Too many requests (HTTP 429). Wait before retrying: `details.retryAfter`, when the API sent `Retry-After`, is how long in milliseconds. | | `SERVER_ERROR` | 1 | The API failed while reading (HTTP 5xx), or answered 501 or 503. `details.retryAfter`, when present, is how long to wait in milliseconds. | | `PREFLIGHT_FAILED` | 1 | Hosting is not ready for this project. The message lists the blocking checks. | | `STAGE_BUILD_FAILED` | 1 | The cloud build failed. Read it with `domino logs `. | | `NETWORK_ERROR` | 1 | The API could not be reached before any change was sent: DNS, connection, TLS or a redirect. Or a read's answer broke off, or a deployment preview (which publishes nothing) lost its connection. | | `TIMEOUT` | 1 | A read, or a deployment preview (which publishes nothing), did not finish within 30 seconds, retries included. | | `INVALID_RESPONSE` | 1 | The API answered a read with something the CLI cannot use. | | `OUTCOME_UNCERTAIN` | 3 | A change was sent, then the connection failed, timed out, was interrupted, or the API answered 500, 502 or 504. `details.cause` says which. | | `CANCELLED` | 130 or 143 | Interrupted or declined before any change was sent: 130 for SIGINT or a prompt, 143 for SIGTERM. For a signal, `details.signal` names it. | | `NODE_VERSION_UNSUPPORTED` | 1 | Node is older than the CLI supports. `details.minimumVersion` is the required version (such as `24.5.0`), `details.minimum` its major version, `details.current` the running one. Install the latest release of that major and rerun. | | `COMMAND_FAILED` | 1 | Any other failure. Read the message. | ## Retries and deadlines [#retries-and-deadlines] The CLI sends every management request through the same transport as `createManagementClient` from `@domino-sdk/relay/server`, so the two share these deadlines, retries and failure classes; the CLI never follows a redirect. Every request has 30 seconds, retries and the answer's body included. The CLI sends a request again, at most three times in all, only when the API applies a repeat at most once: reads, `deploy` publishing its test preview, promotion to a live review request, a live deployment submission by preview ID, `stage` submitting its build request ID, `quests archive` and `quests unarchive` with their action ID, `groups create`, `groups add` and `groups remove` with their action ID, `webhooks create` and `webhooks rotate-secret`, `webhooks pause`, `webhooks resume` and `webhooks replay` (which send an action ID, generated when you pass no `--action-id`), every saved resource change (see [Resource commands](#resource-commands)) and every `--action-id` change, and project creation with its saved action ID. Creation also retries repository provisioning, which reserves one repository per project. It retries when no answer arrived, when the answer broke off after its headers, on HTTP 500, 502, 503 and 504, and on a 429 whose `Retry-After` fits in the time left. If an error body is cut off, its status still controls retries and its `Retry-After` is kept in `details.retryAfter`. It waits `Retry-After` when the API sent one, and otherwise up to 0.25 seconds, then up to 1 second. A failure that remains is the one reported. Other changes, including `domino api` with any method but GET, are sent once, unless the answer is 503 with `Retry-After`: Relay answers that only to a request it refused before doing anything, so every request is sent again after it. `deploy`'s preview request is sent once too, but it only saves a draft, so a timeout or lost answer there is `TIMEOUT` or `NETWORK_ERROR`, never uncertain: nothing was published. An uncertain response includes `details.recovery: "reconcile"` unless it names a saved operation. Read the affected resource before deciding whether to repeat the change, because an `actionId` alone does not prove a route keeps receipts; the command's `recovery` in `contract.json` says when a repeat is safe. A timeout, an unreadable answer and a refusal such as a 400 or 409 are never retried. ### Recover by command [#recover-by-command] Each command in `contract.json` has a `recovery`: what to do when it exits 3 with `OUTCOME_UNCERTAIN`. Read it before the run, because some values need an ID you pass up front. `recoveries` in `contract.json` describes each value. | `recovery` | Commands | After an uncertain change | | ----------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `resume` | Saved changes: every command with `--operation-id`, `deploy`, `stage`, `operations resume` | Run the error's `operation.resume`. A run with `--action-id` instead of a saved operation names no operation; repeat it with the same `--action-id`. | | `replay` | Commands with `--action-id` and no saved operation, and changes that set a state, such as `groups update` or `billing cancel` | Repeat the same command, with the same `--action-id` when it takes one. The API applies it once. Without `--action-id`, a repeat is a new change. | | `reconcile` | Every other change, including `domino api` | Read the affected resource, as the table below says, before deciding whether to repeat it. | | `null` | Reads and local commands | The command changes nothing in the API and never exits 3. | More values may be added in a minor release; treat a value you do not know as `reconcile`. The error's own `details.recovery` stays `reconcile` whenever it names no operation, because the CLI cannot tell whether you passed the ID. ## Rate limits [#rate-limits] Domino sets no general quota on management calls: none per key, per project or per organization. Test-member calls (`management.testMembers`, `domino test-members`) and attempt reads have no limit of their own, except linking an X account by handle, below. This describes today's behavior, not a promised throughput, and limits may be added. These calls have their own limits, because each spends a shared outside quota or starts something costly. Past a limit they fail with HTTP 429 `rate-limited` (`RATE_LIMITED`). "Per caller" counts, within one organization, each person once, whichever session or personal key they use, and each automation, service or hosted app key on its own. | Call | Limit | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | X handle lookup: `management.x.lookup`, `domino x lookup HANDLE`, and linking a test member's X account by `username` (`test-members link-account --username`) | 30 a minute per caller and 300 an hour per organization, shared | | X search preview: Console, `management.trackers.preview`, or `domino api POST /tracker-preview` | 6 a minute per caller and 60 an hour per organization | | `domains add`, `domains recheck`, `domains remove` | 30 an hour per organization, together; one recheck per domain every 10 seconds | | Starting `domino dev` | 10 preview starts and 10 development keys a minute per caller | | `domino feedback` | 10 an hour per caller | | Presenting a personal key (`ak_…`) or an MCP OAuth token that Clerk does not recognize (a mistyped or made-up value; expired, revoked and other refused credentials don't count), on the management API, `git` or MCP | 30 every 10 minutes per client address (an IPv4 address or an IPv6 /64), counted by each API server on its own; past it, every `ak_` and MCP token from that address answers 429 until the window ends. A refused value presented again within a minute can be refused without being checked again. | Any call can still answer 429 or 503, with `Retry-After` when the wait is known. Honor it: the CLI and the management client retry within the call's deadline as described in [Retries and deadlines](#retries-and-deadlines), and a failure that remains reports the wait in `details.retryAfter`. To run many calls, such as a rehearsal with hundreds of test members, keep a fixed number in flight, give each write that takes an `actionId` a deterministic one so a retry replays instead of repeating, and slow down when a 429 comes back. ## Resume an uncertain change [#resume-an-uncertain-change] A change the CLI cannot confirm exits 3 with `OUTCOME_UNCERTAIN`, after any retries above. When the error has an `operation`, run `domino` with its `resume` array as the arguments, adding `--json`. `operation.scope` pins all four scope values (API URL, organization, project and environment), and `resume` either repeats the scope as flags or reads it from the saved operation journal. It runs in the original scope from any directory and never contains a credential. Project creation uses an empty `scope.project` until the server assigns one; `operations show` then reports the created project separately. A resume (`deploy --resume`, `stage --request-id`) runs only in a pinned scope: each of `--api-url`, `--organization`, `--project` and `--environment` must come from a flag or its `RELAY_*` variable. Otherwise it fails with `SCOPE_REQUIRED` and `details.missing` lists the flags to add; relay.json and the checkout's scope are never used for a resume. An operation is reported only when the CLI knew all four values when it sent the change. | `operation.kind` | Started by | `id` | Resuming | | ---------------------- | --------------------------------------------------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `deploy.publish` | `domino deploy` in test | the deployment preview | `deploy --resume ` publishes that test preview, or returns its committed result. It never creates a second release. Live requests are approved in Console and have no CLI resume command. | | `deploy.promote` | `domino deploy --promote` in live (deprecated; use `domino promote`), in the checkout's scope | the promotion action UUID | `operations resume `, in the scope it journaled, returns the pending promotion that action prepared, in the deployment record shape, or prepares it once. Approval still happens in Console. | | `deployments.rollback` | `domino deploy rollback` in test | the rollback action UUID | `operations resume ` returns the deployment that action published, or rolls back once. | | `promotions.prepare` | `domino promote` in test | the promotion action UUID | `operations resume ` returns the promotion that action prepared, or prepares it once. Approval still happens in Console. | | `stage.build` | `domino stage` | the build request ID | `stage --commit --request-id ` returns the build that request created, or creates it once. It needs no checkout: it skips the hosting preflight, the uncommitted-changes check, the missing-secret warnings and the wallet sign-in warning. A first submission the API refuses as given (400, or 422 such as `commit-not-found` for a commit the hosted repository lacks) saves no operation and prints no retry: fix the cause, then stage again. | A `deploy.publish` or `deploy.promote` operation sends `POST /deployments/:deployment/publish` or `/promote`, with the deployment in the path, whichever CLI release saved it. ### Recover after restarting the CLI [#recover-after-restarting-the-cli] The CLI saves project creation, quest archive/unarchive, group creation and membership changes, resource changes, webhook endpoint creation and secret rotation, build submission, deployment publication, and live promotion before sending the mutation. The journal lives in `$RELAY_CONFIG_DIR/operations`, or `~/.config/domino/operations`. Keep that directory between CI invocations. Journal resume commands include `--directory` with the exact journal path; pass that option when using a copied journal elsewhere. Existing stage and deploy resume commands keep their pinned scope flags. Files are private to the local user and contain the exact request inputs, scope, actor and committed results, never authentication credentials. A webhook create or rotation result keeps its signing secret only until the CLI has written it to stdout once, when the operation completes (or the next `show` or `resume`, if that output failed); the record then keeps the result with `secretRedacted: true`, and `show` adds a `note` pointing to `domino webhooks rotate-secret`. Listing and error output omit request inputs. ```sh domino operations list --json domino operations show OPERATION_ID --json domino operations resume OPERATION_ID --json domino operations forget OPERATION_ID --json domino operations prune --older-than 30 --json ``` `list` and `show` work offline. `resume` uses the saved API URL, organization, project, environment and payload; it ignores checkout configuration and `RELAY_*` scope variables. Explicit conflicting scope flags are rejected. Authenticate for the saved API origin as the original actor. A completed operation returns its cached committed result without another mutation. For a build, that result acknowledges submission; use `domino builds` or the existing `stage --request-id` path to inspect completion. Nothing expires saved operations on its own. `forget` removes one record, pending or complete; a forgotten pending operation can no longer be resumed. `prune` removes completed operations last changed more than `--older-than` days ago (default 30; `0` removes every completed one) and answers `{ cutoff, removed }`. It leaves pending operations, and records another process holds or that cannot be read. Use `--operation-id UUID` on `create`, `quests archive`, `quests unarchive`, `groups create`, `groups add`, `groups remove` or any saved resource change when your automation supplies the ID; a resource change sends it as its `actionId`. Reusing it with changed inputs, actor or scope fails with `CONFLICT`. Without this flag, a new invocation starts a new operation; use `operations resume` for recovery. Creation saves the server-assigned project ID before repository setup. `create` resumes its own operation while a step ends uncertain because it timed out or lost its connection, or answers 502, 503 or 504: it backs off from 2 to 15 seconds for up to 5 minutes in all, holding the operation, and succeeds with the normal result. With `--json`, stdout still carries one result, and a failure is one error object on stderr. After 5 minutes it fails as before, with `operation`: `OUTCOME_UNCERTAIN` says Domino may still be setting up the project, and an API that stayed unavailable is a `SERVER_ERROR`. Either way, run its `resume`. A definite refusal fails at once. If setup or Git checkout fails, the error still includes `operation` and its exact resume command, even when the outcome is a known failure with exit 1. Resuming provisions the same project's repository and finishes its checkout. It clones into an operation-owned sibling directory, saves the finished checkout and renames it into place. An interrupted clone with local changes is preserved and reports `LOCAL_CONFLICT`; preserve those edits before resolving the conflict. A live process owns the operation until it exits; another resume reports `LOCAL_CONFLICT`. Cancellation keeps the journal, and the next process recovers a stopped owner's lock. | Mutation | Recovery | | | | | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | -------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `create` | Saved project-creation receipt, repository and checkout checkpoints. | | | | | | `quests archive`, `quests unarchive` | Saved action ID, exact lifecycle request and scope. | | | | | | `groups create`, `groups add`, `groups remove` | Saved action ID, exact request and scope (`group.create`, `group.member`). `groups update`, `archive` and `unarchive` set a state, so repeating one is safe. | | | | | | Resource changes: `points award\|spend\|adjust\|set\|reverse`, `trackers create\|update\|ingest`, `leaderboards exclude\|include\|bind-exclusion-group`, `redemptions cancel`, `entitlements restore`, `completions restore`, lifecycle verbs and the rest in [Resource commands](#resource-commands) | Saved request and scope; the operation ID is the request's `actionId`, so a resume repeats the same request, which the server applies once. A new resource command saves its management client method, and `operation.kind` names it, such as `points.record`; a lifecycle verb, `trackers refresh`, \`quests enroll | withdraw`, `leaderboard-settlements reject | retry-measurement`, `members unlink-identity | relink-identity | identity-sign-in`and the deprecated`members relink`save their route and body as`management.request`and print the API's answer as before.`point-ledgers update\` sets a state, so repeating it is safe. | | `stage` | Saved build request ID and commit; existing `--request-id` remains supported. | | | | | | `deploy` test publication | Saved preview ID; existing `--resume` remains supported. | | | | | | `deploy --promote` | Run `domino promotions list` before repeating. A pending promotion may already exist; approval and rejection happen only in Console **Live → Deployments**. | | | | | | `promote` | Saved action ID; `operations resume ` returns the same promotion. `domino promotions list` shows whether a newer one superseded it. | | | | | | `deploy --preview`, image uploads | Inspect `domino releases list` and the project's image/catalog state before rebuilding; publication is separate. | | | | | | `secrets set`, `secrets delete` | Inspect `domino secrets list`; values are never read back. Confirm the intended value before replacing it. A `secrets set` of an app login key (`di_…`) that the key check refused (`INVALID_ARGUMENT`) stored nothing. | | | | | | `domains add`, `domains recheck`, `domains remove` | Inspect `domino domains list`. Adding a hostname the project already has fails with `already-exists`; re-checking twice within 10 seconds fails with `rate-limited`. | | | | | | `tokens create`, `tokens scope`, `tokens revoke` | Inspect `domino tokens list`. An uncertain `tokens create` may have issued a key whose secret you never saw: revoke the unconfirmed key with that name before creating another. A `domino login` session creates and scopes test keys only: a live `tokens create` or `tokens scope` is refused with `console-approval-required` and changes nothing, so ask the user to create the live key in Console Settings → API keys. | | | | | | `billing checkout`, `billing topup`, `billing portal` | Inspect `domino billing show`. Each returns a link to a Stripe page; the project changes only once someone pays there. A repeated checkout in the same ten-minute window returns the same Checkout session, and one for a project whose subscription holds its plan fails with `subscription-exists`. | | | | | | `billing change`, `billing cancel`, `billing resume` | Inspect `domino billing show`. Each is safe to repeat: asking for the plan the subscription is already on, or one already held for the renewal, changes nothing more, and cancelling or resuming twice leaves it as the first call did. `subscription`'s plan moves once Stripe reports the change, usually within a minute. | | | | | | `starters export` | Changes nothing in the project; it only writes local files. A failure while writing can leave a partly written directory, which a repeat refuses with `LOCAL_CONFLICT`: delete that directory, or export into another one. | | | | | | `feedback` | Inspect the Linear issue list for the submitted report before sending another. | | | | | | `logout` | Inspect `domino whoami`; revoke or sign in again as appropriate. | | | | | | `api POST/PUT/PATCH/DELETE` and management writes without documented receipts | Read the affected resource first. An `actionId` field alone does not make replay safe. For webhook writes, list endpoints and deliveries before recreating, rotating or replaying; a `webhooks replay` sent with `--action-id` is repeated with the same ID instead, and answers the delivery the first replay queued. | | | | | These reconcile-only writes are sent once and are not entered into the replay journal. ## Cancellation [#cancellation] SIGINT (Ctrl-C) and SIGTERM stop the command. With no change outstanding they report `CANCELLED`, exit 130 for SIGINT and 143 for SIGTERM; while a change is outstanding they report `OUTCOME_UNCERTAIN`, exit 3. Git children started by this CLI are stopped before it exits on a signal. Recovery records remain on disk. Interrupting `stage` while it waits for the build reports `CANCELLED` with the `stage.build` operation: the build keeps running, and resuming returns it. Declining a confirmation or cancelling a prompt reports `CANCELLED` too; prompts come before any change. `domino dev` treats the first signal as a normal stop and prints its result; a second signal exits at once. ## Events [#events] With `--json`, a command writes JSON events to stderr while it runs. Each has an `event` field: * `{"event": "operation", "operation": {…}}`: written once the credential check passes, right before a resumable change is sent, after its recovery journal is saved, with the same `operation` a recoverable error would carry. Record it to resume the change if the CLI process dies. * `{"event": "browser-login", "url": "…", "code": "…", "expiresAt": 1767225600000}`: `domino login` is waiting for approval of this code at this URL. A person has until `expiresAt` (epoch milliseconds, 15 minutes after the request started) to approve it; after that, run `domino login` again. Console's approval page shows the request's time, its IP's country and the CLI's version and OS, so the person can tell it is theirs. Other stderr lines are prose. The error, when there is one, is always last. ## Results [#results] Each command's result schema is `schemas/results/.schema.json`, with spaces in the command replaced by `-`, such as `secrets-set`. `contract.json` names it for every command. `status`, `quests`, `releases`, `members list`, `builds`, `logs`, `whoami` and `api` print the management API's response body unchanged; their `route` in `contract.json` names the endpoint, and the schema lists only the fields the CLI relies on. The [resource commands](#resource-commands) print the typed result of the management client method they call, the same value `createManagementClient` returns, and `route` names its endpoint. `--help` and `domino help [command...]` with `--json` print `{"help": "…"}`, and `domino help` with an unknown command fails with `UNKNOWN_COMMAND`; `--version` with `--json` prints `{"version": "…"}`. `domino members list [--q TEXT] [--needs-review] [--limit N] [--cursor C] --json` prints `{ items, total, nextCursor }` (`members-list` schema), the project's members most recently joined first with their `accounts` and activity counts. Pass `nextCursor` back as `--cursor` for the next page until it is `null`, keeping the same `--q` and `--needs-review`. It needs `read`; see [operator member reads](management.md#management-client) for what `--q` matches. `domino quests replay --json` fetches the attempt's capture (`GET /attempts/{attempt}/capture`, `publish` permission) and evaluates local quest code against it with `replayAttempt` from `@domino-sdk/relay/testing`. Captures exist for test-environment attempts only: with live selected the command fails with `ENVIRONMENT_UNSUPPORTED` before any request, and an attempt whose evaluation has not started fails with `CONFLICT` (`attempt-not-evaluated`); a capture taken mid-run has `recorded: null`. It replays the discovered quest whose key the capture names; pass `--quest FILE` (and `--export NAME` for a named export) for another module or for a quest created in Console, whose capture has no key. `--out FILE` also saves the capture; `--capture FILE` replays a saved one without contacting the API, and then `` is optional. The result (`quests-replay` schema) is `{ attempt, quest, decision, recorded, changed, divergence, calls, out? }`: `decision` is what the code decides now, `recorded` what quest code decided in the captured run, `changed` whether they differ (kind, reason, rewards or completion key), `divergence` the first capability call the capture could not answer (the replay stops there, with `decision` and `changed` null), and `calls` the number of calls it answered. A changed decision still exits 0; branch on `changed`. Members in `members get`, `members list`, `members lookup` and the attempts' people carry `appIdentities`: the member's linked [app identities](/docs/build/app-login#map-members-to-your-user-ids), each `{ kind, subject }`. `domino members lookup --kind app:game --subject 42` finds the member holding your app's user `42`; `--kind x|discord|telegram --subject ID` finds an account by its numeric user ID, and `--kind wallet --chain CHAIN --address ADDRESS` a wallet. `--provider` with `--subject` is the deprecated form, which still finds accounts of any provider, such as `email`. Wallets in `members get` carry `provedOn` for a smart-contract wallet: the `eip155:` whose contract checked its latest proof ([wallets](participant-api.md#wallets)); it is omitted for a key's own signature. Member accounts in `members get`, `members list`, `members lookup` and the attempts' people carry `status` and `reason` only when Relay keeps the account's OAuth tokens to read it in the background: `status` is `connected` or `needs-reconnect`, and `reason` (`revoked`, `expired` or `missing-scope`) says why it needs reconnecting. See [operator member reads](management.md#management-client). `members get` also carries `creatorAccounts`: the member's linked [creator accounts](participant-api.md#creator-accounts), such as a YouTube channel, each with `provider`, `subject`, `revision`, `linkedAt`, `status` (`connected` or `needs-reconnect`, with `reason`) and the channel's `profile`; it is empty when the member linked none. Unlink one with `domino api POST /members//creator-accounts/unlink body.json` and a body of `provider`, `subject`, `revision` and `reason` (`review` permission). Quest releases (`quests`, `releases`, `members get`, `attempts`) carry `announcements` when the quest [posts announcements](authoring.md#announcements), each with `provider` (`telegram` or `discord`), `action` (`post-message`), `chat` or `channel`, `text` and an optional `replyToTriggeringMessage`; it is omitted otherwise. A Discord channel setting's `source` carries `postable: true` when it is an announcement's channel. Quest releases (`quests`, `releases`, `members get`, `attempts`) carry `retryCooldownMs` when the quest has a [retry cooldown](quest-rules.md#set-a-retry-cooldown) and `completionCooldownMs` when it has a [completion cooldown](quest-rules.md#set-a-completion-cooldown). Each quest in `members progress` and `members get` carries `retryAt` while that member's retry cooldown runs, and `nextAvailableAt` while a cooldown or an exhausted budget holds the quest until a known time. All are omitted otherwise. Releases also carry `authorBudgets` when the quest has a [per-giver budget](/docs/build/trackers#cap-what-each-giver-gives). `domino reference --json` prints `{ topics }`, every reference shipped with the installed CLI, and `domino reference --json` prints `{ topic, markdown }`. Each capability's `reference` in `domino capabilities` names one of these topics. An unknown topic fails with `INVALID_ARGUMENT` and lists the topics. The management client accepts additional metadata fields in a deployment record's `source` and discards those fields. It still validates the source kind (`direct`, `promotion` or `rollback`) and the deployment identifier required for promotion and rollback. `leaderboards list` and `leaderboards history` return each board's definition as published, including `includeUnscored` when a board lists members without points, and `ties` when a board sets its live tie order (`"earliest"` ranks equal scores by who reached them first, so `leaderboards standings` returns them in that order with distinct ranks; absent means `"shared"`). `domino build --hosting-artifacts --json` prints the `--out` result: `file` is the `.domino-build` directory beside `relay.json`, `files` lists the artifacts written there (`quests.json` and `catalog.json`), and the counts follow. It refuses `--out` with `INVALID_ARGUMENT`. The `build`, `check` and `deploy` result counts include `offers`. Build results and deployment previews also carry the authored `offers` array in their bundle, so automation that inspects publication contents can handle offers alongside point ledgers, quests and leaderboards. A quest or type built against an SDK runtime carries `runtime: { digest, sdkVersion }` and only its own code; the bundle's `runtimes` array holds each runtime's modules once. Write the bundle through unchanged: dropping `runtimes` makes Relay refuse a reference it has not stored. A manifest holds up to 1,000 quests and 200 quest types; more fails `domino build` and `domino deploy` with `BUNDLE_FAILED` before anything is sent. Agents in parallel worktrees of one project publish with `domino deploy --only ` (repeatable), so a deployment from one checkout doesn't republish, or revert, quests another checkout changed. The deployment holds only the named quests, the SDK runtimes they import, the `relay.json` images whose key their code names as a string literal, and `supportedInteractions`; every other quest keeps its release and every other resource stays as published, so the result lists only those quests. `supportedInteractions` is the one project-level value it sets, from the checkout's `relay.json`, so rebase before deploying when another worktree changed it. The key is the literal `key` a quest file passes to `defineQuest`. An unknown key fails with `INVALID_ARGUMENT` (exit 2) naming the discovered keys, before any request; `--only` with `--resume`, `--promote`, `--dry-run` or `--out` fails the same way. It works with `--preview` and `--yes`. See [publish only some quests](/docs/build/publishing#publish-only-some-quests). A `deploy` preview or published result carries `calendar`: the project timezone change the deployment makes from relay.json's `calendar`, as `{ "timeZone", "effectiveAt" }`, or `null` when it makes none. A null `effectiveAt` means it keeps `timeZone` and cancels a pending change. Publishing makes exactly the change the preview showed; if a day boundary passed or a calendar change since then would make it differ, it fails with "Preview again" and publishes nothing. See the [project calendar](authoring.md#project-calendar). A `deploy` preview or published result carries `questChanges`: each quest in it by key, with `change` `created` (new quest), `updated` (a new release replaces the active one, named with its `createdAt` in `replaces`) or `unchanged` (the active release already has the same code, SDK runtime, provider, effective settings and archive state, so publishing keeps it and its ID). Check the `updated` keys before publishing: a quest your change did not touch appearing there means the bundle carries other code for it than what is published. Without `--json` the confirmation lists each created and updated quest and counts the unchanged ones. A result from an older Relay omits it. A `deploy` preview or published result carries `warnings`: what the deployment publishes anyway but the operator should fix, as `{ "code", "message", "integration", "quests" }`. Code `integration-required` names the quests (by key) that need an unconnected integration, such as Discord; their attempts fail with that code until it is connected, and connecting it admits the failed attempts of automatic quests without a tracker again. Code `x-post-rewards-ungated` names the quests that reward X posts without an `inGroup` prerequisite (an X tracker trigger paying the post's author, or a content quest on an X tracker, with declared rewards or an engagement rule); X does not allow apps that reward users for posting, so add `inGroup(group)` with a group of approved creators. Code `quests-not-in-bundle` names the quests published in test from code, not archived, that a full deployment leaves out, such as a starter's deleted sample quests; they stay as published, so archive each one you no longer want with `domino quests archive --reason "…"`. Quests created in Console and `deploy --only` are never listed. `domino stage` prints the warnings of the deployment its build published, and its `--json` result carries them in `warnings`. The list is empty when there is nothing to report, and a result from an older Relay omits it. Treat an unknown `code` as a notice its `message` explains. A warning never fails the command or changes its exit code; without `--json` the CLI also prints each one on stderr as `Warning: …`. ## Scope and credentials [#scope-and-credentials] Each scope value comes from, in order: a flag (`--api-url`, `--organization`, `--project`, `--environment`), then `RELAY_API_URL`, `RELAY_ORGANIZATION`, `RELAY_PROJECT` or `RELAY_ENVIRONMENT`, then the checkout's `domino.json` in the Git common directory, then the nearest `relay.json` (or `--config FILE`). The API defaults to `https://relay.domino.run`. `domino doctor --json` reports each value's source in `scopeSources`. `domino secrets`, `domino storage` and `domino schedules` are the exception for the environment: they use test unless the `--environment` flag says `live`, and ignore `RELAY_ENVIRONMENT` and configuration. When either names live, they warn on stderr and still use test; their results say which environment they used in `environment`. The credential is `RELAY_MANAGEMENT_TOKEN` when set, otherwise the token `domino login` saved for the API URL in `$RELAY_CONFIG_DIR/credentials.json` (default `~/.config/domino`). ### Running without a person [#running-without-a-person] A headless agent or CI uses an organization automation key (`dak_…`) as `RELAY_MANAGEMENT_TOKEN`, or saves it with `domino login --token-stdin`. An organization administrator creates it in Console Settings → API keys; never ask for one in chat. It reaches every project of the organization or a chosen list, with chosen permissions (`read`, `publish`, `inventory`, `points`, `ingest`, `enroll`) and, separately, permission to create projects. A listed key also reaches the projects it creates. It works for `domino create`, `git push` through the credential helper, `domino stage`, authoring, test members and the [staging test toolbar](campaign-testing.md#check-participant-flows-on-staging), in the test environment only: * Any write in live, including drafts, previews and secrets, is refused with `console-approval-required`. Ask the owner to publish from Console. [What each credential may do in live](/docs/concepts/access#what-each-credential-may-do-in-live) compares it with the other credentials. * Grants, the team, `/project` changes, `/tokens`, CLI sign-in approval and creating keys are refused with `credential-not-allowed` or `permission-required`. * Identity integrations (app login keys) are refused with `permission-required`, listing included. An organization administrator's own `domino login` session lists them with `domino identity-integrations list`; only Console creates them. * `domino create` without the key's create-projects permission fails with `permission-required`; a project outside its list fails with `scope-mismatch`. * A project it creates connects a Discord server another project of the organization already connects, with `domino integrations discord connect --server ` (list them with `domino integrations discord servers`) and the key's `publish` permission, then reads the connection with `domino integrations discord get`. Installing Domino's bot into a new server takes an organization administrator in Console: stop and ask on `server-not-connected-in-organization`. `domino whoami --json` prints `credential`: `{ kind: "automation", id, name, projects, createProjects, permissions }`, with `projects` null for every project. Its changes are audited as `automation:`. ## Unattended mode [#unattended-mode] Whoever launches an agent with nobody present to answer it, such as a CI job, a scheduled run or a sandbox, sets `DOMINO_AGENT_MODE=unattended` in its environment. The agent cannot turn the mode on itself. `domino doctor --json` and `domino whoami --json` report it as `agentMode`: `unattended`, or `interactive` for any other value or none. In unattended mode: * **Login.** Browser sign-in refuses with `UNATTENDED_LOGIN_UNAVAILABLE`, and a command with no credential fails with `AUTH_REQUIRED`. Supply `RELAY_MANAGEMENT_TOKEN`; `domino login` can still save a token it reads from that variable or `--token-stdin`. * **Feedback.** `domino feedback` appends the report as one JSON line, with a `savedAt` timestamp, to `./domino-feedback.jsonl` or the path in `DOMINO_FEEDBACK_FILE`, and does not send it. It needs no credential. The result is `{ "sent": false, "saved": true, "file", "report" }`. `--submit`, or `DOMINO_FEEDBACK=submit`, sends it as usual. ## Claude and ChatGPT [#claude-and-chatgpt] Operators can connect Claude, ChatGPT, Claude Code, VS Code or Cursor to Domino's MCP server at `https://mcp.domino.run` and sign in with their Domino account; the guide is [Connect an AI assistant](https://console.domino.run/docs/agents/connect). Its tools act as that operator, through the same management operations as the CLI, so their roles apply: * **Reads, in test and live:** `list_projects`, `get_project`, `list_quests`, `get_quest`, `list_campaigns`, `list_submissions`, `get_submission`, `find_member`, `get_member`, `get_leaderboard`, `list_offers`, `get_usage`, `list_analytics_metrics`, `query_analytics` and `list_audit_events`. * **Writes, in test only:** `create_test_member`, `act_as_test_member`, `reset_test_member`, `review_submission` and `delete_submission_evidence`. In live they are refused with `console-approval-required`, like every live write of an MCP connector ([what each credential may do in live](/docs/concepts/access#what-each-credential-may-do-in-live)). There is no tool for publishing, points, deployments, access or arbitrary management calls; use the CLI for those. When an owner asks how to check their campaign from a chat assistant, point them to the guide instead of building an integration. ## Stable and experimental commands [#stable-and-experimental-commands] `domino --help` lists stable commands under **Commands** and experimental ones under **Experimental commands**; `contract.json` marks each with `stability`. An experimental command's options, output and result can change in a minor release. It never changes how a stable command behaves. | Experimental command | Why | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `dev` | Its preview transport, progress output and result are still changing. Script `stage` instead. | | `billing` | Billing is open to Domino staff only; its commands, results and plan names may change before it opens to everyone. | | `connections` | Plugin connections are new while the plugin contract is built; their commands and results may change. `connections authorize` prints an OAuth plugin's `authorizationUrl` for the person signed in to the CLI to open; they finish it in Console as themselves. An automation key can't start one (403 `credential-not-allowed`). A destination needs no person in test: `connections connect --dry-run` connects it without an account, `connections log` lists what it would have sent, `connections activity` reads its sync status and `connections resync` sends every member again. | | `members consent` | Members' marketing consent is a new feature family; the command and its result may change. | | `members ban`, `members unban` | Member bans are a new feature family; the commands, the `--dry-run` preview and the ban result may change. | | `tiered-rewards` | Archiving tiered rewards is a new management resource; its commands and results may change. | Commands the CLI runs for Git, such as `credential-helper`, are not part of the contract. ## Webhook operations [#webhook-operations] `domino webhooks` covers endpoint list/create/update/disable, secret rotation, delivery logs, and replay. `create` and `rotate-secret` print a signing secret; treat their JSON output as a credential. Other commands omit secrets. Both are saved operations: pass `--operation-id ` to supply the ID, sent as the `actionId`, and after an uncertain answer run `operations resume` to get the same endpoint and secret. Their journal records keep that secret only until it has been printed once: a resume of an operation that has not completed returns it, and once it has, `operations show` and `operations resume` answer `secretRedacted: true` instead, and repeating the command with the same `--operation-id` fails with `CONFLICT`. Rotate the secret to get a new one. A CLI session can read live webhooks but cannot change them. A live API key with `publish` can perform these operational writes. See [Webhook reference](/docs/reference/webhooks) for command arguments and response shapes. Deprecated generic resource lifecycle commands also use the durable operation journal. `resources archive` and `resources unarchive` save `resource.lifecycle` with the selected kind, key or ID, desired archive state and reason before sending. `operations resume` reuses that scoped payload and action ID, including after a fresh process starts. `resources list` reports `archivedAt` as a timestamp or `null`, and lists offers in `offers`, which archive only with `offers archive|unarchive`, and tiered rewards in `tieredRewards`, which archive only with the experimental `tiered-rewards archive|unarchive`; a successful mutation returns `{ resource, changed }`. ## Resource commands [#resource-commands] Each family has `list` and `get` style reads and its changes, all built on the management client from `@domino-sdk/relay/server`. Every command takes the scope flags and `--json`; reads need `read`, and changes the permission their management method documents. | Family | Reads | Changes | | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Points | `points get MEMBER LEDGER`, `points list MEMBER LEDGER [--cursor]`, `point-ledgers list` | `points award\|spend\|adjust\|set\|reverse MEMBER LEDGER --reason …`, `point-ledgers create NAME`, `point-ledgers update LEDGER --name …`, `point-ledgers archive\|unarchive` | | Trackers | `trackers list`, `trackers get`, `trackers records`, `trackers summary`, `trackers content`, `trackers outcomes`, `x lookup HANDLE` (an X handle's stable user ID) | `trackers create KEY [--source KIND]` (`--chain` and `--token` for `--source wallet-balances`) and `trackers update TRACKER` (trackers of the managed sources as data, in test), `trackers ingest TRACKER FILE`, `trackers retry-outcome`, `trackers pause\|resume\|refresh\|archive\|unarchive` | | Leaderboards | `leaderboards list`, `leaderboards standings`, `leaderboards history`, `leaderboards exclusions`, `leaderboards exclusion-group`, `leaderboard-settlements list\|get\|export` | `leaderboards exclude\|include BOARD MEMBER --reason …`, `leaderboards bind-exclusion-group BOARD GROUP --reason …`, `leaderboard-settlements approve\|reject\|retry-measurement`, `leaderboards archive\|unarchive` | | Referrals | `referral-programs list`, `referral-relationships list` (each relationship and qualification a [member import](/docs/reference/imports) recorded names it in `import`), `referral-relationships history MEMBER` | `referral-programs qualify PROGRAM MEMBER [--disqualify]`, `referral-rewards resolve ENTITLEMENT`, `referral-relationships pause\|resume`, `referral-programs archive\|unarchive` | | Offers and redemptions | `offers list`, `redemptions history [--member] [--cursor] [--limit]` | `redemptions cancel REDEMPTION --expected-revision N`, `offers pause\|resume`, `offers archive\|unarchive OFFER --reason …` (`publish`, in test), `redemptions pause\|resume` | | Tiered rewards (experimental) | `tiered-rewards list` | `tiered-rewards archive\|unarchive KEY --reason …` (`publish`, in test) | | Rewards | — (read entitlements with `members get MEMBER`) | `entitlements restore ENTITLEMENT --expected-revision N --reason …` (a voided entitlement; `review` permission) | | Completions | — (read revoked ones in `revokedCompletions` of `members get MEMBER`; a revoke that freed the occurrence lists `budgets` it returned) | `completions restore COMPLETION --reason …` (a revoked completion; `review` permission; `budgets` it charged again, or 409 `budget-exhausted`) | | Profiles | `profiles settings` | `profiles configure FILE`, `profiles moderate MEMBER [--reset-name] [--remove-avatar] [--lock\|--unlock]` | | Calendar | `calendar get` | `calendar configure --time-zone ZONE` | | Members and groups | `members list\|get\|progress\|lookup\|identities\|counters` (deprecated: `identity-links\|oauth-accounts`), `groups list\|get\|members\|member` | `members unlink-identity\|relink-identity\|identity-sign-in` (deprecated: `relink`), `members moderate-counter`, `groups create\|update\|archive\|unarchive\|add\|remove` | | Quests and releases | `quests list [--lifecycle STATE]\|get` (each quest carries `lifecycle`: `active`, `paused` or `archived`; repeat `--lifecycle` for more than one state), `quests enrollments`, `releases list [--quest]\|get`, `attempts list [--quest]\|get`, `attempts evidence-retention`, `metrics get`, `usage active-members`, `usage real-people-in-test`, `usage plan`, `pauses list` | `quests pause\|resume\|archive\|unarchive\|enroll\|withdraw`, `attempts accept-anyway ATTEMPT --reason TEXT`, `attempts redact-evidence ATTEMPT --reason TEXT`, `attempts redact-evidence-before --quest QUEST --decided-before MS --reason TEXT` (loops until done; `review`, in live an organization admin's key or a service key), `attempts set-evidence-retention --days N\|--keep` (`publish`; shortening needs an organization admin's key or a service key) | | Test members (test only, `publish`) | `test-members list`, `test-members history MEMBER [--since MS]` | `test-members create NAME`, `test-members act MEMBER QUEST [--input JSON \| --photo FILE] [--release RELEASE]`, `test-members pass MEMBER QUEST [--completion-key KEY]`, `test-members accept-anyway MEMBER ATTEMPT [--live-rules --reason TEXT]`, `test-members advance MEMBER (--days N \| --hours N \| --minutes N \| --to MS\|INSTANT)`, `test-members accept-referral MEMBER (--code CODE \| --inviter MEMBER)`, `test-members redeem MEMBER OFFER`, `test-members link-wallet\|unlink-wallet MEMBER --chain CHAIN --address ADDRESS`, `test-members link-account MEMBER --kind x\|discord (--subject ID \| --username HANDLE)`, `test-members unlink-account MEMBER --kind x\|discord --subject ID` (deprecated: `--provider` and `--id`), `test-members record MEMBER --tracker TRACKER [--data JSON] [--as author\|messageAuthor]`, `test-members reset MEMBER`, `test-members purge MEMBER... --reason TEXT` | | Dashboards | `dashboards list [--include-archived]`, `dashboards get KEY [--range 7d\|30d\|90d\|all]` (see [dashboards](#dashboards)) | `dashboards archive\|unarchive KEY --reason …` | | Member imports (`publish`; reads need `read`) | `imports list`, `imports get IMPORT`, `imports rows IMPORT [--status STATUS]` | `import --file FILE [--source NAME] [--import IMPORT] [--dry-run]`, `imports apply\|cancel\|stop IMPORT`, `imports revert IMPORT --reason TEXT` (see [member imports](/docs/reference/imports); live imports are applied in Console; read `state`: a stopped or reverted import is `stopped` or `reverted`, while its 1.x `status` reads `applied`) | To simulate a whole campaign with the test-member commands, read [campaign testing](/docs/reference/campaign-testing) (`domino reference campaign-testing`). For declaring trackers, their collection options, record filters, trigger outcome codes and engagement accounting, read [trackers](/docs/reference/trackers) (`domino reference trackers`). For quests that check a wallet's EVM contract events or Solana transactions, read [on-chain actions](/docs/reference/onchain) (`domino reference onchain`). For the evaluator readers a quest uses to check what a member did, such as Discord membership, X, wallets, submissions, AI text review and image checks, read [verify member actions](/docs/reference/verification) (`domino reference verification`). For campaign windows, prerequisites, automatic and repeatable quests, reject codes, deferral, cooldowns, streaks, progress counters and local quest tests, read [quest rules and evaluation](/docs/reference/quest-rules) (`domino reference quest-rules`). `test-members history` names each attempt's and skipped outcome's quest by ID as `quest` and by authoring key as `questKey`, absent for a quest created without a key. `attempts list` items name only the quest ID; map an item's `release` to the page's `releases[]` entry with that `id`, whose `key` is the quest's key. Every change except `point-ledgers update`, the `groups update\|archive\|unarchive` state changes and the member import commands is saved before it is sent. Member imports resume by themselves: run `domino import` again with `--import IMPORT`, since a row with the same `ref` replaces the earlier one. Saved changes resume as described in [Recover after restarting the CLI](#recover-after-restarting-the-cli). The CLI checks the input with the SDK's own schema first: input it refuses exits 2 with `INVALID_ARGUMENT` and leaves nothing saved or sent. A change the API refuses outright on its first attempt (exit 1, `outcome: "failed"`) is not kept either, since nothing was applied; only a change that may have been applied stays saved for `operations resume`. When no environment comes from `--environment`, `RELAY_ENVIRONMENT` or relay.json, a change is saved for the only environment your credential reaches, such as a personal API key's own, and otherwise for `test`. Release history (`releases list` and `releases get`) also needs `publish`. Quest and release records can include optional `presentation.steps`, `image`, `imageAlt`, and `requirements`. Steps retain their authored order and can carry an HTTPS link action. `configuration.presentation` records a Console or quest-instance override; `configuration.presentationDefaults` records the defaults used when resetting it. These fields also appear wherever attempt or member results include a release. Read the resolved `presentation` to render a quest; the configuration fields describe its authoring state. See [quest authoring](/docs/reference/authoring) for the shapes. Older records can omit these fields. A text setting in a release's settings can carry an optional `picker`, such as `"telegram-chat"` from `settings.telegramChat()`: Console then offers a picker for its value, which is still a string. Treat an unknown picker as plain text. Six pickers also constrain the value: `"amount"` (`settings.amount()`), a non-negative base-unit integer string, with an optional `decimals` field for showing it in whole tokens, `"chain"` (`settings.chain()`), `"solana"`, Solana mainnet's CAIP-2 ID or an EVM chain ID, and `"nft"` (`settings.nft()`), an NFT collection such as `"eip155:1/erc721:0x…"`, `"eip155:8453/erc1155:0x…/1"` or `"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/nft:"`, whose EVM contract publication and Console edits also check through ERC-165, and whose Solana mint they check is a Metaplex collection NFT, `"contract-call"` (`settings.contractCall()`), a contract's CAIP-10 ID and a view or pure function signature returning one uint, int, bool or address, such as `"eip155:8453:0x… function stakedOf(address) view returns (uint256)"`, whose chain they also check, and `"contract-event"` (`settings.contractEvent()`), an EVM contract event such as `"eip155:8453:0x… Transfer(address indexed from, address indexed to, uint256 value)"`, whose chain publication and Console edits check reports a finalized or safe block, and whose address they check holds a contract, and `"solana-program"` (`settings.solanaProgram()`), a base58 Solana program address, which they check is an executable program. A release with such a setting may carry `solanaIdls`: by setting key, `{ program, source, idl }`, the program's Anchor IDL as JSON, stored from its on-chain IDL account when the setting was saved (`source: "chain"`) or pasted by an operator (`"pasted"`); treat other `source` values as unknown. `ctx.wallet.transactions()` decodes the program's instructions with it ([Solana transactions](onchain.md#solana-transactions)). The bare `status`, `quests` and `releases` commands, and `groups show`, are deprecated and removed in 2.0.0; `contract.json` gives every deprecated command, and every deprecated option, such as `members lookup --provider`, a `deprecated` note naming its replacement. They still print Console's former pages, and without `--json` the three page reads warn on stderr. Use `metrics get`, `attempts list`, `quests list` and `pauses list` for `status`; `quests list` and `quests get` for `quests`; `releases list` and `releases get` for `releases`; and `groups get` for `groups show`. `usage active-members` reads the project's live active members this UTC month against its plan's `limit` (null when the plan has none), and the six earlier months, newest first (`management.usage.activeMembers()`, `GET /management/v1/usage/active-members`). An active member sent a signed-in participant request on a UTC day of the month after the day they joined; imports, tracker records and test members never count. See [active members](/docs/concepts/participants#active-members). `usage real-people-in-test` reads the real people who joined the test environment against the plan's `limit`, and whether the limit is `enforced` (`management.usage.realPeopleInTest()`, `GET /management/v1/usage/real-people-in-test`). Test members never count. When it is enforced and full, new people's sign-ins to test fail with `limit-reached`; people who already joined still sign in. See [real people in test](/docs/concepts/participants#real-people-in-test). `usage plan` reads the project's plan as it stands (`management.usage.plan()`, `GET /management/v1/usage/plan`), with `read` permission and whichever environment you select: `plan` and its `source` (`stripe`, `staff`, `grandfathered`, `trial`, or null on Free), whether it may serve live and why (`live.reason`: `plan`, `trial` or `grace` with `until`, or `free` or `trial-ended`), `liveState` while live is in notice or offline, the organization's one `trial` (`unused`, `active`, `grace` or `used`, and its `project`), whether the plan includes a custom domain, the AI builder balance by pool (`ai`), the Stripe `subscription` as `billing show` gives it to an administrator (its `plan` is always `launch`, `growth` or `scale`, since `usage plan` is stable), and `enforcement`, whether plan limits are enforced. It buys nothing; see `billing` below for that. See [going live needs a plan](/docs/concepts/promotion#going-live-needs-a-plan). `billing show` reads whether Stripe billing is open to you (`available`, and `reason`: `admin-required`, `staff-only` or `not-configured`) and, when it is, the project's subscription as Stripe last reported it (`management.billing.read()`, `GET /management/v1/billing`). `billing checkout --plan PLAN [--interval month|year]` returns a Stripe Checkout URL that subscribes the project, and `billing portal` the organization's Stripe customer portal (`management.billing.checkout()`, `management.billing.portal()`); open the URL in a browser. An annual checkout (`--interval year`) has a promotion code field on Stripe's page; a monthly one has none, so a code such as a first-year offer needs `--interval year`. While a code discounts the current period, `subscription.discount` gives what the period costs (`amount`) instead of its list price (`listAmount`), both in US cents with tax included, and when that period ends (`until`); a renewal at the list price, a plan change or the subscription ending clears it. `billing checkout` and `billing portal` need an organization administrator's sign-in, never an API key. Until the billing launch they are open to Domino's own organizations only, and everyone else gets `billing-staff-only`. The plans are `launch`, `growth` and `scale` today. `--plan` takes any name, and Relay refuses one it does not offer with 400 (`BAD_REQUEST`). The billing results read a plan the same way: `subscription.plan` and `scheduledChange.plan` can name a plan Relay added after this CLI. The `billing` commands are experimental: their results and plan names can change in a minor release. `billing organization` (`management.billing.organization()`, `GET /management/v1/organization/billing`) reads the organization's billing for any of its members, with no project selected: each project you can read with its `plan`, `source`, `live`, `liveState` and Stripe `subscription`, the organization's Free projects against their limit (`freeProjects`), the free AI allowance its Free projects share (`freeAi`), the `trial`, `enforcement`, and in `billing` whether you may open the customer portal, as `billing show` says. An app key, or a Playground session fixed to one project, gets `credential-not-allowed`. `billing change --plan PLAN [--interval month|year]` (`management.billing.changePlan()`, `POST /management/v1/billing/plan`) moves a paying project's subscription to another plan. A higher plan, or annual billing, applies now: Stripe invoices the prorated difference at once, and when that payment needs the customer (a bank's authentication, say), nothing changes until the returned `invoiceUrl` is paid. A lower plan, or monthly billing, is held for the renewal, so the period already paid keeps its plan; `billing show` names it under `scheduledChange`. Asking for the current plan drops a held change. `billing cancel` (`management.billing.cancel()`) ends the subscription when its period does; the project keeps its plan until then, and `billing resume` takes it back. `subscription.endedFor` is `project-deleted` while a deleted project's subscription runs out (restoring the project resumes it), and `project-transferred` when the project came from another organization whose subscription ends with its period: `billing checkout` then subscribes the project again with no charge until that period ends. `billing topup [--quantity 1-10]` (`management.billing.topup()`, `POST /management/v1/billing/topup`) returns a Checkout URL buying $10 packs of AI builder credit for the project, on any plan; the credit is added once Stripe reports the payment, once per purchase, and never expires. While an invoice is overdue, `billing show`'s `subscription.unpaid` gives when it first failed and the two deadlines: from `promotionsRefusedAt` every promotion in the organization is refused with `plan-required` (`reason: "unpaid"`), and at `offlineAt` the project's live goes offline until it is paid. A project without a subscription that holds its plan gets `no-subscription`, and a plan change while the subscription is set to cancel gets `subscription-canceling`. The same administrator and staff rules apply. ## Project analytics [#project-analytics] `domino analytics get [--range 7d|30d|90d|all] [--ledger KEY]` reads how a project is doing in one environment; `--range` defaults to `30d`. It needs `read`. Without `--json` it prints a summary of every total and its change; `--json` prints the read itself, which `management.analytics.read({ range, ledger })` from `@domino-sdk/relay/server` also returns (`GET /management/v1/analytics?range=…&ledger=…`). When someone asks how a project is doing, quote these numbers instead of computing your own from other reads. **Range.** The range ends now (`range.to`). `7d`, `30d` and `90d` are that many [project-calendar](/docs/concepts/calendar) days, today included; `all` starts on the day of the project's earliest dated record the read counts: a membership, attempt, decision, completion, entry in the chosen ledger, fulfilled redemption, accepted referral or active qualification. That is usually the first membership, but a completion imported with its original date can be older. `7d` and `30d` have one bucket per day; `90d` and `all` one per week, from Monday, with the first and last weeks cut to the range. Days start at local midnight; when the project's timezone changes, the day or week already open finishes in the old timezone, and each bucket's `key` names its timezone (`2026-10-08@Europe/Budapest`, `2026-W41@UTC`). `previous` is the range of equal length that ends where this one starts; it is absent for `all`. Each time is an ISO string with its epoch milliseconds beside it: `from` and `fromMs`, `to` and `toMs`, and each bucket's `start`, `startMs`, `end` and `endMs`. **Metrics.** Each metric is `{ total, previousTotal, series }`: the range's total, the previous range's total (absent for `all`), and one value per bucket in `range.buckets`. Records count as they stand now: a completion revoked or a redemption canceled later leaves the bucket it was in. | Metric | Counts | Dated by | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | | `members.signUps` | Members who joined the project, as the Members page counts them | When they joined | | `members.active` | Distinct members with an attempt; the total counts each member once | Attempt creation | | `quests.attempts` | Attempts | Attempt creation | | `quests.completions` | Quest completions never revoked, as Console counts them | Completion | | `quests.rejections` | Rejected attempts | Decision | | `quests.needsReview` | Attempts the evaluator sent to staff review, whatever staff decided later | Attempt creation | | `points.awarded` | Points in the ledger's award entries | Entry's effective date | | `points.spent` | Points in the ledger's spend entries, as a positive number | Entry's effective date | | `points.net` | Every entry's signed amount: awards, spends, adjustments (offer refunds and imported balances among them) and reversals | Entry's effective date; a reversal takes the date of the entry it reverses | | `points.membersEarning` | Distinct members awarded more than 0 points; the total counts each member once | Entry's effective date | | `offers.redemptions` | Fulfilled offer redemptions | Redemption | | `referrals.accepted` | Accepted referrals | Acceptance | | `referrals.qualified` | Active referral qualifications, one per program and friend. A suspended referral still counts, as it does toward inviter rewards, although Console labels it **Suspended** | First qualification | | `referrals.acceptedQualified` | Of the referrals accepted in the range, how many have an active qualification now, in any program, each friend once, suspended ones included. Only `{ total, previousTotal }`, no series. `acceptedQualified.total ÷ accepted.total` is the share of the range's friends who have qualified since, never above 100%; a friend accepted before the range who qualifies inside it counts in `qualified` but not here | Acceptance | A decision is dated on the wall clock. Attempt creation, completion and quest points are dated on the member's clock, which an [advanced test member](campaign-testing.md#3-run-the-days) moves up to 90 days ahead; activity dated after now is left out until then. An evaluator that asks for review on a quest with human review off rejects the attempt: it counts as a rejection, not as review. **Quests.** `quests.byQuest` lists each quest with an attempt, completion or decision in the range, most attempted first, by quest ID with its current title: * `attempts`, `completions` and `needsReview` as above, with `series.attempts` and `series.completions` per bucket. * `decided`: attempts accepted, progressed or rejected in the range, by decision. `rejected`: those rejected. * `rejectionRate`: `rejected ÷ decided`, or `null` below 10 decided attempts. * `topReasons`: the 5 most common rejections. Rejections with a code group by it and by who wrote it, `codeSource`: `evaluator` for an evaluator's reject code, `relay` for one Relay writes (`budget-exhausted`, `key-used`, `defer-expired`, `defer-limit-exceeded`). An evaluator may write `key-used` too, so Relay's `key-used` and an evaluator's are separate rows. `reason` is the latest one's reason; a budget's reason names the budget, but its rejections still form one group. Rejections without a code group by exact reason, with `codeSource: null`. Equal counts list coded groups first. * `rejectionsBySource`: `budget` is `budget-exhausted`; `expired` is `defer-expired` or `defer-limit-exceeded`; `staff` is a reviewer's rejection, of an attempt waiting for review or of a deferred one, and Relay's rejection of a quest whose completion an operator revoked; `keyUsed` is Relay's `key-used`, an attempt whose completion key, unique across members, another member's completion holds; `evaluator` is every other rejection, an evaluator's own `key-used` included. **Points.** The points section reads one ledger: `--ledger` by key or ID, archived ledgers included, or else the first ledger that is not archived. `points.ledgers` lists every ledger with `archived`, and `points.ledger` is the one read, or `null`, with zeros, when there is none. `spent` and `net` appear only when the ledger has ever had a spend entry (`hasSpending`). An unknown ledger fails with 404 `not-found`. **Sections that depend on the project.** `offers` appears only when the project has an offer, with `byOffer` listing every offer, most redeemed first. `referrals` appears only when it has a referral program, archived ones included. `testEnvironment` appears only in test: `lastResetAt` (and `lastResetAtMs`), when a test member was last reset, which deletes that member's history, and `shiftedClockMembers`, how many have an advanced clock. For range `all`, `members.signUps.total` and `quests.completions.total` equal `metrics get`'s `members` and `completions`, imported completions included, except for records dated after now (a test member's advanced clock) or not at all. ## Metric catalog [#metric-catalog] `domino analytics metrics` lists Domino's closed metric catalog, read from the API (`management.analytics.catalog()`, `GET /management/v1/analytics/catalog`): each metric's `id`, `definition`, `kind` (`count`, `distinct` or `rate`), the `breakdowns` and `filters` it accepts, the `charts` it allows, whether it needs a point `ledger`, and a rate's `minimumDenominator`. It needs `read`. `domino analytics query --metric ID [--range 7d|30d|90d|all] [--breakdown DIMENSION] [--filter NAME=KEY[,KEY]]... [--ledger KEY] [--limit N]` reads one metric (`management.analytics.query({ metric, range, breakdown?, filter?, ledger?, limit? })`, `GET /management/v1/analytics/query?metric=…&range=…&filter.quest=a,b`). `--range` defaults to `30d` and buckets exactly as [project analytics](#project-analytics) does. `--filter` repeats, one per filter, and its keys are keys or IDs. Without `--json` it prints the total with its change and one line per breakdown row, with `Other` last; `--json` prints the result. There are no formulas or custom queries: answer a question the catalog can't express by saying so, not by combining metrics. The result has `metric`, `kind`, `range`, `previous` (absent for `all`), `ledger` (the key of the ledger a points metric read) and `value` (`{ total, previousTotal, series }`). With no breakdown or filter, `value` equals the same metric in `analytics get`; `groups.*`, `trackers.records` and `leaderboards.settledParticipants` are only in the catalog. With `--breakdown`, `breakdown` has the `dimension`, its `rows` and `other`: * **Rows.** Each row has `key` (a resource's key, or its ID when it has none; a group's key; a source; an outcome; or a reason's code or text), `title`, `archived`, `total`, `previousTotal` and `series`. Rows come by total, largest first, with an empty rate last; ties go to the larger denominator, then by title. A quest, offer, program, group, tracker or leaderboard breakdown has a row for every matching resource that is not archived, with zeros when it had no activity, and for an archived one with activity in the range or the range before. * **`other`.** Rows past `--limit` (1 to 100, default 100) are summed into `other`, with `rows` saying how many. For a rate, `other` is the summed numerators over the summed denominators of the rows it holds. * **`value`** covers every row, `other` included. **Filters** narrow what is counted before it is split. A list matches any of its keys; different filters all apply. `quest`, `offer`, `program` and `leaderboard` keep those resources. `tracker` keeps activity on the quests whose current release is triggered by the tracker, and on `trackers.records` the tracker's own outcomes. `group` keeps members in the group now, whatever their membership was when they acted, and on `groups.joins` and `groups.leaves` the changes in that group. **Rates** are fractions from 0 to 1, and `null` below 10 in the denominator: in the total, the previous total, each bucket and each row. | Metric | Breakdowns | Filters | | ----------------------------------------------------------------------- | --------------------------- | ------------------------------- | | `members.signUps` | none | `group` | | `members.active` | none | `group` | | `quests.attempts`, `quests.completions`, `quests.needsReview` | `quest` | `quest`, `group`, `tracker` | | `quests.rejections` | `quest`, `source`, `reason` | `quest`, `group`, `tracker` | | `quests.rejectionRate` | `quest` | `quest`, `group`, `tracker` | | `points.awarded`, `points.spent`, `points.net`, `points.membersEarning` | none | `group`; `--ledger` is required | | `offers.redemptions` | `offer` | `offer`, `group` | | `referrals.accepted` | none | none | | `referrals.qualified` | `program` | `program` | | `referrals.qualificationRate` | `program` | `program` | | `groups.joins`, `groups.leaves` | `group` | `group` | | `trackers.records` | `outcome`, `tracker` | `tracker` | | `leaderboards.settledParticipants` | `leaderboard` | `leaderboard` | The counts are defined in [project analytics](#project-analytics). The catalog adds: * `quests.rejectionRate`: rejected attempts ÷ attempts accepted, progressed or rejected, both by decision. Its quest rows are `analytics get`'s `byQuest[].rejectionRate`. * `referrals.qualificationRate`: of the referrals accepted in each bucket, the share whose friend has an active qualification now, suspended referrals included; for a `program` row or filter, a qualification in that program. Every program row shares the denominator, so the rows don't add up to the total. With no filter its total is `acceptedQualified.total ÷ accepted.total`. * `source` rows are `evaluator`, `staff`, `budget`, `expired` and `keyUsed`, as `rejectionsBySource` counts them. `reason` rows group by rejection code when there is one, otherwise by the exact text, titled with the latest rejection's reason; a code and the same text stay separate rows, and Relay's `key-used` is the row `relay:key-used`, apart from an evaluator's `key-used`. * `referrals.accepted` takes no program: a friend accepts an inviter, not a program. * `groups.joins` and `groups.leaves`: members added to and removed from a group, by when the change happened. A change counts only when it changed the member's membership from their previous change in that group, so a first change counts only as a join. Repeats that changed nothing, and the undated rows Domino recorded for memberships older than its group history, don't count. Each change is one Console **Audit history** record (`group-member-added`, `group-member-removed`). * `trackers.records`: tracker trigger outcomes, one per quest and record observation, by when the record was observed. `outcome` rows are `admitted` and `skipped`. A retry or re-decision overwrites an outcome, so each bucket shows today's outcome of its records, not the outcome they had then. They are the records a tracker's trigger outcomes list in Console. * `leaderboards.settledParticipants`: the rows of leaderboard settlements, one per member and settlement, by the settlement's cutoff. Pending and approved settlements count; a settlement rejected later leaves its bucket. Settlements are periodic, so it draws as `number`, `bar` or `table`, not `line`. * With `--range all`, a group, tracker or settlement metric starts at its own earliest record when that is older than the project's. `--ledger` takes a key or ID, archived ledgers included, so an archived ledger's history stays readable. | Code (422) | Meaning | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `unknown-metric` | No metric has this ID. | | `breakdown-not-accepted`, `filter-not-accepted` | The metric doesn't take this breakdown or filter. | | `ledger-required`, `ledger-not-accepted` | A points metric needs `--ledger`; other metrics take none. | | `invalid-limit` | `--limit` is outside 1 to 100. | | `unknown-reference` | A quest, tracker, offer, program, leaderboard or ledger key names nothing; the error has `filter` and `key`. | | `group-not-found` | No group has this key in the environment; the error has `group`. | ## Dashboards [#dashboards] A dashboard is a set of catalog-metric panels defined in code with `defineDashboard` and deployed from `relay.json` `dashboards` (see [dashboards](authoring.md#dashboards) for the definition and the deploy rules). To choose its metrics and check it before handing it over, read [analytics](/docs/reference/analytics). Both reads need `read`. `domino dashboards list [--include-archived]` (`management.dashboards.list({ includeArchived })`, `GET /management/v1/dashboards?includeArchived=true`) lists each dashboard's `key`, `title`, `description`, `position`, `archivedAt` and `updatedAt` (when its current version was published), ordered by `position` with dashboards without one last, then by title. Archived dashboards appear only with `--include-archived`. `domino dashboards get KEY [--range 7d|30d|90d|all]` (`management.dashboards.read({ key, range })`, `GET /management/v1/dashboards/KEY?range=…`) reads one dashboard, archived ones included, over `--range`, or its `defaultRange` without one. Without `--json` it prints each section and each panel's total, breakdown rows and `Other`, or the panel's error: use it to check a dashboard you built. `--json` prints the read: the summary fields, `definition` (as stored, with `defaultRange` and each panel's `width` filled in), `range`, and `panels`, one per panel in reading order, each with its `key` and either `result` or `error`. * **`result`** is the panel's [metric query](#metric-catalog) result for the dashboard's range, metric, breakdown, filters and ledger. Every panel reads as of the same instant. A broken-down `line` or `bar` panel reads the top 8 rows with the rest in `other`; a `table` panel up to 100. * **`error`** is `{ code, message }` for a panel whose query failed: `group-not-found` for a group the environment lacks, `panel-timeout` for a query that ran past its time budget (each statement 2 seconds, and the read about 18 seconds, after which panels not yet read answer `panel-timeout`), `panel-failed` for any other failure, or another metric query code. The other panels still have results. An unknown key is 404 `not-found`. `domino dashboards archive|unarchive KEY --reason TEXT` (`management.dashboards.archive|unarchive(key, { reason })`, `POST /management/v1/dashboards/KEY/archive|unarchive`) archives and restores one in test (`publish`), returning `{ dashboard, changed }`; live refuses it with `promotion-required`. Each is a saved change; see [lifecycle retries](#lifecycle-retries). Archiving takes it out of the default list; deploying its code again leaves it archived. ## Lifecycle retries [#lifecycle-retries] Resource-specific archive, unarchive, pause and resume commands use the matching management path verb, and `trackers refresh` its own. Each is a saved resource change: pass `--operation-id ` to supply its ID, and resume an uncertain one with `operations resume`. `--action-id ` instead sends that action ID without saving the change, as these commands did before they were saved; retry identical inputs with the same ID. Passing both fails with `INVALID_ARGUMENT`. `trackers pause|resume`, `groups archive|unarchive` and `schedules pause|archive|resume` take an optional `--reason TEXT` for the audit log; quest, offer and redemption pauses require one. Quest, offer and redemption pauses require the latest `--expected-revision` (`pauses list` shows quest and redemption revisions, `offers list` offer revisions); a stale revision refuses the change. `--revision` is its deprecated name, removed in 2.0.0, and still works. `redemptions cancel` and `entitlements restore` take the same two options. Passing both with different numbers fails with `INVALID_ARGUMENT`, and passing neither with `INPUT_REQUIRED`. The retained `resources archive|unarchive` and `quests archive|unarchive` commands support the saved operation journal and `operations resume`. `webhooks disable` remains a deprecated alias of `webhooks pause`. `trackers resume` on an X tracker that Domino paused because its X budget is spent (`pauseReason: "x-budget-exhausted"` in `trackers get`) exits with the API's 409 `x-budget-exhausted` until `resumesAt`; one paused with `test-window-ended` resumes for another 14 days. `trackers create|update --refresh-seconds` below the plan's minimum still saves, and `effectiveRefreshSeconds` shows the interval the tracker runs at. `trackers outcomes --json` and `trackers retry-outcome --json` items carry `enrollment` (`quest` or `campaign`, an open string) on a `not-enrolled` skip, naming the enrollment the member lacks; outcomes recorded before it omit it. ## Retry operator corrections [#retry-operator-corrections] Quest enrollment and withdrawal, settlement reject and identity changes are saved resource changes with `--operation-id`, like the lifecycle commands above; `--action-id` sends them unsaved, and an `actionId` in a `members unlink-identity`, `relink-identity`, `identity-sign-in` or `relink` file counts as `--action-id`. Inventory rename/archive/unarchive/stock correction, webhook delete and webhook replay accept `--action-id`. Every `test-members` change accepts `--action-id` too, checked as an action ID (1–100 letters, digits, `-` or `_`) before anything is sent; see [run the days](campaign-testing.md#3-run-the-days) for its answers. Save that ID with the exact input before sending. After an uncertain response, retry the same command with the same ID, revision or creation-time fence and reason. A new correction uses a new ID. A reused ID with different input returns `action-id-reused`. Read state with `quests enrollments`, `stock-items list`, `webhooks get` or `states`, `leaderboard-settlements get`, or `members identities` before preparing a correction. `members identities` marks an X, Discord or Telegram account a member import vouched for with `imported: true`, and one an app login's server vouched for at sign-in also with `vouchedBy: { integration, integrationName }`. Every identity's `source` says where it came from: `member`, `import` (with the import's ID as `import`), `app-login` or `test-link`. `identity-integrations list` reports each key's `vouchesAccounts`. See [operator undo commands](management.md#operator-undo-commands) and [webhook deletion](webhooks.md#delete-an-endpoint) for arguments and refusal recovery. CLI session writes remain Test-only. ## Scheduled jobs [#scheduled-jobs] `domino schedules list --json` reads the active staged build in test, or the live app's build with `--environment live`. It returns `{ state, schedules }`; each schedule has its `declaration`, `nextRun`, `blocked` and nullable `lastRun`. A run includes `id`, `build`, `startedAt`, `scheduledAt`, `manual`, `status`, nullable `duration` in milliseconds, nullable `error` and `result`: the app's `{ outcome, summary?, metrics?, diagnostics? }`, null when it sent none and absent on runs from before results. Times are epoch milliseconds. `nextRun: null` means the schedule is stopped or blocked. The six control commands share this result shape. `schedules runs NAME --json` returns `{ runs }`, the schedule's latest 20 runs newest first, in the same run shape. All seven ship result schemas in the automation contract. With `publish`, `schedules run NAME` queues one manual run, and `pause`, `archive` and `resume` change project-wide state, with an optional `--reason TEXT` for the audit log. `recover NAME --yes` acknowledges the previous job has stopped and permits future ticks. JSON mode never prompts, so recovery without `--yes` fails with `INVALID_ARGUMENT` before sending. A queued or blocked job refuses another run with `CONFLICT`; inspect it and recover only after checking its work has stopped. See [hosted jobs](hosted.md#scheduled-jobs). ## Badge catalog and member corrections [#badge-catalog-and-member-corrections] `domino badges list --json` returns the managed catalog, including `archivedAt`. `domino badges member MEMBER --json` returns `{ items: [{ badge, awardedAt }] }`. The create/update/archive/unarchive commands return a managed badge. Award/revoke commands return `{ badge, member, awarded, changed }`: `changed: false` means the member already had the requested presence. Each write uses the normal saved operation journal and supports uncertain-answer recovery. Award and revoke require `--reason`. Rewards in `quests get`, `quests list`, `releases get`, `attempts get` and the other results that carry a reward bundle may include a group membership reward: a `kind: "tier"` reward whose `reward.group` is the group key. Treat `reward.badge` and `reward.group` as distinct from an ordinary tiered reward, which has neither. `groups members` and `groups member` list a member a quest reward added like any other; Console **Audit history** names the completion that added them. See [group membership rewards](authoring.md#group-membership-rewards). The same results' releases also carry `integrationModules`, every integration module the quest declares, beside `integrations`, which lists only `discord` and `x`; and `rewardEntries`, every reward as `{ type, data }`, beside `rewards`. Read the open fields to see a module or reward kind this CLI's SDK does not know. Results from older Relay versions omit both. # Test a campaign with test members (/docs/reference/campaign-testing) Simulate the campaign the way its participants will live it: several members, several days, through the real ledger in the test environment. Then fix what breaks and run the scenario again. A unit test of one evaluator (`runQuest`, in [test your quests](/docs/build/testing)) proves one decision; a test-member scenario proves the campaign: enrollment, prerequisites, availability, streaks, budgets, review and rewards working together over time. Work in the test environment with a `domino login` CLI session, a personal API key, or an organization automation key. The `domino test-members` commands always address test. Each step needs its own permission. An organization admin holds all of them; a project role holds only its own: | Step | Permission | Role that holds it | | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------ | | `domino test-members …` (create, act, pass, accept-anyway, advance, link-wallet, unlink-wallet, link-account, unlink-account, record, history, reset) | `publish` | editor | | `domino groups add` | `publish` | editor | | `domino points award` | `points` | editor | | `domino quests enroll` | `enroll` | editor | | `domino trackers ingest` | `ingest` | editor | | Confirm a staff-confirmed quest | `confirm` | staff | | Decide an attempt awaiting review | `review` | reviewer | A personal API key carries only the permissions chosen when it was created. Before you start, check which steps your campaign needs. A refusal with code `permission-required` means the credential lacks that step's permission; ask the operator for it. Method details, limits and reset rules are in [test members](quest-rules.md#test-members) and the public guide, [Try quests with test members](/docs/use/quests#try-quests-with-test-members). ## The loop [#the-loop] Work through these steps in order. Each ends on its completion criterion. ### 1. Map the campaign [#1-map-the-campaign] Read the published quests (`domino quests list --json`, `domino quests get QUEST --json`) and the quest source. For each quest, write down its trigger and input, completion rule (once, daily, streak, count), prerequisites, availability window, enrollment requirement, budget, review switch and rewards. Then list the thresholds and the data each evaluator reads. Done when every quest has a row and every number in the code (a minimum, a limit, a day count, a follower count, a balance) is on your list. ### 2. Cast the members [#2-cast-the-members] Create one test member per persona the campaign must handle, not one per quest. A persona is a path through the campaign: the member who does everything on day one, the one who misses a day, the one exactly at each threshold, the one just below it, the one with odd input. Name members after their persona so the history reads like a story: ```sh domino test-members create "Streak-breaker" --json domino test-members create "Exactly 100" --json domino test-members create "ゆき 🌸" --json ``` Names take 1–40 UTF-16 code units after trimming, so an emoji counts as two. Keep each member's `id` from the result. Done when every threshold on your list has a member on each side of it, and every quest has at least one member expected to pass and one expected to fail. ### 3. Run the days [#3-run-the-days] Act as each member, then move their clock. Each test member has their own clock: advancing one leaves the other test members where they are, and real members and live on real time. `QUEST` is the quest ID from `domino quests list`. ```sh domino test-members act MEMBER QUEST --json # claim domino test-members act MEMBER QUEST --input '{"kind":"text","value":"gm"}' --json domino test-members act MEMBER QUEST --wait --json # the settled attempt: status, reason, code domino test-members advance MEMBER --days 1 --json domino test-members advance MEMBER --minutes 90 --json # or --hours N, --to INSTANT ``` `act` answers the queued attempt. With `--wait` it reads the attempt until it is no longer queued, evaluating or deferred, and prints it with its `status`, `reason` and `code`; after `--timeout` seconds (default 60) it prints the attempt as it stands. Reading the attempt also needs `read`, which a personal API key may not carry. Every `test-members` change takes `--action-id ID`: any 1–100 letters, digits, `-` or `_`, the same rule as the management client's `actionId`. The CLI sends it as the change's `actionId` without saving an operation, so a simulation can name each action once (`app-sim-12-3-s2-t0`) and retry it from the CLI or the management client alike. Repeat it only with the same input. `--operation-id UUID` instead saves the change in the operation journal; the two can't be combined. `act`, `pass` and `accept-anyway` (`testMembers.act`, `pass`, `acceptAnyway`) answer an `Attempt`, and `act --wait` prints one too. `domino attempts get ATTEMPT` (`attempts.get`) answers a `ManagedAttempt`, which wraps the attempt with what surrounds it: | Command | Answer | | --------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `test-members act\|pass\|accept-anyway` | `Attempt`: `{ id, quest, member, status, reason, code, generation, … }` | | `test-members history MEMBER` | `{ attempts, … }`: each an `Attempt` with `eligibleAt` | | `attempts list` | `{ items, releases, people, total, nextCursor }`: each item an `Attempt` | | `attempts get ATTEMPT` | `ManagedAttempt`: `{ attempt, release, events, supersededBy?, person }`; read the status as `.attempt.status` | Give a member the wallet a holding or balance quest reads with `domino test-members link-wallet MEMBER --chain solana|eip155|cosmos --address ADDRESS` (Cosmos also takes `--network cosmos:`). Set up the rest of what a quest gates on with the ordinary operator commands, which accept a test member's ID: `domino quests enroll QUEST MEMBER`, `domino points award MEMBER LEDGER --amount N --reason …` for a points-balance prerequisite, `domino groups add GROUP MEMBER --reason …` for a group prerequisite. For custom trackers whose source declares `identities: ["member"]`, send records with `domino trackers ingest TRACKER FILE`, naming the member as `"author": { "id": "MEMBER" }` and keeping real `occurredAt` and `observedAt`; Relay projects them onto that member's clock. For managed X and Discord trackers, send the member's activity with `domino test-members record` (see [X and Discord activity](#x-and-discord-activity)). There is no CLI command for staff confirmation or review; use `domino api` or the management client: ```sh # confirm: body { "actionId", "quest", "member" } (management.attempts.confirm) domino api POST /attempts confirm.json --json # review: body { "actionId", "generation", "decision": "accept" | "reject", "reason" } (management.attempts.review) domino api POST /attempts/ATTEMPT/review review.json --json ``` Take `generation` from `.attempt.generation` of `domino attempts get ATTEMPT --json`. Use a new `actionId` for each new decision, and the same one only to retry it. To get a persona past a quest whose check you can't satisfy in test (a follow on X, an on-chain balance, a quiz) and on to the quests behind it, pass it: `domino test-members pass MEMBER QUEST [--completion-key KEY] --json` admits an attempt as `act` would, then accepts it as you without running the check. Prerequisites, availability, enrollment, a waiting attempt and the retry cooldown still refuse it, and the completion policy, budgets and rewards still apply: a counter quest counts one step, a streak quest today's action, and a keyed quest without `--completion-key` gets a fresh key. Automatic quests can't be passed; send the record that starts a tracker-triggered quest, and if its check rejects the attempt, `domino test-members accept-anyway MEMBER ATTEMPT --json` accepts that rejected attempt as you. Both answer the decided attempt. Use them only for checks you aren't testing: a passed quest proves nothing about its own check. `accept-anyway` also accepts what live refuses (an archived quest, a superseded attempt, a completion cooldown) and needs no reason; to rehearse an operator reversing a rejection in live, add `--live-rules --reason TEXT`, which refuses those with live's codes. Submission returns a queued attempt; evaluation continues in the background. Read the outcome before acting again. Done when every persona has walked its whole path, including the days it is meant to skip. ### 4. Read what happened [#4-read-what-happened] ```sh domino test-members history MEMBER --json # attempts with questKey and eligibleAt, skipped tracker outcomes with code domino members get MEMBER --json # progress, completions, balances, enrollments domino members progress MEMBER --json # quest progress, attempts, balances; no quest code domino attempts get ATTEMPT --json # .attempt: status, reason, code, progress domino trackers outcomes TRACKER --json # why a record admitted no attempt ``` History names each attempt's and skipped outcome's quest by ID as `quest` and by authoring key as `questKey`, absent for a quest created without a key, so filter it by key directly. `domino attempts list --json` items carry only the quest ID: map an attempt's `release` to the `releases[]` entry with that `id`, whose `key` is the quest's key. Compare each persona against what the campaign promises. An outcome is a bug when it differs from the brief, even if the code did exactly what it says. Done when every attempt and skipped outcome has an expected/actual verdict, and every balance matches a sum you computed by hand. ### 5. Fix, then prove the fix [#5-fix-then-prove-the-fix] Fix the quest or the campaign definition locally. Before publishing, replay the attempts the bug touched: `domino quests replay ATTEMPT_ID --environment test --json` runs your local quest code against a captured evaluation, so you see which past outcomes the fix changes before it reaches anyone. Replay a few attempts that went right, too. Then publish to test, create fresh members, and run the failing persona again. A member cannot go back in time, so start each re-run from a new member. `domino test-members reset MEMBER` deletes a member. It refuses with 409 `test-member-reset-blocked`, naming the blocking `state`, while an attempt is queued, evaluating or deferred, and when the member touched shared or external state: a shared project budget, inventory, redemptions or a reward handover, reward or webhook deliveries, referral relationships, a leaderboard settlement or an engagement account. A successful reset also frees the member's wallet addresses and unlinks the accounts it connected while a staging site acted as it. A refused reset changes nothing; wait for evaluations to finish, or keep the member and create another. `domino test-members purge MEMBER... --reason TEXT` (`management.testMembers.purge({ actionId, members, reason })`) cleans up after a run that redeemed offers or used referrals: it deletes up to 500 test members in one transaction, with their redemptions, reservations, reward handovers and referrals, and gives the stock unit each redemption, reservation and pickup handover took back to its stock item. List both sides of every referral; a referral with a member outside the list refuses the whole call with 409 `test-member-reset-blocked` (`referrals affect another member`, `member` the listed member, `outside` the other). Every other reason reset refuses a member still refuses the purge, naming the member in `member`. A refused purge changes nothing; purged IDs never come back. Reset, purge and erase never touch the app's own KV or SQL stores. When the app keeps member data there (ticket mappings, progress rows, uploaded photos) and a fresh member collides with the old rows, clear them in test instead of shipping a cleanup endpoint: `domino storage clear sql DB --table tickets --dry-run --json` counts the rows, and the same command with `--yes` empties the tables, keeping the schema and `d1_migrations`; `--all-tables --keep settings` empties every table but the named ones; `domino storage clear kv PHOTOS --prefix photo: --yes` deletes the keys under a prefix, looping over batches of 10,000. Live refuses with `test-environment-required`. See [hosted storage](hosted.md#app-storage). Done when the failing persona passes on a fresh member, the replays change only the outcomes you meant to change, and the other personas still pass. ## What to watch for [#what-to-watch-for] These are the places campaigns break. Treat each as a prompt to design a persona, not as a checklist to tick: the right cases depend on what this campaign reads and promises. ### Odd handles and characters [#odd-handles-and-characters] Anything a member types or a provider returns reaches your evaluator as a string. Try names, text and handles with emoji, combining marks, right-to-left scripts, zero-width characters, leading `@`, mixed case, surrounding spaces and the maximum length. * Text limits count UTF-16 code units, as JavaScript's `.length` does, so one emoji can use two of a `maxLength`. A text input that is only whitespace counts as empty. * URL inputs must be `http` or `https` with no spaces or credentials. `allowedHosts` matches the hostname exactly and case-insensitively: `x.com` does not admit `www.x.com` or `twitter.com`. * An evaluator comparing handles or keywords should decide on case, Unicode normalization and a leading `@` on purpose. X `username` arrives without `@`; members often type it with one. * Display names render in Console, leaderboards and your app. Check that a 40-character name and a right-to-left name lay out without breaking the page. ### Thresholds and boundaries [#thresholds-and-boundaries] For every number on your list, test exactly at it, one below and one above. Off-by-one shows up as `>` where the brief means "at least", or as a count that includes or excludes the current attempt. * Follower and engagement thresholds: X `followersCount` and post metrics (`likeCount`, `viewCount` and the rest) can be `null` when the provider does not report them. Decide whether `null` means fail, review or pass, and test it. * Count and streak completions: the attempt that reaches the target, the one after it, and a member who stops one short. * Budgets and daily limits: the last reward a budget can pay and the first it refuses. Project budgets are charged in the period of the member's clock. ### Missing and partial data [#missing-and-partial-data] Evaluators break on the record they did not expect, not on the happy one. * X posts: `post()` resolves to `null` for a deleted or missing post. Replies, reposts and quotes carry `isReply`, `isRetweet`, `isQuoteTweet` and the `inReplyTo…`, `retweetedTweetId` and `quotedTweetId` IDs; `media` is empty when the provider's list is malformed. A repost's text is not the member's own words. * X account reads (`x.connection()`, `profile()`, `follows()`, `reposted()`) resolve to `null` when the member has no linked X account. A test member has none until you link one with `test-members link-account` or its first `test-members record` into an X tracker links a synthetic one, so test the no-account branch with a member that has neither. * Custom tracker records: send records with optional fields missing, empty strings, zero and negative amounts, a `removed` record, and the same record ID twice. The tracker's schema refuses some of these at ingest; the evaluator must handle the rest. * Provider failures throw `CapabilityError` with code `provider-unavailable`. Relay retries the evaluation; a quest that treats the failure as a rejection punishes the member for an outage. ### Days, streaks and time [#days-streaks-and-time] Time is where campaigns look right on day one and break on day three. * Run streaks across a missed day and two submissions on one day. Advance with `--days 1` between days, not by waiting; `--days` keeps the member's time of day. * Day edges and time blocks: `--hours N` (1–720), `--minutes N` (1–43200) and `--to INSTANT` move the clock by exact time. Use them to act just before and just after midnight in the project's time zone, or on both sides of a block edge such as a 90-minute window: `--to 2026-10-09T23:59:00+02:00`, act, `--minutes 2`, act. `--to` takes epoch milliseconds or an ISO 8601 instant with an offset, and must be later than the member's clock (`test-member-clock-past`). Pass one of `--days`, `--hours`, `--minutes` or `--to` per call. * Daily quests: act, advance one day, act again; then act twice without advancing and expect the second to be refused or to change nothing. * Availability windows and campaign dates: advance a member past the end and confirm the quest closes; start one before a quest opens. * The project calendar's time zone decides where a day ends. A campaign announced in one time zone and configured in another shifts every deadline. * Deferred evaluations and outbox retries wait in real time. Advancing a member does not wake a deferred attempt early; read the attempt's `deferUntil` and wait. * A member's clock can run at most 90 days ahead of real time, whichever units moved it (`test-member-clock-limit`). Plan long campaigns as several shorter personas. ### Referrals [#referrals] A test member accepts an invitation with `domino test-members accept-referral MEMBER (--code CODE | --inviter MEMBER)`, as a signed-in member accepting it would. `--inviter` names another test member and uses its invitation code, allocating one if it has none. The same refusals apply: `self-referral`, `inviter-already-accepted` for a second inviter, `invitation-closed` once the invitee has completed a quest other than a program's qualifying quest, `referral-cycle` and `invitation-not-found`. Accepting the same inviter again changes nothing. A `signup` program qualifies the invitee on acceptance and pays both sides; a `questCompleted` program qualifies when the invitee completes that quest, or on acceptance when they already completed it. A run for an inviter, two invitees and a quest-qualified program: 1. Create three members. Have the invitees accept: `domino test-members accept-referral INVITEE --inviter INVITER`. Accept before the invitee completes any quest other than a program's qualifying quest, or `invitation-closed` refuses it. 2. Pass or act on the qualifying quest as each invitee, advancing days between them when the program's timing matters. 3. Read the result: `domino referral-relationships list` for qualifications, `domino referral-relationships history INVITER` for the inviter's referral rewards, and `domino points get` for each balance. Check milestones at their exact counts. 4. Try the refusals: the inviter's own code, a second inviter, and an invitee who already completed a quest other than the qualifying quest. Once a test member has accepted an inviter or been accepted as one, `reset` refuses it with 409 `test-member-reset-blocked`, so create fresh members for each referral run, and afterwards remove the inviter and its invitees together with `domino test-members purge INVITER INVITEE... --reason TEXT`. Check the referral UI itself on the staging site, signed in as a test member. ### Offers and stock [#offers-and-stock] A test member redeems an offer with `domino test-members redeem MEMBER OFFER`, as a signed-in member redeeming it would: the offer's price comes off the member's balance, stock goes down, and the per-member limit counts it. The same refusals apply: `offer-paused`, `collection-paused`, `prerequisites-incomplete`, `offer-limit-reached`, `insufficient-points` and `offer-out-of-stock`, and a refusal changes nothing. It prints the redemption. Hand its `entitlement` over as staff would: `management.handover.begin` at the entitlement's revision, then `management.handover.confirm` (or `domino api POST /entitlements/ENTITLEMENT/begin` and `/confirm`). A run for a stocked offer: 1. Create more members than the offer has stock, and award each the price, one of them a point short. 2. Redeem as each. Expect `insufficient-points` for the short member, and `offer-out-of-stock` once stock runs out. Redeem again as one member to hit the per-member limit. 3. Read the result: `domino redemptions history`, each balance with `domino points get`, and the offer's `available` with `domino offers list`. 4. Hand one entitlement over and confirm it. Once a test member has redeemed, `reset` refuses it with 409 `test-member-reset-blocked` (`redemption history exists`), so create fresh members for each run, and afterwards remove them with `domino test-members purge MEMBER... --reason TEXT`, which gives their redeemed stock back. Check the offer screens themselves on the staging site, signed in as a test member. ### Balance-gated quests [#balance-gated-quests] * Points-balance prerequisites (`balanceAtLeast`) and group prerequisites are testable now: award points or add the member to the group, then act. Test the member exactly at the minimum, one below, and one who drops below after spending. * Token and wallet balances: link an address with `domino test-members link-wallet`, and quest code reads it as it reads a wallet a member linked. `ctx.wallet.list()` returns it, and `ctx.wallet.balance()` reads its real on-chain balance. So pick addresses whose holdings put each persona where you want them: at the threshold, just below it, holding on another chain, holding nothing. Give one member several addresses, up to five per chain family, to test a quest that sums or picks among them. For a balance you can't find on chain, such as an exact decimal amount, test the evaluator with `runQuest` and `facts.wallet`. * A linked address stays the test member's until you unlink it or reset the member; both free it for anyone to link. An address another member already holds is refused with `account-linked-elsewhere`, so give each persona its own address. The address's real owner can take it back: if they sign in with that wallet, or connect it with a signature, in the same project and environment, it moves to them and leaves the test member's `wallets`. Link another address to continue. Read `wallets` in the `test-members list` result before a run, so a vanished address doesn't pass for a failing quest. * Linking a Cosmos address again with another `--network` moves it to that chain ID instead of adding a second entry. * Tracker records that name a linked address are attributed to the test member, and records the address authored before you linked it are decided again. A tracker that watches wallets therefore reacts to the link itself. ### Real X and Discord accounts [#real-x-and-discord-accounts] Quests that read `x.profile()`, `follows()`, `reposted()` or a member's Discord roles need an account X and Discord know. Link a real, public account to the test member without OAuth: ```sh domino test-members link-account MEMBER --kind x --username XDevelopers --json domino test-members link-account MEMBER --kind discord --subject 80351110224678912 --json domino test-members unlink-account MEMBER --kind x --subject 2244994945 --json ``` * Quest code reads the linked account like one a member linked: `x.connection()` returns its ID, and the profile, follower, repost and role reads ask X and Discord about it. Pick accounts whose public state puts each persona where you want it: following the target, not following it, holding the role, missing it. * `--username` resolves the X handle through X; a handle no account holds is refused with `not-found`, and a busy X lookup with `rate-limited`. Discord accounts link by ID only. * Linking replaces the member's synthetic or previously linked account of that provider. A member that connected an account itself on a staging site refuses with `conflict`. * An account another member holds is refused with `account-linked-elsewhere`. When the account's owner signs in with it or connects it in the same project and environment, it moves to them and leaves the test member's `accounts`. Read `accounts` in the `test-members list` result before a run. * Tracker records the account authors are attributed to the test member, and `test-members record` posts as it. * Unlinking or reset frees the account for anyone to link. * Relay never writes to a linked account. A Discord role reward to a test-linked account creates no delivery and is never sent; the attempt's history says why. Prove role rewards with a real member or `runQuest`. ### X and Discord activity [#x-and-discord-activity] `domino test-members record MEMBER --tracker TRACKER --data JSON` writes one synthetic post, message or reaction into a managed X or Discord tracker. Attribution, the quest's `where` filter and its trigger run as they do for collected activity, so this proves the quest's trigger end to end. ```sh domino test-members record MEMBER --tracker posts --data '{"content":"gm #launch","likeCount":12}' --json domino test-members record MEMBER --tracker chat --data '{"channelId":"1300000000000000001","text":"gm"}' --json domino test-members record MEMBER --tracker reactions --data '{"emoji":{"name":"⭐"}}' --json domino test-members record MEMBER --tracker reactions --as messageAuthor --json # a reaction to MEMBER's message ``` `--data` uses the stored record's paths, the same ones the quest's `where` reads: `content`, `author.username`, `isReply` and the counts (`likeCount`, `viewCount` …) for X; `channelId`, `parentChannelId`, `replyTo` and `text` for messages (`text` only when the tracker stores text); `channelId`, `parentChannelId`, `messageId`, `emoji`, `reactorRoles` and `messagePublishedAt` for reactions. Set `parentChannelId` to make the record a message or reaction in a thread of that channel. Relay sets IDs and real time. The answer's `admissions` counts the tracker's quests that admitted, skipped or filtered out the record; `test-members history` lists skips with their code. What to watch for: * Every condition of the filter, one at a time: a record that matches, then one that misses by a single field (another channel, another emoji, `likeCount` one below the bound, the text with different case). A filtered record leaves no outcome, only the `filtered` count. * A reaction quest that pays `messageAuthor`: send with `--as messageAuthor`. A reaction the member makes has no message author a quest can pay, and is skipped with `no-account`. * Discord message text: a tracker without `text: true` refuses `text` with `validation-failed`, and a quest filtering on `text` never matches its records. * The record's time is now. A tracker window that excludes now refuses it; a quest whose window excludes the member's projected time skips it with `outside-window`. * A record names the member's linked account of the tracker's provider when it has one. Otherwise it names a synthetic account linked on the first record, which is what `x.connection()` then returns. X doesn't know a synthetic account, so `profile()`, `follows()` and `reposted()` answer as X does for an unknown account; link a real account (above) or test those checks with `runQuest` and `facts.x`. * Unlinking the synthetic account from the member makes later records skip with `unlinked-account`, the path a member who disconnected X takes. Reset removes the member's synthetic accounts and records with everything else, and unlinks its linked accounts. ### Photo quests [#photo-quests] `domino test-members act MEMBER QUEST --photo shot.jpg --wait` uploads a JPEG or PNG of at most 5 MB as the member's photo, then submits it. The quest's real image check runs on it, so `ctx.images.review` calls the AI provider. With the quest's `review: () => ({ enabled: true })`, a photo the check finds `unclear` and maps to `decision.review` settles as `needs-review` and waits in Console's review queue; with review off it becomes a rejection. Only that member can submit the photo, and reset deletes it. To cover the three answers without a real photo for each, test the evaluator with `runQuest` and `facts.images.review`, which answers `ctx.images.review` with `{ kind: "pass" | "fail" | "unclear", reason }`. `facts.review.photo` answers only the deprecated `ctx.review.photo()`. ### What test members cannot reach yet [#what-test-members-cannot-reach-yet] | Quest needs | With test members | Instead | | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------- | | X profile, follower and repost reads, Discord roles, for a state no public account has | Link a real account with `test-members link-account`; a synthetic account is unknown to X and Discord | `runQuest` with `facts.x` or `facts.discord` | Report each gap you had to route around in your handoff, so the operator knows which paths were proven end to end and which only at the evaluator. ## Pace a live rehearsal [#pace-a-live-rehearsal] The loop walks members through simulated days. A rehearsal moves many members in real time instead, so leaderboards and screens update every few seconds while people watch. Run it from a driver process outside the hosted app: a terminal, a CI job or any long-running process. 1. **Drive from outside the hosted app.** Use `management.testMembers.*` (`createManagementClient` in the test environment) or `domino test-members`, with a `domino login` session, a personal API key or an organization automation key that holds `publish` in test. A hosted app key or service key gets 403 `credential-not-allowed`. 2. **Don't pace with schedules, `waitUntil` or a browser tab.** A hosted schedule runs at most every 5 minutes (`every` from `5m`, or cron minute slots at least five minutes apart), once per run, within 30 seconds, and must not start work that outlives the response ([scheduled jobs](/docs/build/schedules)). Use one only for coarse steps that fit those bounds. A loop in an open page stops when the tab closes. 3. **Give each step a deterministic `actionId`.** Derive it from the scenario, such as `rehearsal-3-m17-s4` (1–100 letters, digits, `_` or `-`). A restarted driver resends steps with the same IDs and input, and each replays its first answer instead of acting twice. From the CLI, pass the same ID as `--action-id` (see [run the days](#3-run-the-days)). 4. **Bound concurrency.** Keep a fixed number of calls in flight, not one per member. The management client resends a write that carries an `actionId` after no answer, HTTP 500, 502, 503 or 504, or a 429 with `Retry-After`, waiting as asked, up to three tries within the call's deadline (30 seconds by default). When a call still fails with 429, slow the driver down. [Rate limits](automation.md#rate-limits) lists the calls with limits of their own, such as linking an X account by handle. 5. **Move clocks for time.** For streaks, daily quests and availability windows, `advance` the member (`--minutes`, `--hours`, `--days` or `--to`) instead of waiting in real time. Pace in real time only what people watch. Done when the driver can stop and restart mid-scenario without doubling a step, and every member's history matches the scenario's step list. ## Check participant flows on staging [#check-participant-flows-on-staging] `domino test-members` proves the ledger. To prove the app's own screens (sign-in state, profile pages, referral links, the quest UI), sign the staging site in as a test member and use it. Stage a build first ([hosted projects](hosted.md#source-and-release-workflow)). 1. **Get a staging link.** `domino api POST /staging/link body.json` with `{ "path": "/" }` returns `{ url, expiresAt }`: a one-minute URL that opens staging once. Mint a new one per browser session. It needs `read` and the test environment. 2. **Open it.** Navigate your browser to `url`. The staging host answers 303 to `path` and sets the `__Host-domino-staging` cookie, valid 12 hours. Without a browser, request `url` without following redirects and keep the cookie from `Set-Cookie`. 3. **Act as a test member.** The page shows the test toolbar when the link names an operator. Use its member picker, or call the toolbar's endpoints on the staging origin with the staging cookie, `X-Domino-Toolbar: 1` on every request, and the staging origin as `Origin` on every POST: * `GET /__domino/toolbar/state`: the operator, the project's test members, and who the page is signed in as. * `POST /__domino/toolbar/members` with `{ "name" }` creates a test member. * `POST /__domino/toolbar/session` with `{ "member": "" }` signs the page in as that member and sets the `__Host-relay_session` cookie; `{ "member": null }` signs out. Reload the page afterwards. The session is an ordinary 8-hour member session. * `POST /__domino/toolbar/members//advance` with `{ "days" }`, `GET …//quests`, `POST …//passes` with `{ "quest" }`, and `GET …//history?since=` match `domino test-members advance`, `pass` and `history`, which you can use instead. 4. **Walk the flow** in the browser as the member, then read the outcome with `domino test-members history`. The toolbar's collapsed bar sits in the bottom-right corner, over the app. When a tap in the browser fails with "`` intercepts pointer events", the app's bottom-pinned UI, such as a tab bar, should make room for it: while the toolbar is mounted, `` carries `data-domino-staging-toolbar` and the `--domino-staging-toolbar-inset-bottom` custom property, the distance from the viewport's bottom edge to the top of the collapsed bar in px. Opening the toolbar leaves it unchanged. **Hide toolbar** in the toolbar's `⋯` menu hides it for the browser tab (kept in `sessionStorage` across reloads, until the tab closes), leaving only a small "Show the Domino staging toolbar" button in the corner; while hidden, the property is `0px` and the attribute is gone, so screenshots and layout checks see the live layout. Live has no toolbar, so give the property a `0px` fallback: ```css nav.tab-bar { padding-bottom: calc(env(safe-area-inset-bottom) + var(--domino-staging-toolbar-inset-bottom, 0px)); } ``` App code never reads the toolbar's endpoints. An app that picks phases or open quests by date reads the member's clock from `relay.me.get()` (`clock`) with `memberTime()`, and reads it again on `domino:test-state-changed` ([participant API](participant-api.md#the-members-clock)), so an advanced test member sees the app on their own date. Who the link names decides whether step 3 works. Each toolbar request checks again that the operator still holds `publish` in test, and that the key behind the link is still active: | Credential that minted the link | Acts as | Toolbar | | ---------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `domino login` CLI session, Console | You | Works while you hold `publish` in test. | | Personal API key (`ak_`) | You | Only when the key itself holds `publish`; ends when the key is revoked or loses it. | | Organization automation key (`dak_`) | `automation:` | Only when the key holds `publish` and reaches the project; ends when the key or the secret that minted the link is revoked or expires. | | Hosted app key, service key, Claude or ChatGPT | Nobody | Views staging; the toolbar answers 403 `operator-required`. | Toolbar actions are audited as the operator: your user, or `automation:`. A revoked or expired key answers 401 `unauthenticated`; mint a new link with an active credential. A human reviewer without Console access signs in with the staging password instead (Console **Settings → Staging access**); a password session views staging but never gets the toolbar. **Before staging, on a `domino dev` preview.** The same steps work on a running preview, so you can check the flow before integrating into `main`. In step 1, add the preview URL `domino dev` printed: `{ "path": "/", "preview": "https://--.domino.page" }`. The link and its `__Host-domino-staging` cookie open that preview host only, never staging or another preview, and only while the preview runs; restart `domino dev` and mint a new link if it went offline. The toolbar, its endpoints on the preview origin, and the credential table above work as on staging, and the app's dev server never receives the cookie. A URL that is not a running preview of the project answers 404 `preview-not-found`. Without the link's session, the preview's toolbar endpoints answer 403 `operator-required`. ## Report [#report] End with a short table per persona: what it did on which simulated day, the expected and actual outcome, and the balances. List the bugs you fixed with the persona that found them, the replays you ran, and the paths you could only test with `runQuest`. Leave the members you used for inspection, or reset them when the operator wants a clean list; a member whose reset is refused stays in the list with the `state` that blocked it. # What Domino supports (/docs/reference/capabilities) This page is generated from the capability catalog bundled with the CLI. Unlisted behavior is unknown, not an implied feature. ## identity [#identity] **Built in** Real email and configured OAuth sign-in, and wallet sign-in where a project enables it; identities and sessions are scoped to a project and environment. A signed-in participant reads their own verified sign-in email addresses with me.get() (email, or null after wallet-only sign-in, and emails); participant routes never return another member's. me.get() also returns the member's clock (\{ now, offsetMs }), which runs ahead for a test member whose clock was advanced; memberTime(me) from @domino-sdk/relay/browser gives the member's current time for date-gated screens. Entry points: `auth.providers`, `auth.start`, `auth.emailStart`, `auth.emailVerify`, `auth.session`, `me.get`. ## connected-accounts [#connected-accounts] **Built in** Project-scoped Discord membership, roles, nickname, display name, boosting status and captured message reads. Participants link identity; operators install the bot once. X and Discord sign-ins and connections store the account's public name, handle and picture for participant profiles. Linking X signs in with X (OAuth 2.0 with PKCE) and stores the account's stable X user ID, which tracker attribution and ctx.x ownership checks use; a handle never establishes ownership. No arbitrary Discord writes. Operators read active X and Discord selections and reviewers unlink them with a reason in Console or the management API. Subject and revision preconditions prevent stale reviews from unlinking newer selections. Unlinking affects the project and environment, preserving identity ownership, sign-in grants and existing rewards, except that an imported or vouched account the member never signed in with or proved is released so its owner can link it. Reviewers restore exact retained OAuth selections through the kind-keyed members.identityStates.relink operation, with a revision and audited reason. Relink keeps ownership, sign-in grants and sessions, and redecides unlinked historical activity under the current quest policy. members.identityStates reads every identity a member proved, by kind (wallets, X, Discord and Telegram accounts, app identities), with its link, revision, sign-in and allowed actions, and unlinks or relinks any of them with an expected revision, an audited reason and a receipt-backed action ID, naming the identity as a member import does (\{ kind: "x" | "discord" | "telegram", subject }, \{ kind: "app:", subject } or \{ kind: "wallet", chain, address }; an X or Discord subject resolves to the member's selected account, else the only one they hold, and 409 identity-ambiguous otherwise) or with the selector the list returned, whose oauth and app forms are deprecated. Each identity read says where it came from (source: member, import with the import's ID, app-login or test-link); the per-kind methods (oauthAccounts, unlinkOAuthAccount, identityLinks) are deprecated 1.x adapters. Entry points: `auth.accounts`, `auth.start`, `auth.disconnect`, `management.members.identityStates.list`, `management.members.identityStates.unlink`, `management.members.identityStates.relink`. CLI: `domino members identities`, `domino members unlink-identity`, `domino members relink-identity`. [Read the guide](/docs/reference/verification). ## identity-exchange [#identity-exchange] **Built in** Keep an existing application login using createParticipantBackend on its server. A Console administrator creates a project/environment identity integration key. The adapter verifies the application session, exchanges its stable subject for a member-only Domino session, and routes participant requests with trusted scope. Keys never expire unless an expiry is chosen at creation. They rotate with overlap: addSecret issues a second secret while the first keeps working, revokeSecret retires the old one, and removeSecretExpiry makes an expiring secret permanent; each secret reports its last use. When an integration's last secret expires, its sessions end. rotate, which replaces every secret at once, is deprecated. A key created with an identity kind (identityKind: "app:", set only at creation) links each exchanged subject to its member as an app: identity, which custom tracker records can name and ctx.member.identities() lists. A kind belongs to the integration that first claimed it, also after a disconnect; a new integration reuses a kind with identities only by adopting them (adoptIdentitiesFrom plus an audited reason; identityIntegrations.kinds lists owners). Reviewers unlink and relink a member's app identity with an audited reason; unlinking ends its sessions and refuses its sign-in (409 APP\_IDENTITY\_UNLINKED) until relinked. Administrators manage these keys in Console, or with management.identityIntegrations from a Console session; an API key or CLI sign-in cannot change them. An administrator's CLI session lists them without secrets (identityIntegrations.list and kinds; domino identity-integrations list and kinds), so the owner creates the key in Console (which, for a Domino-hosted app, stores it as the app secret APP\_IDENTITY\_KEY in the same step: identityIntegrations.createIntoAppSecret and addSecretIntoAppSecret, publish permission, no token answered) or stores it with printf %s "$KEY" | domino secrets set APP\_IDENTITY\_KEY, and the agent confirms it; non-admin CLI sessions and every key type are refused. members.identityStates unlinks and relinks app identities by kind beside wallets and accounts; unlinkIdentity and relinkIdentity are deprecated adapters. With Vouches accounts on (identityIntegrations.setAccountVouching with a reason, from a Console session with publish permission), authenticate may also return accounts (wallets and one X, Telegram and Discord account each) that the exchange attaches under a member import's rules, answering each one attached, already-linked or refused with an import refusal code; with it off, accounts are refused with 403 ACCOUNT\_VOUCHING\_OFF. exchangeParticipant runs the same exchange from a server without a browser. inspectIntegrationKey (GET /v1/auth/integration) answers which integration a key belongs to (organization, project, environment, origin, this secret's expiry and the app's origins in that environment), never a secret and without counting as a use; domino secrets set runs it on every di\_ value and refuses, storing nothing, a key that is unknown, expired, revoked or for another project or environment. Entry points: `management.identityIntegrations.list`, `management.identityIntegrations.create`, `management.identityIntegrations.rotate`, `management.identityIntegrations.addSecret`, `management.identityIntegrations.addSecretIntoAppSecret`, `management.identityIntegrations.createIntoAppSecret`, `management.identityIntegrations.revokeSecret`, `management.identityIntegrations.removeSecretExpiry`, `management.identityIntegrations.revoke`, `management.identityIntegrations.kinds`, `management.identityIntegrations.setAccountVouching`, `management.members.unlinkIdentity`, `management.members.relinkIdentity`, `management.members.identityStates.list`, `management.members.identityStates.unlink`, `management.members.identityStates.relink`. CLI: `domino identity-integrations list`, `domino identity-integrations kinds`. [Read the guide](/docs/reference/participant-api). ## quests [#quests] **Built in** Claim, photo, quiz, staff and automatic quests; immutable releases pin attempts and promised rewards. Keyed completions and atomic lifetime, day, week (from Monday) or month budgets of the project calendar prevent duplicate awards for repeatable quests. Evaluations replay deterministically: ctx.now is the attempt's evaluation time, Date.now() and new Date() stay frozen at it, and Math.random() and crypto randomness are seeded per attempt. Reads name tiered rewards by ID; apps match them to their keys with tieredRewards.list. Deleting a quest file does not archive it: domino quests archive|unarchive --reason (publish permission; test only, since live refuses archive state changes from every credential with promotion-required) or the quest's Console page publishes its current release again as archived or reopened, with an audited reason. Archived quests admit no new attempts, leave collections and supportedInteractions, keep completions and rewards, and stay archived when their code deploys again; unarchiving re-checks the app's supported interactions and the prerequisite graph. Console-created quests change participation in their Console editor. An operator with publish pauses or resumes one quest, or all redemptions, against the revision they saw and with a reason (management.quests.pause/resume and management.redemptions.pause/resume). With read permission, operators list each quest's current release, archived ones included (management.quests.list), and every paused or resumed operation (management.operations.list). With read and publish, they page through release history, every release a quest has had, newest first and optionally for one quest (management.releases.list), and read one current or historical release (management.releases.get). From the CLI, domino quests list|get read current quests, releases list|get read release history, and pauses list reads the revision pause and resume need; the bare domino quests, releases and status read Console's former pages and are deprecated. Entry points: `keyed`, `budget`, `quests.experience`, `quests.observe`, `quests.open`, `tieredRewards.list`, `management.quests.archive`, `management.quests.unarchive`, `management.quests.list`, `management.releases.list`, `management.releases.get`, `management.operations.list`, `management.quests.pause`, `management.quests.resume`, `management.redemptions.pause`, `management.redemptions.resume`. CLI: `domino quests list`, `domino quests get`, `domino quests archive`, `domino quests unarchive`, `domino quests pause`, `domino quests resume`, `domino quests enrollments`, `domino quests enroll`, `domino quests withdraw`, `domino releases list`, `domino releases get`, `domino pauses list`, `domino redemptions pause`, `domino redemptions resume`. [Read the guide](/docs/reference/authoring). ## points [#points] **Built in** Define typed point-ledger handles in code and pass them to authored rewards, budgets, prerequisites, leaderboards, referrals and engagement rules. settings.pointLedger selects a Console ledger as a typed reference; an empty selection pauses the quest. The server applies completion and rewards; the browser cannot write balances. A signed-in participant reads only their own balances (current and available after reservations) and point history, without operator reasons. Reads name ledgers by ID; apps match them to their keys and names with pointLedgers.list. An external score gets its own ledger, updated with absolute values (set updates) by the server that measures it; that ledger is never spent. Console member detail shows every balance and provides history, award, adjustment and reversal controls. Creating, editing or archiving a ledger needs publish and works only in test: live refuses it from every credential with promotion-required, and live ledgers come from a promotion from test. Archived ledgers stay readable and existing published rewards keep working, but new reward choices and definitions cannot use them. From the CLI, domino point-ledgers list|create|update|archive|unarchive manage ledgers. Entry points: `defineLedger`, `points`, `settings.pointLedger`, `decision.accept`, `me.observeProgress`, `me.balances`, `me.pointHistory`, `pointLedgers.list`, `management.pointLedgers.list`, `management.pointLedgers.create`, `management.pointLedgers.update`, `management.pointLedgers.archive`. CLI: `domino point-ledgers list`, `domino point-ledgers create`, `domino point-ledgers update`, `domino point-ledgers archive`, `domino point-ledgers unarchive`. ## review [#review] **Built in** Queue a quest attempt for an authorized human reviewer. Review decisions and reward execution remain server-owned. Reviewers with review accept or reject a waiting attempt at the generation they saw, with a reason, and evaluate a failed attempt again (management.attempts.review, management.attempts.retry). With read permission, operators page through attempts newest first by quest, status and search, with the releases they pin, the members they name and a total (management.attempts.list), and read one attempt with its release, recorded events and member (management.attempts.get). From the CLI, domino attempts list|get reach the same reads. An evaluator rejects with decision.reject(\{ reason, code? }): the optional code (lowercase letters, digits and hyphens, at most 64 characters, none of the codes Relay writes itself such as budget-exhausted) names the cause, and these reads and Console's attempt detail show it beside the reason; member responses never carry it. Entry points: `decision.review`, `decision.reject`, `management.attempts.review`, `management.attempts.retry`, `management.attempts.list`, `management.attempts.get`. CLI: `domino attempts list`, `domino attempts get`. ## text-ai-review [#text-ai-review] **Built in** Review text against author criteria with ctx.review\.text. Returns pass, fail or unclear; quest code chooses the decision. Text is limited to 16,000 JavaScript string units, criteria to 2,000. Successful results are captured for replay. Provider failures remain errors. Does not add a free-text submission form or verify authorship. Entry points: `defineQuest`. [Read the guide](/docs/reference/verification). ## image-checks [#image-checks] **Built in** Evaluator ctx.images.inspect, compare, review, extract and seen check images without exposing bytes or URLs to quest code. References name the attempt's photo evidence, the linked X profile picture, a post's attached image, or a reference image deployed from relay.json images. Perceptual hash distances (0-64) compare images; AI review and field extraction accept up to four images; seen records the image when called, even for a later-rejected attempt, and counts other participants' near-duplicates. Results are captured for replay. Checks run at verification time and do not prove retention. Entry points: `defineQuest`. [Read the guide](/docs/reference/verification). ## streaks [#streaks] **Built in** streak(\{ days }) pays one lifetime milestone per member after consecutive project calendar days with an accepted attempt. It completes manual quests (the day each submission was admitted, so a manual streak counts submission days) and tracker-triggered quests (the day of each record observation's occurredAt, such as an X post's publication or a Discord message's post time). Several accepted attempts on one day count once, late evidence repairs gaps and merges runs, and a budget-exhausted milestone keeps its days. Days end at midnight in the project timezone, a timezone change keeps runs consecutive, and a published goal never changes. Content quests and prerequisite-only automatic() quests cannot be streaks. Entry points: `streak`, `manual`, `automatic`. [Read the guide](/docs/reference/quest-rules). ## project-calendar [#project-calendar] **Built in** One timezone per project and environment sets where days, weeks (ISO, from Monday) and months begin for quest budgets, engagement budgets, streak days and calendar leaderboard windows, so a weekly budget and a weekly board cover the same week. Evaluators use the attempt-pinned ctx.calendar.day(at?) for the YYYY-MM-DD\@Zone key and exact day bounds; without at it reads ctx.eligibleAt. UTC until set with relay.json calendar.timeZone, Console Settings, or management.calendar.configure (publish permission; live refuses it from every credential with promotion-required and takes test's calendar through a promotion). Named IANA zones only; fixed offsets are rejected, and no quest, budget, streak or board names its own zone. A change takes effect at the next midnight of the current zone: the open day, week or month finishes in the old zone, the next one ends at the new zone's next boundary, and streak days stay consecutive. A deployment applies relay.json's zone only when the declaration changes, so a Console change stays until relay.json is edited. From the CLI, domino calendar get reads the calendar and calendar configure --time-zone --reason changes it as a saved operation (--operation-id). Entry points: `management.calendar.read`, `management.calendar.configure`, `budget`, `streak`, `defineLeaderboard`, `defineEngagementReward`. CLI: `domino calendar get`, `domino calendar configure`. [Read the guide](/docs/reference/authoring). ## milestones [#milestones] **Compose with supported features** Compose typed same-participant prerequisites with completed(quest), a project-calendar day/week/month completion, balanceAtLeast(ledger, minimum), or inGroup(group). Completion prerequisites are durable facts; balance and group requirements observe current eligibility. Referral milestones are native, not composed: they count currently qualified friends and recover or restore rewards, with debt, when a friend's qualification changes. Other aggregate thresholds need application logic. Entry points: `completed`, `balanceAtLeast`, `defineGroup`, `inGroup`, `automatic`, `defineQuest`. [Read the guide](/docs/reference/quest-rules). ## groups [#groups] **Built in** Project-level groups: named sets of members that inGroup prerequisites, leaderboard group filters and referral inviterGroup rules read by key. defineGroup gives code a typed handle for a key. Publishing code that names a group the environment lacks creates it with no members and the handle's title (its key when only a leaderboard or referral rule names it): a deployment in test, an approved promotion in live, audited as group-created and listed as a group-created warning beforehand; it never changes an existing group's title or members. Operators create groups and manage their members in Console Groups, with domino groups, or with management.groups. Reads need read; changes need publish, which a personal API key can use in live but a CLI session cannot. Every membership change records its reason in the audit history, and adding a member checks prerequisite-only automatic quests. A quest reward, groupMembership(group) beside points(...) and badge(...), adds the member when the completion's rewards are delivered, with the completion as the audited reason; it is how an approved application (manual review) joins a curated group in one step. Publishing refuses a group reward naming an archived group (validation-failed; conflict on promotion approval), since publishing never unarchives a group. Revoking the completion removes the membership it added unless an operator's add or another unrevoked completion also backs it; an archived group keeps the member. Groups are archived, never deleted: an archived group keeps its members, which still count, but its membership cannot change until it is unarchived. A group that the current release of a quest that is not archived rewards cannot be archived (group-in-use, naming the quests). Leaderboard settlements read the membership a group had at their cutoff. A leaderboard's exclusion group is a group too: Relay generates one per board under the reserved domino--exclusions-- key prefix, and a group bound as any board's exclusion group cannot be archived (group-in-use) until those boards are bound elsewhere. management.leaderboards.group is a deprecated alias that creates a missing group on its first member. Entry points: `defineGroup`, `inGroup`, `groupMembership`, `management.groups.list`, `management.groups.get`, `management.groups.create`, `management.groups.update`, `management.groups.archive`, `management.groups.unarchive`, `management.groups.members`, `management.groups.member`, `management.groups.addMember`, `management.groups.removeMember`. CLI: `domino groups list`, `domino groups get`, `domino groups create`, `domino groups update`, `domino groups archive`, `domino groups unarchive`, `domino groups members`, `domino groups member`, `domino groups add`, `domino groups remove`. [Read the guide](/docs/reference/authoring). ## campaign-eligibility [#campaign-eligibility] **Built in** Code-authored campaigns coordinate a half-open time window and optional durable enrollment across quests. Quests may narrow that window. Participant quest views return the campaign, first enrollment time and current withdrawal state, effective window, typed prerequisite failures, stable blocked reasons, and each authored budget's current remaining capacity and reset time. Members may enroll, withdraw and re-enroll themselves. Trusted servers and Console operators with enroll permission may enroll, withdraw with an audited reason, and restore a member. Withdrawal blocks new admissions without deleting attempts, completions, rewards or the first enrollment time. Entry points: `defineCampaign`, `campaigns.enroll`, `campaigns.withdraw`, `management.campaigns.enroll`, `management.campaigns.withdraw`, `quests.experience`. [Read the guide](/docs/reference/quest-rules). ## referrals [#referrals] **Built in** Project-scoped invitation codes, immutable inviter attribution, configurable signup, quest or external qualification, fixed rewards and distinct-friend milestones. For several verified actions, qualify through a counter quest with questCompleted(quest). External qualification can be withdrawn and reconciles rewards; a group plus an inGroup quest qualifies once and group removal does not unqualify. Selective one-level inviter bonuses add points without reducing invitee awards. Code-defined rules publish through reviewed project deployments. From the CLI, domino referral-programs list|qualify, referral-relationships list|history|pause|resume and referral-rewards resolve reach the same reads and commands; changes are saved operations (--operation-id). Entry points: `defineReferral`, `referrals.list`, `referrals.summary`, `referrals.accept`, `referrals.history`, `management.referrals.list`, `management.referrals.participants`, `management.referrals.history`, `management.referrals.qualify`, `management.referrals.resolveReward`, `management.referrals.pause`, `management.referrals.resume`. CLI: `domino referral-programs list`, `domino referral-programs qualify`, `domino referral-programs archive`, `domino referral-programs unarchive`, `domino referral-relationships list`, `domino referral-relationships history`, `domino referral-relationships pause`, `domino referral-relationships resume`, `domino referral-rewards resolve`. [Read the guide](/docs/reference/referrals). ## cross-participant-rewards [#cross-participant-rewards] **Built in** Referral programs can reward inviter and invitee independently and award a selective percentage of eligible points to the inviter. Bonuses accumulate fractions and support limits, suspension and correction recovery. Arbitrary participant-to-participant transfers and multi-level commissions are not supported. Entry points: `defineReferral`, `referrals.summary`. [Read the guide](/docs/reference/referrals). ## rankings [#rankings] **Built in** Publish project-scoped points leaderboards through project deployments. Read paginated standings, your rank, and nearby competitors with shared tie ranks, or, on a board defined with ties: "earliest", equal scores ordered by who reached them first (the settlement's earliest order), each with its own rank. A signed-in member pages through the point entries behind their own score on a board (me.leaderboardHistory), selected by the board's own window, quests and calculation, so they sum to the score; an excluded member gets none. Code defines balance, earned, or net calculations and time windows. Public access is explicit; participant rows use board-specific aliases unless the project enables profiles, which add each member's name and avatar. A board's optional group ranks only that group's members (see the groups capability). A board defined with includeUnscored also lists its eligible members without qualifying points at score 0, sharing one rank after every member who scored; settlements leave them out. Scoped management APIs supply external points and corrections. A board leaves out the members of its exclusion group: its own generated group (key domino--exclusions--), created by its first exclusion, or a group an operator binds with management.leaderboards.bindExclusionGroup so it moderates several boards. management.leaderboards.exclude and exclusions change and read that group, as the groups operations do; exclusion changes need publish, also in live from a personal API key; binding an exclusion group is a definition change that live refuses with promotion-required; both are audited; an external score, such as a tracked wallet balance, gets its own ledger, updated with absolute values (set updates). Referral bonus points contribute to their named balance. Fixed and calendar earned/net boards can declare point and tiered-reward prizes at window end. Relay freezes eligible standings and the members of the exclusion group bound at the cutoff, then a reviewer approves delivery in Console or with management.leaderboards.approveSettlement; each winner gets an ordinary completion that can be revoked. A settlement may also declare a pool prize (settle.pool: a base-unit amount in points or a token unit with decimals, an optional minScore): the freeze splits it over the eligible scores in exact integer math, rounding down and giving leftover units to the largest remainders, and stores each member's pool share on the frozen row. A points pool pays each share into its ledger in the winner's completion on approval, and revoking the completion reverses it; any other unit is only recorded, and management.leaderboards.settlementExport lists each row's share with the member's verified wallets for the operator to pay off-platform, settlementPayoutCsv turns that list into the CSV the CLI prints, and Console downloads it from the settlement panel. Cross-project balances are not supported. A board that counts engagement points first takes a closing measurement: the settlement is closing while Relay resamples the content (a sample within 30 minutes after the cutoff counts as measured at it), then freezes and lists content frozen at its last good sample as unmeasured; management.leaderboards.retrySettlementMeasurement measures it again. Reviewers can reject a pending settlement with an audited reason. Rejection pays no prizes and permanently retains the frozen window; settlementReviews and settlementReview include rejected history, while legacy reads keep their original statuses. From the CLI, domino leaderboards list|standings|history|exclusions|exclusion-group read boards, leaderboards exclude|include change exclusions, leaderboards bind-exclusion-group binds a board's exclusion group, and leaderboard-settlements list|get|approve|reject|retry-measurement review settlements, leaderboard-settlements export prints the payout list as CSV (--json for the typed result); changes are saved operations (--operation-id). Entry points: `defineLeaderboard`, `leaderboards.list`, `leaderboards.standings`, `me.leaderboardHistory`, `management.leaderboards.list`, `management.leaderboards.standings`, `management.leaderboards.history`, `management.leaderboards.exclusions`, `management.leaderboards.exclude`, `management.leaderboards.exclusionGroup`, `management.leaderboards.bindExclusionGroup`, `management.leaderboards.group`, `management.leaderboards.settlements`, `management.leaderboards.settlement`, `management.leaderboards.approveSettlement`, `management.completions.revoke`, `management.leaderboards.settlementReviews`, `management.leaderboards.settlementReview`, `management.leaderboards.settlementExport`, `management.leaderboards.rejectSettlement`, `management.leaderboards.retrySettlementMeasurement`. CLI: `domino leaderboards list`, `domino leaderboards standings`, `domino leaderboards history`, `domino leaderboards exclusions`, `domino leaderboards exclude`, `domino leaderboards include`, `domino leaderboards exclusion-group`, `domino leaderboards bind-exclusion-group`, `domino leaderboards archive`, `domino leaderboards unarchive`, `domino leaderboard-settlements list`, `domino leaderboard-settlements get`, `domino leaderboard-settlements export`, `domino leaderboard-settlements approve`, `domino leaderboard-settlements reject`, `domino leaderboard-settlements retry-measurement`. ## hosted-app-secrets [#hosted-app-secrets] **Built in** Operator-set secrets for a hosted app's server code, separate in each environment: test's reach staged builds and live's the live app; a promotion never copies them and warns (binding-missing) about ones live lacks. Worker apps declare the secrets they read, with descriptions and required flags, in relay.json app.secrets; domino stage warns about missing required ones and reports missingSecrets without blocking, and domino secrets list reports declared secrets that are missing. domino secrets set/list/delete (test unless --environment live; RELAY\_ENVIRONMENT and relay.json are ignored, with a warning when they name live, and the result's environment says which was used), Console Settings → App secrets with Test or Live selected, or the management API; publish in that environment changes them, read lists names, and a CLI session may set and delete them in live. Values are write-only, 1 byte to 5 KiB, at most 50 per project environment; names are uppercase letters, digits and underscores, excluding DOMINO\_ names and ASSETS. Worker builds staged after a change receive them as secret bindings, replacing app vars of the same name; earlier builds keep their values. A live change reaches the live app with the next build promoted to live. Static apps and domino dev receive none. KV and D1 bindings are declared separately under app.storage. domino secrets set checks a value starting with di\_ as an app login key before storing it (see identity-exchange). Entry points: `management.secrets.list`, `management.secrets.set`, `management.secrets.delete`. CLI: `domino secrets list`, `domino secrets set`, `domino secrets delete`. [Read the guide](/docs/reference/hosted). ## plugin-connections [#plugin-connections] **Built in** Experimental while the plugin contract is new: management.connections and the domino connections commands may change in a minor release. A project connects to plugins, the integrations Domino writes, separately in each environment; a promotion never copies a connection. management.connections.list or domino connections list answers every plugin available to the project, a never-connected one as disconnected with revision 0; get reads one (404 unknown-plugin otherwise), with its status (disconnected, ready, or attention with an issue), config, the JSON Schema the config must satisfy, its secret fields and whether each is set, and its revision. connect takes the config and a value for every secret field (422 invalid-config with the failing path, 422 invalid-secrets with the field, 409 already-connected); update replaces the config, some secrets or both and sets the status to ready (409 not-connected, 409 connection-changed on a stale expectedRevision); disconnect deletes the secrets and keeps the connection as disconnected, and connect works again. A plugin whose manifest declares OAuth connects through the vendor instead: authorize (domino connections authorize) takes the config and answers the vendor's authorizationUrl, which works once for ten minutes; the vendor's callback sends the browser to Console with a one-time oauthCode, and completeAuthorization redeems it within five minutes, for the person who started it only (403 scope-mismatch for anyone else, 409 oauth-flow-expired for a spent or expired code), making the connection ready with its tokens sealed on it; an automation, service or hosted app key can't authorize (403 credential-not-allowed); connect refuses such a plugin with 409 oauth-required, authorize refuses one connected with secrets with 409 oauth-not-supported, and a deployment without the vendor's OAuth app with 409 plugin-not-configured. A forged, replayed or expired state is refused and changes nothing. Domino refreshes the tokens before they expire; a refresh the vendor refuses, or tokens that can't be refreshed any more, set attention with reconnectReason revoked or expired, which only authorizing again clears; disconnect deletes the tokens, then revokes them at the vendor's revocation endpoint, logging a failed revocation. Every change takes an actionId, so a retry applies once; finishing an OAuth connection is once by its code instead, and a repeat answers 409 oauth-flow-expired. Reads need read and changes publish; a CLI session may change connections in live, an automation key or MCP client in test only. Secrets and OAuth tokens are sealed at rest and write-only: no read, error, log or CLI output answers one, and the CLI reads them from stdin as JSON or a hidden prompt, never from arguments. Test or live is chosen only by --environment (test by default). management.plugins.list answers the plugins the deployment runs with the public part of each manifest (name, slots, config schema, secret field names and labels, the events a destination receives, and for an OAuth plugin its scopes and whether the deployment has its OAuth app); it needs read, and Console's Integrations page builds each plugin's connection form from it. A plugin that connects through its own flow, such as Telegram's chats through Domino's bot, has no settings or secrets: its status comes from what the project connected, and connect, update and disconnect answer 409 plugin-managed. Each plugins.list entry also says which bespoke Console component, if any, replaces the generated card. A destination plugin whose manifest allows a dry run connects in the test environment without secrets or the vendor's account with connect \{ dryRun: true } (domino connections connect --dry-run): it records each request it would have sent instead (dryRunLog, domino connections log, the latest 500 kept), and authorizing the vendor's account turns the dry run off and starts the backfill again; 409 dry-run-not-supported and dry-run-test-only otherwise. A destination's activity (domino connections activity) answers its last sync, whether it is failing (10 failed deliveries in a row, or a day of failing deliveries without one success, until the vendor accepts one), members synced and skipped by reason, its latest backfill or resync with progress, and its latest 20 errors; resync (domino connections resync) sends every member's profile and attributes again, never past events, one at a time. These answer 409 not-a-destination for other plugins. Entry points: `management.connections.list`, `management.connections.get`, `management.connections.connect`, `management.connections.authorize`, `management.connections.completeAuthorization`, `management.connections.update`, `management.connections.disconnect`, `management.connections.activity`, `management.connections.resync`, `management.connections.dryRunLog`, `management.plugins.list`. CLI: `domino connections list`, `domino connections get`, `domino connections connect`, `domino connections authorize`, `domino connections update`, `domino connections disconnect`, `domino connections activity`, `domino connections resync`, `domino connections log`. [Read the guide](/docs/reference/management). ## starter-export [#starter-export] **Built in** domino starters export