Write your first quest

Build a quest in code, publish it to test, and try it in your app.

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

For the model behind this guide, read Quests. See Compose a campaign for a complete example. Prefer a working recipe? Start from a snippet.

Publish your first quest

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.

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)],
})

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:

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:

"pointLedgers": [
  { "entry": "ledgers.ts", "exportName": "pointsLedger" }
]

Publication rejects a quest whose reward names a ledger the environment doesn't have. See Points and ledgers.

Save it where discovery finds it

Save the example as quests/welcome.quest.ts. New projects include these patterns in relay.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, the bundle imports it, and the deployment carries it once.

Preview and publish to test

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

Try it in the app

Open the app, sign in, and claim the quest. Confirm that completion survives a refresh.

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:

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:

RecipeName in the URL
Photo questphoto-quest
Quizquiz
Staff-confirmation queststaff-confirmation
X profile picturex-profile-picture
Unique photounique-photo
X Community screenshotcommunity-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.
  • Pin a version. Install a pinned registry version at /v/<version>/<name>.json.

Override or register an explicit entry

Add an entry to the quests array in your existing relay.json. Preserve the connection and other entries:

{
  "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

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.

UseWhen
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.
Named budgetsYou want to limit successful completions or awarded points across several quests.

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

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.

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.

See text and URL submissions for options and participant submission controls for the SDK flow.

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.
  • 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:

Define a tracker and a quest it triggers

Declare a custom tracker 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.

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();
  },
});

Check the code in your app's server

Keep the codes in an app secret. 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.

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.

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:

ResultQuest decision
passdecision.accept()
faildecision.reject({ reason: result.reason })
uncleardecision.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.

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

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

Return presentation from defineQuest or defineQuestType:

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: [],
})
FieldMeaning
stepsUp to 30 steps in display order. Each has a title, optional description, and optional action.
steps[].actionCurrently { kind: "link", url, label }. The URL must use HTTPS.
image, imageAltOptional image URL and alt text. Images accept HTTPS or a same-site path beginning with /.
requirementsOptional 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

On this page