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

<!-- example-source: templates/campaign/quests/welcome.quest.ts -->

```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`:

<!-- example-source: templates/campaign/ledgers.ts -->

```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/<version>/<name>.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<boolean>;

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<string, string> = 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.
