Upgrade from 0.x to 1.0
Move a project from 0.x of the Relay SDK and CLI to 1.0, one breaking change at a time.
This page is the migration path from @domino-sdk/relay and @domino-sdk/relay-cli 0.x to 1.0. Every breaking change a 0.x project meets has its own section under Changes. Features that 1.0 added without breaking anything are in their own guides, and changes made during 1.x are in the changelog.
Who needs this
Projects whose package.json depends on @domino-sdk/relay or @domino-sdk/relay-cli at a 0.x version. Run domino doctor --json to read the installed versions; Versions and compatibility lists the versions this documentation revision describes.
The two packages are released together and share one version, so upgrade them together and never mix a 1.x SDK with a 0.x CLI, or the reverse.
What 1.0 is
1.0.0 was released on 2026-10-01, and the npm latest dist-tag carries 1.x, as Release channels describes. Before it, 1.0 shipped as 1.0.0-next.N prereleases, which could break between prereleases. This page compares 0.x with 1.0 directly.
Supported consumers and compatibility
Use Node.js 24 for the CLI, server and build tools, TypeScript 5.5 or later for typed consumers, and React 19 for the optional React entrypoint. The base client, browser helpers and server client do not require React.
Stable 1.x APIs follow semver: a breaking change requires a new major version. Only explicitly experimental entrypoints, names or declarations have experimental compatibility rules, and an /internal entrypoint is unsupported for application code. Keep the SDK and CLI pinned to the same exact version.
Minor releases only add, but an addition can still stop code that builds SDK types itself from compiling. A @compat tag in a type's doc comment says how the type may grow:
| Tag | Types | A minor release can |
|---|---|---|
@compat sealed | RelayClient, ManagementClient, RequestError and its subclasses | Add methods and fields. Only the SDK creates these; don't implement or subclass them. |
@compat output | Results such as Offer | Add fields. An object you build as this type, such as a test fixture, may need them. |
@compat open | The Known* unions, such as KnownRelayErrorCode | Add values. Give a switch over them a default branch. |
@compat input | Options such as RelayClientOptions | Add optional fields or accept more values. |
Upgrade
Install the latest release
Install both packages from latest at the same exact version, with your project's package manager. With npm:
npm install --save-exact @domino-sdk/relay@latest @domino-sdk/relay-cli@latestWith pnpm:
pnpm add -E @domino-sdk/relay@latest @domino-sdk/relay-cli@latestReplace any file:.domino/*.tgz entries and pnpm overrides first, as the 0.x upgrade procedure describes. Commit the regenerated lockfile.
Build and typecheck
Run these from the project directory:
npx domino build
npm run checkdomino build bundles your quests, engagement rules, reusable types, and collection slots without publishing. npm run check is the typecheck script the starters ship (tsc --noEmit); use your project's own typecheck if it differs. Treat every error as an entry in your to-do list.
Follow each section
Open each section under Changes, search for the affected code, and migrate it. Run the two commands above again after every few sections.
Verify before publishing
Run npx domino doctor --json, then npx domino deploy --preview --environment test --json to validate the project against the server without publishing. If an error matches no section here, report it with domino feedback.
Reach live through a promotion
Two server rules changed during 1.x, so a project upgrading from 0.x meets them on its first deploy to live, whatever its SDK version. Since 1.34.0, live refuses every definition write, such as creating or renaming a point ledger, from every credential. Since 1.40.0, live refuses direct code deployments: domino deploy --environment live, with or without --preview, and management.deployments.preview with a client for live. domino deploy --promote TEST_DEPLOYMENT_ID --environment live still prepares a promotion, but it is deprecated in favor of domino promote. Both answer 422 promotion-required, which the CLI reports as ENVIRONMENT_UNSUPPORTED with exit 1. Deploy to test, run domino promote, and have a person approve the promotion in Console. See test and live, promotion, and the 1.34.0 and 1.40.0 entries of the changelog.
How each change is written
Every breaking change has one section with the same five parts, so an agent can apply it without reading the rest of the page:
| Part | What it gives you |
|---|---|
| What changed | The removed or renamed API, option, command or response field, and the new behavior. |
| Old | The 0.x code, usually in a text block because it no longer compiles. |
| New | The same code for 1.0, in a ts block that the documentation build typechecks. |
| Why | The reason for the break, so you can judge edge cases the example does not cover. |
| Find it | A ripgrep command that lists every place to change. |
New examples declare the handles they use, such as declare const community: LedgerHandle; in your project, import the handles you already have.
Changes
Primitive handles
Authoring constructors take key and return typed handles, and helpers that connect authored resources accept those handles instead of raw keys or { key } objects. The id alias on constructors is removed, and budget, tier, referral milestone and referral bonus declarations use key too. The rule covers completed, questCompleted, balanceAtLeast, inGroup, points, budget ledger costs, leaderboard and referral ledger, group and quest references, engagement rules, and hosted TrackerReader methods. defineTieredReward(...).tier() accepts only a tier key the definition declares. A ledger an operator selects in Console is settings.pointLedger({ label, default? }), whose value points and ledger costs accept; without a default, an empty selection pauses the quest, so guard the rewards.
Old:
const reward = points("community", 100)
balance: settings.resource({ source: pointLedgers(), label: "Point ledger" })
export default defineLeaderboard({
id: "weekly",
title: "Weekly contributors",
balance: "community",
score: { kind: "earned", quests: ["welcome"] },
})New, in resources.ts:
import {
defineLeaderboard, defineLedger, earned, points, settings, type QuestHandle,
} from "@domino-sdk/relay/authoring"
declare const welcome: QuestHandle
export const community = defineLedger({ key: "community", name: "XP" })
export const reward = points(community, 100)
export const ledgerSetting = settings.pointLedger({ label: "Point ledger" })
export const weekly = defineLeaderboard({
key: "weekly",
title: "Weekly contributors",
ledger: community,
score: earned({ quests: [welcome] }),
})Publish the named exports in relay.json, such as "pointLedgers": [{ "entry": "resources.ts", "exportName": "community" }], and keep existing keys when moving definitions between files. Handles exist only in source: management methods and API reads keep their string IDs, so don't wrap a returned ID in { key } or cast it to a handle.
domino migrate previews the syntax rewrites and domino migrate --write applies them: id becomes key, also on nested declarations, and balance becomes ledger. It reports each string quests, qualifiedBy, ledger and prerequisite reference as HANDLE_REFERENCE_REQUIRED; replace those with imports, including in code generators.
Why: a raw string could connect a quest to the wrong kind of resource, and id confused an authoring key with the ID Relay stores. Publication still serializes the stable key.
Find it: rg -n -e 'points\(' -e 'completed\(' -e 'questCompleted\(' -e 'balanceAtLeast\(' -e 'settings\.resource' -e '\bid:' -e '\bbalance:' -e '\bqualifiedBy:' -e '\bquests:' --glob '!node_modules/**'
Prerequisites are typed eligibility rules
completed(quest) returns { kind: "completion", quest, scope: "ever" } instead of { quest }, and prerequisiteSchema no longer accepts the old shape, so republish quests built with 0.x. completed(quest, { scope: "period", period }) checks a project-calendar period, which must be named. balanceAtLeast(ledger, minimum) and inGroup(group) are the other rules. Publish any definition you add: requires refers to it but does not register it in relay.json.
Old:
const requirement = { quest: "welcome" }
if (requirement.quest === "welcome") { /* ... */ }New:
import {
balanceAtLeast, completed, inGroup, type GroupHandle, type LedgerHandle, type QuestHandle,
} from "@domino-sdk/relay/authoring"
declare const welcome: QuestHandle
declare const dailyCheckIn: QuestHandle
declare const community: LedgerHandle
declare const veterans: GroupHandle
export const requires = [
completed(welcome),
completed(dailyCheckIn, { scope: "period", period: "day" }),
balanceAtLeast(community, 100),
inGroup(veterans),
]
for (const requirement of requires) {
if (requirement.kind === "completion") console.log(requirement.quest, requirement.scope)
}Why: quest views report the exact missing condition, and Relay evaluates balances, groups and period completions without overloading one quest-reference shape.
Find it: rg -n 'requires:|prerequisiteSchema|\.requires|\.missing' --glob '!node_modules/**'
Performance evaluators no longer select rewards
A content quest's performance.evaluate returns decision.accept(), decision.review({ reason }) or decision.reject({ reason }) and nothing else. It used to accept rewards and ignore them; now passing rewards is a type error, and a result that carries them fails the evaluation with "A performance evaluator cannot select rewards". Select rewards in the quest's evaluate, which runs before performance.
Old:
evaluate: ({ metrics }) =>
metrics.likes >= 100 ? decision.accept({ rewards: bonusRewards }) : decision.reject({ reason: "Reach 100 likes." }),New:
import { decision } from "@domino-sdk/relay/authoring"
export const performance = {
metrics: ["likes"],
evaluate: ({ metrics }: { metrics: { likes: number } }) =>
metrics.likes >= 100 ? decision.accept() : decision.reject({ reason: "Reach 100 likes." }),
}Why: performance gates the rewards content evaluation already selected, so a value in its result never took effect.
Find it: rg "performance:" -A12 --glob '!node_modules' --glob '*.ts' | rg "rewards"
Staff confirmations take no evidence
manual({ actor: "staff", input }) throws when the quest is defined. Its types never allowed the combination, but JavaScript callers got a quest that members could submit evidence to. Use manual({ actor: "staff" }) for a staff confirmation, or manual({ input }) for evidence the member submits.
Old:
trigger: manual({ actor: "staff", input }),New:
import { manual } from "@domino-sdk/relay/authoring"
export const trigger = manual({ actor: "staff" })Why: staff-captured evidence is not supported, and dropping the actor silently turned a staff confirmation into a quest any member could claim.
Find it: rg -n 'actor: "staff"' --glob '!node_modules'
Evaluators see a frozen clock and seeded randomness
Hosted evaluation freezes the clock for each evaluation run: Date.now(), new Date() and Date() return ctx.now on every retry of the run, including after awaited capability calls, and performance.now() returns 0. Math.random(), crypto.getRandomValues() and crypto.randomUUID() repeat the same values within a run, so they are not secret. This applies to every evaluation Domino runs, including releases built with 0.x. See evaluation time and randomness.
Old:
import { decision, type EvaluationContext } from "@domino-sdk/relay/authoring"
export async function evaluate(ctx: EvaluationContext<{ closesAt: string }>) {
if (Date.now() > Date.parse(ctx.settings.closesAt)) return decision.reject({ reason: "Closed" })
return decision.accept()
}New:
import { decision, type EvaluationContext } from "@domino-sdk/relay/authoring"
export async function evaluate(ctx: EvaluationContext<{ closesAt: string }>) {
// ctx.eligibleAt is when the activity happened; ctx.now is when this evaluation run started.
if (ctx.eligibleAt > Date.parse(ctx.settings.closesAt)) return decision.reject({ reason: "Closed" })
return decision.accept()
}Why: a retried or replayed attempt must make the same decision and keyed() completion key.
Find it: rg -n "Date\.now\(|new Date\(\)|Math\.random\(|randomUUID\(|getRandomValues\(|performance\.now\(" --glob '!node_modules'
CLI errors have stable codes and exit statuses
Every CLI failure has a stable code. With --json, the error on stderr's last line always has error, code and outcome, and adds status, apiCode, details or operation when they apply; parse errors no longer report Commander's codes such as commander.unknownOption. Every failure used to exit 1; now:
| Exit | Outcome | When |
|---|---|---|
| 1 | failed | The command failed. |
| 2 | failed | Usage error: unknown command or option, or a missing or invalid argument. |
| 3 | uncertain | A change was sent but not confirmed, such as a lost deploy publication response. An interrupted command whose change was sent exits 3 too. |
| 130 | cancelled | Ctrl-C (SIGINT) or a declined confirmation, before any change was sent. |
| 143 | cancelled | SIGTERM, before any change was sent. |
deploy --resume and stage --request-id need the full scope as flags (--api-url, --organization, --project, --environment), which the error's operation.resume supplies; without them they fail with SCOPE_REQUIRED. domino doctor, domino check and domino migrate still exit 1 when they find a problem and now also write DOCTOR_UNHEALTHY, CHECK_FAILED or MIGRATION_BLOCKED to stderr. With --json, domino stage announces its build request as an {"event": "operation"} line on stderr instead of the prose line Build request <id>. The CLI automation contract lists every code.
Old:
domino deploy --json 2>error.json
if [ $? -eq 1 ]; then jq -r .error error.json; fiNew:
domino deploy --json 2>errors.log
status=$?
if [ "$status" -eq 3 ]; then
# The change may have landed: finish it instead of deploying again.
eval "domino $(tail -n 1 errors.log | jq -r '.operation.resume | @sh') --json"
elif [ "$status" -ne 0 ]; then
echo "domino failed with $(tail -n 1 errors.log | jq -r .code)"
fiWhy: automation could not tell a typo from a server refusal, or a failed publication from one that may have succeeded.
Find it: rg 'commander\.|domino .*(\$\? -eq 1|exitCode === 1|code === 1|status === 1)|\.(error|code) ===? "' --glob '!node_modules'
Live deploys return a pending review
A deployment record's status can be "pending": a person with publish permission approves live publication in Console, so a live deployment is answered before it is published. The deprecated domino deploy --promote and management.deployments.promote(id) return a pending record, whose ID is a promotion's, and a person can reject it ("rejected"). Automation that switches exhaustively on status needs these cases or a default branch.
Old:
switch (result.status) {
case "preview": return review(result)
case "published": return done(result)
}New:
import type { DeploymentRecord } from "@domino-sdk/relay/schema"
export function describe(record: DeploymentRecord) {
switch (record.status) {
case "published":
return "Live"
case "pending":
return "Waiting for approval in Console"
default:
return record.status
}
}Why: live changes need a person's approval, and approval happens after the deploy call returns.
Find it: rg 'status.*(published|preview)|deploy --promote|deployments\.promote' --glob '!node_modules'
Call the management API with domino api
domino GET /path and the other HTTP methods (POST, PUT, DELETE, PATCH) as the first argument are removed and fail with UNKNOWN_COMMAND and exit 2. Use domino api METHOD PATH, which takes the same body file or - for stdin.
Old:
domino POST /trackers tracker.json --jsonNew:
domino api POST /trackers tracker.json --jsonWhy: the shorthand was an undocumented alias, and 1.0 keeps one spelling per command, so domino --help lists the whole public surface.
Find it: rg 'domino (GET|POST|PUT|DELETE|PATCH) ' --glob '!node_modules'
Runtime entry exports only the client
@domino-sdk/relay exports only the participant client (createRelayClient, createRelayAction), its errors (RelayError, isRelayError, RelayErrorCode, RequestError, isRequestError, QuestUpdatedError), the helpers paginate, sessionMember, formatUnits and parseUnits, the constants AttemptStatus, QuestOutcomeState and PointEntryKind, and the types the client returns, such as QuestView, Session and LeaderboardPage. Everything else moved:
Old import from @domino-sdk/relay (or /react for avatars) | New entry point |
|---|---|
Any …Schema, identifier, caip10, xPostId, isEvmChainId, validateSubmissionValue, parseSettings, record and management types | @domino-sdk/relay/schema |
defineTracker, defineLeaderboard, defineReferral, defineEngagementReward, content, streak, settings, discord, pointLedgers, x, customSource, TrackerReader, XReader, ImageReader | @domino-sdk/relay/authoring |
avatarSrc, defaultAvatar, defaultAvatarSvg, walletAvatarSeed | @domino-sdk/relay/avatar |
buildQuest from @domino-sdk/relay/build without the CLI | Install esbuild next to the SDK |
identityIntegrationSchema, identityIntegrationInputSchema, participantExchangeSchema, questConfiguration, parseImageResult, parseImageExtraction | @domino-sdk/relay/server |
identityIntegrationSecretSchema | No longer public |
isContentParticipantRoute, isProfileParticipantRoute, isWalletParticipantRoute, isWalletSignInRoute | isParticipantRoute and isIntegrationParticipantPath from @domino-sdk/relay/server |
questActions, reconcileQuestDraft | relay.quests.open(view), whose quest-updated result carries the reconciled draft |
Old:
import { createRelayClient, avatarSrc, xTweetSchema, defineTracker } from "@domino-sdk/relay"
import { defaultAvatar } from "@domino-sdk/relay/react"New:
import { createRelayClient } from "@domino-sdk/relay"
import { avatarSrc, defaultAvatar } from "@domino-sdk/relay/avatar"
import { xTweetSchema } from "@domino-sdk/relay/schema"
import { defineTracker } from "@domino-sdk/relay/authoring"Why: an app that only calls Domino loaded every builder and schema. With one kind of code per entry point and sideEffects: false, bundlers drop what an app does not import.
Find it: rg -n "from ['\"]@domino-sdk/relay(/react)?['\"]|avatarSrc|defaultAvatar|walletAvatarSeed" --glob '!node_modules'
One paging, session and error convention in the participant client
The participant client (createRelayClient, createBrowserRelayClient) follows one convention:
- Every method takes an optional last argument,
{ session?, signal? }, which replaces positional session arguments. Session-bound calls read the current session when you omit it. - Every paged read takes
{ cursor? }and returns{ items, nextCursor }, and so do the participant routes and the management methods that share them. - Every rejection is a
RelayError, which extends the sharedRequestError: akind(http,network,timeout,aborted,invalid-response,client), an open stringcode(known ones inRelayErrorCode),statusonly when response headers arrived, anduncertainwith arecoverywhen a write may have taken effect, as Treat failed writes as uncertain describes. Test withisRelayError(error):instanceoffails across two copies of the package. auth.session()returns aParticipantSessionwhoseprincipalcan be a kind this SDK does not know; read the member withsessionMember(session).- Methods the participant API refused, duplicate methods and the single-wallet
wallet.*namespace are gone.
| 0.x | 1.0 |
|---|---|
relay.submit(input) | relay.me.claim(input) without member, or a quest controller from relay.quests.open(view) |
relay.retry(attemptId) | relay.quests.retry(view) |
relay.progress(member), relay.upload(member, file) | relay.me.progress(), relay.me.upload(file) |
relay.auth.connections() | relay.auth.accounts() |
relay.wallet.connection() | relay.wallets.list() |
relay.wallet.challenge and verify, or relay.wallets.challenge(input, session) | relay.wallets.link(signer), or relay.wallets.challenge(input, { session }) and verify(proof, { session }) |
relay.me.collectionPass(entitlement, session) | relay.me.collectionPass(entitlement, { session }) |
relay.me.linkPass(code) | Removed: verify passes on your server with POST /management/v1/passes/verify |
relay.lookupMember, snapshot, publish, publishBatch, beginHandover, confirmHandover, decide, journeyTemplate, template | Removed: use the management client from @domino-sdk/relay/server, domino deploy or Console |
relay.referrals.history(before), relay.engagement.page and boundaries, relay.content.qualifications and performance with before, returning next | { cursor }, returning nextCursor |
relay.content.admissions | relay.quests.outcomes |
management.referrals.history(member, before), management.content.*, management.engagement.page and boundaries with before or after | { cursor }, returning nextCursor (engagement.versions is unchanged) |
GET /v1/referrals/history, /v1/engagement/*, /v1/content/* with before or after | cursor, answering nextCursor; a malformed cursor answers 400 invalid-cursor |
error instanceof RelayError | isRelayError(error) |
error.status typed number; 400 or 413 from profiles.uploadAvatar, 404 from quests.get | number | undefined; kind: "client" with code invalid-input or not-found |
ZodError from invalid input or a changed response | RelayError with code invalid-input or invalid-response |
A plain Error from submissions | RelayError with code not-signed-in, stale-session, submission-conflict or submission-storage |
A QuestUpdatedError replayed from a saved review state, with status 409 | kind: "client" and no status |
A wallet signer's own error from wallets.link or auth.wallet.signIn | RelayError with kind: "client", code wallet-signer, and the wallet's error as cause |
session.principal.member | sessionMember(session) |
new RelayError(message, status, missing, code) | new RelayError(message, { kind, status, code, missing }) |
Old:
const history = await relay.referrals.history()
if (history.next) await relay.referrals.history(history.next)
await relay.me.collectionPass("entitlement-id", session.id)New:
import { createRelayClient, isRelayError, RelayErrorCode } from "@domino-sdk/relay"
const relay = createRelayClient({ baseUrl: "/relay" })
const session = await relay.auth.session()
const history = await relay.referrals.history()
if (history.nextCursor) await relay.referrals.history({ cursor: history.nextCursor })
try {
await relay.me.collectionPass("entitlement-id", { session: session?.id })
} catch (error) {
if (isRelayError(error) && error.code === RelayErrorCode.InvalidInput) console.warn(error.message)
}Why: the client paged four ways, bound sessions three ways, kept methods the server refused, and threw plain Errors, invented statuses and raw ZodErrors. One shape per concern lets apps handle every method the same way.
Find it:
rg -nP "(?<!\.me|\.quests|controller|handle|quest)\.(submit|retry|progress|upload)\(|(?<!ctx)\.wallet\.(connection|challenge|verify)\(|auth\.connections|linkPass|lookupMember|beginHandover|confirmHandover|publishBatch|journeyTemplate|\.decide\(" --glob '!node_modules'
rg -nP "\b(history|page|boundaries|admissions|qualifications|performance)\w*\.next\b(?!\()|\b(before|after): \w+\.next\b|instanceof RelayError|ZodError|principal\.member|collectionPass\(|wallets\.(challenge|verify)\(" --glob '!node_modules'Custom sources declare their record data
customSource(namespace, options) requires data, a zod object schema for each record's data (z is exported by @domino-sdk/relay/authoring). Without it, customSource throws and publication refuses the tracker; a published tracker without a schema takes no records (422 tracker-schema-required). Ingestion rejects a batch whose record data does not match with 422 invalid-record-data, naming the record, path and reason. A 0.x authors: "wallet" option is identities: ["wallet"].
The schema types the data: a tracker-triggered quest's ctx.tracker is non-null and ctx.tracker.record.data has the schema's type, trackers.records(tracker) returns typed records, and where paths and { setting } bounds are type-checked. ctx.tracker is null for triggers that do not supply it, and ctx.content is now ctx.tracker.content.body. The schema can change under the same key and applies to later records; a trigger skips a stored record that no longer matches with invalid-record-data. where can be a list of conditions, so code that reads a release's trigger.tracker.where handles an array. Declare record data lists what a schema supports.
Old:
source: customSource("token-balance", { identities: ["wallet"] }),
// In the quest:
const amount = tracker!.record.data.amount
if (typeof amount !== "string") return decision.reject({ reason: "No amount" })New:
import { automatic, customSource, defineTracker, z } from "@domino-sdk/relay/authoring"
const balances = 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 const trigger = automatic({ tracker: balances, where: { path: "decimals", equals: 18 } })Why: without a schema, filters named unchecked paths, evaluators narrowed unknown values by hand, and a server could store records no quest could read.
Find it: rg "customSource\(|authors:|tracker!|\.record\.data|trigger\.tracker\.where" --glob '!node_modules'
Quest settings come only from settings builders
settings.object(shape) and defineQuest({ settings }) accept only fields built with settings.* (integer, text, boolean, choice, datetime, resource, pointLedger, quiz). A plain zod schema used to type-check and fail when the quest was defined; it is now a type error, and defineQuest throws Setting "<key>" is not a quest setting for JavaScript callers.
Old:
settings: settings.object({ points: z.number().int().default(100) }),New:
import { settings } from "@domino-sdk/relay/authoring"
export const questSettings = settings.object({
points: settings.integer({ label: "Points", default: 100, min: 0, max: 100000 }),
})Why: Console edits a setting from the field description its builder records, so a setting without one could never be configured.
Find it: rg "settings(\.object)?\(\{" -A12 --glob '!node_modules' --glob '*.ts' | rg ": z\."
Catch ManagementError instead of CreationError or a status property
Every failed management client call rejects with a ManagementError, including transport failures that used to escape as TypeError or AbortError, and invalid input, which used to throw a ZodError. CreationError is gone: its actionId is ManagementError.operationId. error.status is undefined when no response headers arrived, so read error.kind first. Every call ends after 30 seconds unless you set another timeout, on the client or as { signal, timeout } on a call. See Management client.
Old:
import { CreationError } from "@domino-sdk/relay-cli"
} catch (error) {
if (error instanceof CreationError) retryLater(error.actionId)
else if ((error as { status?: number }).status === 409) reportConflict(error)
}New:
import { ManagementError, type ManagementClient } from "@domino-sdk/relay/server"
declare const management: ManagementClient
declare function retryLater(operationId: string | undefined): void
try {
await management.inventory.createItem({ name: "Shirt" })
} catch (error) {
if (!(error instanceof ManagementError)) throw error
if (error.recovery === "replay") retryLater(error.operationId)
else if (error.kind === "http" && error.status === 409) console.warn(error.code)
}Why: one error type lets automation tell a refusal from a lost answer, and a call without a deadline could hang a Worker or script indefinitely.
Find it: rg "CreationError|\.status ===|\"status\" in" --glob '!node_modules'
Member methods moved under management.members
The management client's member methods are one namespace, next to the exact lookup members.lookup({ provider, subject }). management.member(id) is management.members.get(id) and management.people(ids) is management.members.people(ids). management.unlinkMemberWallet(id, input) and management.setMemberWalletSignIn(id, input) are management.members.identityStates.unlink(id, input) and identityStates.setSignIn(id, input), which take the identity as { kind: "wallet", chain, address }, the expectedRevision from identityStates.list(id), and a reason.
Old:
const detail = await management.member(memberId)
await management.unlinkMemberWallet(memberId, { chain, address, reason })New:
import type { ManagementClient } from "@domino-sdk/relay/server"
declare const management: ManagementClient
const person = await management.members.lookup({ provider: "discord", subject: "80351110224678912" })
const detail = await management.members.get(person.id)
const states = await management.members.identityStates.list(person.id)
const wallet = states.items.find((state) => state.kind === "wallet" && state.canUnlink)
if (wallet?.kind === "wallet") {
await management.members.identityStates.unlink(person.id, {
identity: { kind: "wallet", chain: wallet.chain, address: wallet.address },
expectedRevision: wallet.revision,
reason: "Reported as compromised",
})
}
console.log(detail.wallets.length)Why: member reads and actions were spread across the client's top level under four names.
Find it: rg "management\.(member|people|unlinkMemberWallet|setMemberWalletSignIn)\(|members\.(unlinkWallet|setWalletSignIn)\(" --glob '!node_modules'
Import the management client from @domino-sdk/relay/server
@domino-sdk/relay-cli is only the domino executable and its agent skills: it has nothing to import. The management client and everything with it (createManagementClient, hostedManagementClient, ManagementError, the management schemas and types, and the starter list, now starters) moved to @domino-sdk/relay/server. Server code, including a hosted app's Worker, needs only @domino-sdk/relay; keep @domino-sdk/relay-cli as a development dependency. serverBoundary() from @domino-sdk/relay/vite fails a browser build that imports @domino-sdk/relay/server.
Old:
import { hostedManagementClient } from "@domino-sdk/relay-cli"New:
import { hostedManagementClient } from "@domino-sdk/relay/server"
export const management = (env: Record<string, string | undefined>) => hostedManagementClient(env)Why: server integrations installed the CLI, with its parser and TypeScript compiler, to reach a small fetch client.
Find it: rg "from ['\"]@domino-sdk/relay-cli['\"]|import\(['\"]@domino-sdk/relay-cli['\"]\)" --glob '!node_modules'
Set the timezone once, on the project calendar
streak() and calendar leaderboard windows no longer take a timeZone. Each project environment has one project calendar, UTC until you choose a timezone, and streak days, budgets and calendar leaderboards count its days, weeks and months. streak({ days, timeZone }) throws, and a definition that still carries a timeZone fails validation. Fixed offsets such as +02:00, Etc/GMT-2 and EST are rejected, and other spellings of a zone resolve to its canonical name.
Without a code change, a "day" or "week" budget now resets at midnight in the project timezone instead of UTC, and published leaderboards with their own timeZone follow the project calendar. A project whose streak quests named a timezone before 1.0 keeps it as its project calendar; otherwise the calendar is UTC, so set the project timezone to keep a board on its old zone. Budgets also accept period: "month".
Old:
completion: streak({ days: 7, timeZone: "Europe/Budapest" }),
window: { kind: "calendar", period: "week", timeZone: "Europe/Budapest" },New: set "calendar": { "timeZone": "Europe/Budapest" } in relay.json, or in Console Settings → General → Project calendar, and drop the zone from code. It takes effect at the next midnight in the current timezone.
import { streak } from "@domino-sdk/relay/authoring"
export const completion = streak({ days: 7 })
export const weekWindow = { kind: "calendar", period: "week" } as constWhy: a weekly budget and a weekly leaderboard in one project could cover different weeks. One calendar makes every day, week and month in a project mean the same thing.
Find it: rg -n "timeZone|period: \"(day|week)\"" --glob '!node_modules' --glob '*.ts' --glob '*.tsx'
Engagement budgets are a list of periods
defineEngagementReward takes budgets, a list of { scope, period, limit }, instead of budget: { projectDaily, memberDaily, projectLifetime }. scope is "project" or "member", and period is "day", "week", "month" or "lifetime" of the project calendar at settlement time. A definition with budget fails validation; published rules keep their limits. Engagement accounts settle once per project calendar day instead of once per UTC day.
Old:
budget: { projectDaily: 10000, memberDaily: 500, projectLifetime: 100000 },New:
export const budgets = [
{ scope: "project", period: "day", limit: 10000 },
{ scope: "member", period: "day", limit: 500 },
{ scope: "project", period: "lifetime", limit: 100000 },
] as constWhy: engagement budgets use the period vocabulary of quest budgets and leaderboards, so a rule can cap weekly or monthly payouts.
Find it: rg -n "projectDaily|memberDaily|projectLifetime" --glob '!node_modules'
React hooks share one cache and return one state shape
Every read hook in @domino-sdk/relay/react returns { status, data, error, refresh }. status is loading, ready or error, and signed-out for hooks that need a member; error is a RelayError, which while ready says why the latest refresh failed. <RelayProvider> keeps one query cache: components that read the same thing share a request, changes through the hooks refresh what they affect, polling pauses while the tab is hidden, and the cache resets when the participant, project, environment or client changes. questStatus returns a code instead of English.
| Old | New |
|---|---|
const { experience, member, deliveries, refresh } = useExperience() | const experience = useExperience(), with deliveries and refresh on it; useSession().member for the member |
experience.quests, experience.message, experience.stale | experience.data.quests, experience.error.message, experience.error while ready |
Experience, Extract<Experience, { status: "ready" }> | ExperienceData ({ quests, collections }); questGroups(experience.data, order) |
useMe(), useSignInMethods() returning a value or null | .data when status is ready |
useAccounts().accounts, useWallets().wallets, useMyProfile().profile, useReferralSummary().summary | .data; a failed account, wallet or profile action lands in actionError, and account and wallet actions resolve true or false |
useLeaderboard(id).page, useProfile(id).profile, .value of placement and referral hooks | .data |
status: "missing" from the profile hooks | status: "error" with error.status === 404 |
useProfileCompletions(id) and usePointHistory(ledger): items, more, loading, an error while ready | data.items, data.nextCursor !== null, loadingMore, loadMoreError; history errors use codes point-ledger-not-found or not-joined |
usePointLedgers().items, useTieredRewards().items, useBalances().items | .data (.data.items for balances) |
PointLedgersState, LeaderboardState, ProfileState and the other *State types | Query<T>, MemberQuery<T> (BalancesState is MemberQuery<PointBalances>), PagedQuery<T>, MemberPagedQuery<T> |
PROFILE_CHANGED window event | Removed; after a change made with useRelay(), call useInvalidate() |
questStatus(view).label, .detail | questStatusText(view); questStatus(view) returns { tone, code, reason? } |
roleDeliveryLabel(deliveries, quest) | roleDeliveryLabel(roleDeliveryStatus(deliveries, quest)) |
An exhaustive switch over questStatus(view).code or roleDeliveryStatus(…) | Add a default: both are open strings |
completionCountLabel(view) | completionCountText(view) |
Changing useExperience().deliveries in place | Copy it first: it is a readonly RoleDelivery[] |
An effect that calls useReferralSummary().refresh() on a timer | Remove it: the summary refreshes every 60 seconds while visible |
useQuest(view).error, useSignIn().error, useSignInOptions().error and .methodsError, SignInMethods.error as strings; invitation.reason | RelayError | null, and invitation.error: render error.message |
completionMark, hasPending, sanitizeDeliveries, watchRoleDeliveries, PENDING_POLL_MS, RETRY_DELAYS_MS, runsSilently, visibleQuests | Not exported. useExperience() leaves hidden quests out; runsSilently(view) was view.trigger.kind === "automatic" && (view.completion.kind === "keyed" || Boolean(view.trigger.tracker)); check delivery.status === "pending" for hasPending |
A useState and useEffect loop over useRelay() | useRelayQuery(key, read) or useMemberQuery(key, read); useRelayPages or useMemberPages for paged reads |
Old:
const { experience, member } = useExperience()
if (experience.status === "error") return <p>{experience.message}</p>New:
import { questStatusText, useExperience, useSession } from "@domino-sdk/relay/react"
export function Summary() {
const experience = useExperience()
const { member } = useSession()
if (experience.status === "error") return <p>{experience.error.message}</p>
if (experience.status !== "ready") return null
const quest = experience.data.quests[0]
return <p>{member} {quest ? questStatusText(quest).label : null}</p>
}Why: each hook ran its own fetch loop, so two components fetched twice, a claim left other reads stale until their next poll, and a failing experience poll blanked unrelated hooks. Status codes let apps translate quest states.
Find it: rg -n "useExperience\(\)|useMe\(\)|useSignInMethods\(\)|\.reason\b|completionMark|hasPending|runsSilently|visibleQuests|watchRoleDeliveries|\.(accounts|wallets|summary|page|profile|items|value|stale|more)\b|status === \"missing\"|PROFILE_CHANGED|questStatus\(|roleDeliveryLabel\(|completionCountLabel|State\b" --glob '!node_modules' --glob '*.{ts,tsx}'
Observation triggers become tracker triggers
automatic({ source, type }), ctx.observation, management.observations.submit, POST /management/v1/observations, the Observation type, observationSchema, observationInputSchema, observationReceiptSchema, observationTriggerSchema, discordReactionSchema, and the observation field of an automatic trigger (QuestView.trigger.observation) and of the automatic availability are removed. A server that reported observations sends records to a custom tracker with management.trackers.ingest, and the quest triggers with automatic({ tracker }). Quests published with an observation trigger admit nothing; republish each on a tracker, under the same key if you like.
| Observation | Tracker record |
|---|---|
id | id |
occurredAt | occurredAt and observedAt |
data | data, matching the source's data schema |
type | A field in data that the quest's where selects, or a separate tracker |
source: "backend:…" | The custom source's namespace |
member | The record's author or a named account. Prefer the wallet, x or discord account the activity came from; use a member account when your server vouches for the member. |
Ingesting a record never joins the member: a trigger skips a member who has not joined with not-joined. Every selected record gets an outcome, an admission or a skip with a stable code; see Explain trigger outcomes.
Discord activity comes from two managed tracker sources, discord.messages() and discord.reactions(), with typed record data. Relay stores a Discord record only when one of its accounts is linked to a member; see Reward Discord messages and reactions.
| Old | New |
|---|---|
automatic({ source: "discord", type: "message.create" }) | automatic({ tracker }) on a discord.messages() tracker |
type: "reaction.add" | automatic({ tracker }) on a discord.reactions() tracker |
type: "reaction.received" | The reactions tracker with beneficiary: "messageAuthor"; reactions to one's own message are skipped with beneficiary-is-author |
discordReactionSchema.parse(ctx.observation.data) | ctx.tracker.record.data |
A reaction's userId, messageAuthorId, actorRoles | ctx.tracker.record.author.id, ctx.tracker.record.accounts.messageAuthor.id, data.reactorRoles |
A message's userId, content | ctx.tracker.record.author.id, and data.text on a discord.messages({ text: true }) tracker |
quest.trigger.observation?.source === "discord" | quest.trigger.tracker?.source is "discord-messages" or "discord-reactions" |
questStatus from @domino-sdk/relay/react gives a tracker-triggered quest the code automatic ("Automatic") unless a counter, streak or cooldown code applies first.
Old:
trigger: automatic({ source: "backend:orders", type: "paid" }),
async evaluate({ observation }) { return Number(observation!.data.total) >= 1000 ? decision.accept() : decision.reject({ reason: "…" }) },
await management.observations.submit({ id: "order-1842", source: "backend:orders", type: "paid", member: "member_existing", occurredAt: paidAt, data: { total: 4200 } })New:
import { automatic, customSource, defineTracker, z } from "@domino-sdk/relay/authoring"
import type { ManagementClient } from "@domino-sdk/relay/server"
declare const management: ManagementClient
export const orders = defineTracker({
key: "orders",
title: "Orders",
source: customSource("orders", {
identities: ["member"],
data: z.object({ status: z.enum(["paid", "refunded"]), total: z.number().int() }),
}),
window: { start: Date.parse("2026-10-01T00:00:00Z") },
})
// The old observation type is a data field the filter selects.
export const trigger = automatic({ tracker: orders, where: { path: "status", equals: "paid" } })
// Your server sends the record with a key that has `ingest` permission.
const paidAt = Date.now()
await management.trackers.ingest("orders", {
actionId: "orders-order-1842-paid",
records: [{
id: "order-1842", author: { id: "member_existing" }, occurredAt: paidAt, observedAt: paidAt,
data: { status: "paid", total: 4200 },
}],
})Why: observations were a second automatic pipeline that matched only source and type, dropped everything else silently, and joined the member the caller named. Tracker triggers already had filters, beneficiary roles, attribution through verified links and a recorded outcome for every decision.
Find it: rg 'automatic\(\{\s*source|\.observation\b|\bobservation\b[?!]?\.|observations\.submit|/observations|\bObservation\b|observation(Input|Receipt|Trigger)?Schema|discordReactionSchema|reaction\.(add|received)|message\.create' --glob '!node_modules'
The observe permission is replaced by enroll
The observe permission is removed. Sending tracker records (management.trackers.ingest) needs ingest, and enrolling a member from a server (management.quests.enroll) needs the new enroll. Editors hold both; a hosted-app key holds read, points and ingest and cannot enroll; a personal API key issued with observe keeps only its other permissions. Reissue such a key in Console Settings → API keys with Ingest, Enroll or both, and replace it wherever it is stored.
Old:
// A personal API key issued with only "observe".
await management.quests.enroll({ actionId, quest: "launch-post", member: "member_existing" })New:
import type { ManagementClient } from "@domino-sdk/relay/server"
// A client whose key was issued with Enroll.
declare const management: ManagementClient
await management.quests.enroll({ actionId: "enroll-launch-post-member-existing", quest: "launch-post", member: "member_existing" })Why: enrolling vouches for a member's consent, which is more than reporting activity, so keys that only send records cannot enroll.
Find it: rg '"observe"|\bobserve\b' --glob '!node_modules'
Readers return null when an account is not linked or a subject is missing
Every evaluator reader answers a missing subject with null instead of throwing, an empty list or a zero balance. ctx.x.profile(), ctx.x.follows() and ctx.x.reposted() return null without a linked X account instead of throwing. ctx.x.post() returns null for a missing post and needs no linked account. ctx.discord.member() returns null without a linked Discord account. ctx.wallet.list() returns null instead of [], and ctx.wallet.balance() returns null instead of { total: "0", wallets: [] } when the member has no wallet of that chain's family. ctx.trackers.content() returns null for a missing record or observation. See evaluator readers.
Old:
const held = await ctx.wallet.balance({ chain: "eip155:8453" })
if (held.wallets.length === 0) return decision.reject({ reason: "Link a wallet on this chain first." })New:
import { decision, type EvaluationContext } from "@domino-sdk/relay/authoring"
// In a quest that declares `integrations: [x]`.
export async function evaluate(ctx: EvaluationContext<{}>) {
const held = await ctx.wallet.balance({ chain: "eip155:8453" })
if (!held) return decision.reject({ reason: "Link a wallet on this chain first." })
// null: no linked X account. false: linked, and not following.
const follows = await ctx.x.follows({ userId: "2244994945" })
if (follows === null) return decision.reject({ reason: "Connect X first." })
return follows && BigInt(held.total) > 0n ? decision.accept() : decision.reject({ reason: "Follow us and hold the token." })
}Why: each reader reported "missing" its own way, and a zero total looked like an answer. With one rule the types say which reads can be missing.
Find it: rg -n "ctx\.(x|wallet|discord|trackers)\.|\.wallets\.length|total === \"0\"" --glob '!node_modules'
Capability failures throw CapabilityError
A capability that cannot answer throws CapabilityError from @domino-sdk/relay/authoring, with a code ("not-linked", "provider-unavailable" or "author-error") and retryable, instead of a plain error. Invalid input, such as an unknown chain, an undeclared Discord integration or an address the member never linked, is author-error; an image check of { x: "profile" } without a linked X account is not-linked. A retryable failure that escapes the evaluation is released, along with every fact captured after it, and the retry asks again. One that quest code catches and moves past is final for the attempt and replays, unless you rethrow it or wrap it with { cause: error }; throw new Error(error.message) loses the link. Any other failure is answered again on every run, and an unknown code from a newer Relay is never retryable.
Old:
} catch (error) {
if (error instanceof Error && error.message.includes("Connect your X account")) return decision.reject({ reason: "Connect X first." })
throw error
}New:
import { CapabilityError, decision, type EvaluationContext } from "@domino-sdk/relay/authoring"
// In a quest that declares `integrations: [x]`.
export async function evaluate(ctx: EvaluationContext<{}>) {
try {
const match = await ctx.images.compare({ x: "profile" }, { asset: "frame" })
return match.phashDistance <= 8 ? decision.accept() : decision.reject({ reason: "Use the campaign picture." })
} catch (error) {
if (error instanceof CapabilityError && error.code === "not-linked") return decision.reject({ reason: "Connect X first." })
throw error
}
}Why: quest code could not tell "link an account" from "the provider is down" from "this quest has a bug" without matching messages, which change.
Find it: rg -n "catch \(|message\.(includes|match|startsWith)|Connect your X account" --glob '!node_modules'
Tracker reads are point in time and pinned per attempt
ctx.trackers.records() and ctx.trackers.summary() take asOf, defaulting to ctx.eligibleAt: only records observed at or before it count, each as its latest such observation. Each evaluation run pins a watermark at its first tracker read, so every tracker read of the run and its retries sees what Relay had stored then. A tracker-triggered quest counts records up to its triggering record's occurredAt, and a manual quest up to its submission. Pass asOf: ctx.now to count everything stored by the watermark.
Old:
// Counted every record collected so far, whenever the evaluation ran.
const posts = await ctx.trackers.records("launch-posts", { member: ctx.member.id })New:
import { decision, type EvaluationContext, type Tracker } from "@domino-sdk/relay/authoring"
declare const launchPosts: Tracker
export async function evaluate(ctx: EvaluationContext<{}>) {
const posts = await ctx.trackers.records(launchPosts, { member: ctx.member.id, asOf: ctx.now })
return posts.total >= 3 ? decision.accept() : decision.reject({ reason: "Post 3 times." })
}Why: a decision must not depend on when an evaluation happened to run, so every read and retry of an attempt gets the same answer.
Find it: rg -n "trackers\.(records|summary)\(" --glob '!node_modules'
Linked X and Discord accounts stay pinned for the attempt
ctx.x and ctx.discord pin the member's account at the evaluation run's first read of each and never recheck it, as ctx.wallet already did; "not linked" is pinned too. Reads no longer fail with "X account changed. Start a new submission." or "Account or integration changed. Start a new verification.". An account linked after the first read is used by a new submission or a scheduled deferred run. Image checks of { x: "profile" } and ctx.trackers.content() attribution use the same pinned X account.
Old:
await ctx.x.profile() // Error: X account changed. Start a new submission.New:
import { decision, type EvaluationContext } from "@domino-sdk/relay/authoring"
// In a quest that declares `integrations: [x]`.
export async function evaluate(ctx: EvaluationContext<{ phrase: string }>) {
// The account pinned at this attempt's first X read; null if none was linked then.
const profile = await ctx.x.profile()
if (!profile) return decision.reject({ reason: "Connect X, then start a new submission." })
return (profile.bio ?? "").includes(ctx.settings.phrase) ? decision.accept() : decision.reject({ reason: "Add the phrase to your bio." })
}Why: each reader pinned differently, so an attempt could fail or not depending on which reader ran.
Find it: rg -n "X account changed|Account or integration changed|start a new (submission|verification)" --glob '!node_modules'
Entitlements can be void, and reads tolerate statuses they do not know
An Entitlement's status can be "void", with voidedAt and no handover fields, after an operator voids a tiered reward granted by mistake. It cannot be collected; a later grant of the same reward makes it available again, while a voided referral reward stays void. A 0.x SDK rejects the whole progress read (relay.me.progress(), useExperience()) and the collection pass preview once a member has a voided entitlement, so upgrade first.
Reads parse entitlements with a tolerant entitlementSchema: objects keep fields a newer server adds, inventory.kind and fulfillment.kind are plain strings, and an unknown status reads as "unsupported" with the server's value in reportedStatus; show it but offer no action. The strict stored shape is entitlementStateSchema, and the management client's entitlementRecordSchema is gone.
Old:
const startedAt = entitlement.status !== "available" ? entitlement.startedAt : nullNew:
import type { Entitlement } from "@domino-sdk/relay/schema"
export const startedAt = (entitlement: Entitlement) =>
entitlement.status === "handing-over" || entitlement.status === "collected" ? entitlement.startedAt : nullWhy: operators need a way out of a reward granted by mistake, and tolerant reads let a later status reach old apps without breaking them.
Find it: rg 'status !== "available"|status != "available"|case "collected"|entitlementRecordSchema' --glob '!node_modules'
Counter progress
Counter completion policies add kind: "progress" and the attempt status progressed. A qualifying action below the goal has no completion and pays no reward; read QuestView.counter for the count and achievement. Don't infer an awarded completion from a successful submission. Move app-side counting into a quest with a new key, since a counter cannot replace a published quest's completion policy. See counter rules.
Old:
if (acceptedActions.length >= 3) await awardReward()New:
import { calendar, count, progress } from "@domino-sdk/relay/authoring"
export const completion = progress({
distinct: ({ calendar }) => calendar.day().key,
goal: count(3),
window: calendar("week"),
})Why: counting and rewarding in separate app requests could double-award on retries; the server deduplicates evidence and commits the achievement with its rewards.
Find it: rg -n -e 'acceptedActions' -e 'completion.kind' -e 'attempt.status' -e 'case "accepted"' --glob '*.{ts,tsx,js,mjs}'
Handle deferred evaluator decisions and progress
Quest evaluate can return decision.defer(...) and decision.progress(...). A waiting attempt has status deferred, an open code and nextEvaluationAt, and QuestView.progress holds its committed progress; waiting grants nothing. Attempt statuses are open strings: use the AttemptStatus constants and a fallback, also for QuestView.attempt. A new submission while deferred answers 409 attempt-pending. An explicit completionKey on decision.accept or decision.review takes precedence over a keyed() callback. See the bounds and expiry codes.
Old:
if (!ready) throw new Error("Not ready yet")New:
import { decision } from "@domino-sdk/relay/authoring"
export const check = (ready: boolean) =>
ready ? decision.accept() : decision.defer({ reason: "Not ready yet", retryAfterMs: 60_000 })Why: a throw retries the same evaluation run with its captured clock and reads. A defer starts a fresh run later, with fresh captures, while the trigger evidence and ctx.eligibleAt stay pinned.
Find it: rg -n 'throw new Error|switch.*(kind|status)|completionKey' --glob '*.{ts,tsx,js,mjs}'
Enroll in quests with relay.quests.enroll
relay.content.enroll, management.content.enroll and their content/enrollments routes are removed. Members enroll with relay.quests.enroll({ actionId, quest }), which takes no member. A server with enroll permission calls management.quests.enroll({ actionId, quest, member }). A quest whose trigger does not require enrollment answers 409 enrollment-not-required. Earlier enrollments keep their first time.
Old:
await relay.content.enroll({ actionId, quest: "launch-post", member: memberId })New:
import { createRelayClient } from "@domino-sdk/relay"
const relay = createRelayClient({ baseUrl: "/relay" })
await relay.quests.enroll({ actionId: crypto.randomUUID(), quest: "launch-post" })Why: any tracker trigger can require enrollment, so enrollment is a quest record, and a member can only enroll themselves.
Find it: rg -n "content\.enroll|content/enrollments|contentEnrollment" --glob '!node_modules'
Tracker triggers hold activity from before an account was linked
Tracker triggers apply the admission rules every automatic quest shares (Historical activity):
- Historical activity waits. A record from before the member linked the account it pays, or with no known link time, is skipped with
historicalinstead of admitted. Sethistorical: "automatic"to admit it as before, or"claim"to let the member claim it withrelay.quests.claim().memberaccounts are never historical. - Skips are decided again when the event that clears them happens: the member links the account, a conflicting link is removed, or the member joins or enrolls.
- Every selected observation has an outcome, including
already-completedfor a completedonce()quest or a streak whose milestone paid, whichingestcounts inadmissions.skipped. - Prerequisites are judged at the record's time, so a record from before the member completed a prerequisite is skipped with
prerequisites-incomplete. - Skip codes are an open set:
TrackerSkipCodeis a known code or any string; exhaustive checks useKnownTrackerSkipCode. management.trackers.retryOutcomerejects with the skip code incode, and a completed quest withalready-completedinstead of "Already completed".
Old:
trigger: automatic({ tracker: tokenBalance, on: "data-changed" }),New:
import { automatic, type Tracker } from "@domino-sdk/relay/authoring"
declare const tokenBalance: Tracker
// Admit activity from before the member linked the wallet, as 0.x did.
export const trigger = automatic({ tracker: tokenBalance, on: "data-changed", historical: "automatic" })Why: an account's earlier activity was paid to whoever linked it later. Holding it fails closed, and each quest says who may release it.
Find it: rg -n "automatic\(\{|retryOutcome|Already completed|TrackerSkipCode|\.skip\b" --glob '!node_modules'
Identity kinds are open, and evaluators read ctx.member.identities()
IdentityKind adds your app's own kinds, `app:${string}` such as app:game, beside wallet, x, discord and member; see app login. Responses and evaluator contexts read kinds as an open set:
identityKindSchemahas no.optionsor.enum, and aRecord<IdentityKind, …>or exhaustiveswitchmisses app kinds.- Kinds in responses and contexts are
OpenIdentityKind,IdentityKind | (string & {}): a record'sauthor.kindand accountkind, eachctx.member.identities()entry, and a published tracker'ssource.identities. ctx.tracker.attribution.accountcan be{ provider: "app:<name>", subject, revision, linkedAt }, or{ provider: string }for a later kind, and the trigger context schemas ignore unknown fields.- Published tracker definitions read as
trackerDefinitionResponseSchema(TrackerDefinitionResponse); keeptrackerDefinitionSchemafor definitions you publish.trackerRecordSchemais a separate tolerant response schema; validate what you send withtrackerRecordInputSchema. ctx.memberis anEvaluationMember,{ id, identities() }, which can gain methods in a minor release. Build test contexts withcreateTestContextorrunQuestfrom@domino-sdk/relay/testing, or the member alone withfakeEvaluationMember.
Old:
const labels: Record<IdentityKind, string> = { wallet: "Wallet", x: "X", discord: "Discord", member: "Member" }
const readDefinition = (body: unknown) => trackerDefinitionSchema.parse(body)
const member = { id: "member_1" }New:
import { trackerDefinitionResponseSchema, type KnownIdentityKind, type OpenIdentityKind } from "@domino-sdk/relay/schema"
import { fakeEvaluationMember } from "@domino-sdk/relay/testing"
const labels: Record<KnownIdentityKind, string> = { wallet: "Wallet", x: "X", discord: "Discord", member: "Member" }
export const label = (kind: OpenIdentityKind) => (Object.hasOwn(labels, kind) ? labels[kind as KnownIdentityKind] : kind)
export const readDefinition = (body: unknown) => trackerDefinitionResponseSchema.parse(body)
export const member = fakeEvaluationMember({ id: "member_1" })Why: apps that know users by their own IDs had to resolve member IDs themselves. Open kinds let a later version add kinds without breaking an SDK you shipped, so keep a default branch wherever you branch on a kind.
Find it: rg -n "IdentityKind|identityKindSchema|TrackerDefinition|trackerDefinitionSchema|trackerRecordSchema|attribution\.account|account\.provider|member: \{ id" --glob '!node_modules'
Reward delivery actions are open strings
A reward delivery's action is an open string: "grant-role" for a Discord role reward, "post-message" for a quest announcement, or an action a newer Relay adds (the known values are RewardDeliveryAction). Show a delivery with another action by its status, and never treat it as a grant. When an operator revokes a completion, its grants get revokedAt; a pending grant becomes failed. Use revokedAt rather than lastError text to tell role reconciliation from a retry. A non-null dispatchedAt means the role may have been granted even if the delivery failed. A delivered grant of a revoked completion is history; its reconciliation says whether the member holds the role now.
Old:
const granted = deliveries.filter((d) => d.status === "delivered")New:
import { RewardDeliveryAction } from "@domino-sdk/relay/schema"
export const describe = (d: { action: RewardDeliveryAction; status: string }) =>
d.action === RewardDeliveryAction.GrantRole ? `Role ${d.status}` : `Delivery ${d.status}`Why: a fixed "grant-role" literal would make every new delivery action a breaking change.
Find it: rg 'rewardDeliveries|"grant-role"|deliveredAt' --glob '!node_modules'
Bounded requests and avatar recovery
Participant requests default to a 15-second deadline, and management and CLI requests to 30 seconds, covering every attempt, the waits between them and the response body. Calls that are safe to repeat retry transient failures, up to three attempts. Wallet signer waits stay outside the deadline, but the caller's signal cancels them. Choose recovery from error.recovery, not from the presence of operationId: avatar uploads have an upload ID but no server receipt, so they report "reconcile".
Old:
if (isRelayError(error) && error.uncertain && error.operationId) await client.profiles.uploadAvatar(photo, { uploadId: error.operationId })New:
import { createRelayClient, isRelayError } from "@domino-sdk/relay"
const client = createRelayClient({ baseUrl: "/relay" })
declare const error: unknown
// Read the current profile before deciding whether to upload again.
if (isRelayError(error) && error.uncertain && error.recovery === "reconcile") await client.profiles.mine()Why: a call without a deadline could hang indefinitely, and replaying an older avatar upload could replace a newer avatar.
Find it: rg -n 'operationId|uploadAvatar|recovery|wallets\.link' --glob '!node_modules'
Generic management writes require reconciliation
Generic management request and raw writes report recovery: "reconcile" when their outcome is uncertain, even with a caller-supplied actionId, which used to make them recommend "replay". Typed methods whose routes keep receipts still report "replay". An uncertain domino api write reports details.recovery: "reconcile".
Old:
if (isManagementError(error) && error.uncertain && error.operationId) await management.request("/custom", schema, { actionId: error.operationId })New:
import { isManagementError } from "@domino-sdk/relay/server"
declare const error: unknown
declare function repeatTypedCall(operationId: string | undefined): Promise<void>
declare function readResourceFirst(): Promise<void>
if (isManagementError(error) && error.uncertain) {
if (error.recovery === "replay") await repeatTypedCall(error.operationId)
else await readResourceFirst() // A generic write: read before writing again.
}Why: an action ID does not prove that an arbitrary endpoint keeps a receipt, and repeating a write without one can apply it twice.
Find it: rg -n 'operationId|management\.(request|raw)|domino api (POST|PUT|PATCH|DELETE)' --glob '!node_modules'
Treat failed writes as uncertain when Relay cannot prove refusal
Relay returns 503 only when the request made no committed write and no external effect. Unexpected failures are 500, and lost or unusable upstream answers are 502, or 504 on timeout. Evidence uploads, avatar uploads, email delivery, key cleanup and hosted activation can return 502 or 504 where 0.x servers returned 503. On 429 or 503, respect Retry-After. For direct HTTP email sign-in, an unconfirmed delivery answers 502 or 504 with a challenge and the binding cookie: if the code arrives, verify it with that challenge in the same browser. The SDK does not resend an unconfirmed start.
Old:
if ([500, 502, 503, 504].includes(error.status)) await submit({ actionId: crypto.randomUUID() })New:
import { isRelayError } from "@domino-sdk/relay"
declare const error: unknown
declare function submit(input: { actionId: string }): Promise<void>
declare function reconcile(): Promise<void>
if (isRelayError(error) && error.uncertain) {
if (error.recovery === "replay" && error.operationId) await submit({ actionId: error.operationId })
else await reconcile()
}Why: a lost answer can follow a successful write, and a new action ID can duplicate it.
Find it: rg -n '503|502|504|uncertain|emailStart|auth/email/start' --glob '!node_modules'
Content stages
Content quests use the shared tracker trigger: content({ tracker }) takes the tracker and its start policy in one object, the content item supplies the completion, and the captured body is ctx.tracker.content.body. Republish every content quest with the 1.0 SDK.
| 0.x | 1.0 |
|---|---|
content(posts, policy) | content({ tracker: posts, ...policy }) |
completion: perContent() | Omit completion: a content quest completes once per item |
posts: "since-start" | since: "window" |
posts: "after-enrollment" with required enrollment | since: "enrollment", enrollment: "required" |
| No start policy | since: "activation", the default |
ctx.content, ctx.tracker.x.content, a performance evaluator's content | ctx.tracker.content.body, with revision and observation beside it |
relay.content.admissions | relay.quests.outcomes({ quest, ...filters }) |
relay.content.claim({ member, content, ... }) | relay.quests.claim({ actionId, quest, tracker, record, observedAt }) on a claimable outcome |
management.content.admissions | management.trackers.triggerOutcomes(tracker, { quest, record, ...filters }) |
management.content.approve | management.trackers.retryOutcome(tracker, { actionId, quest, record, observedAt }), with review permission |
For custom content, declare content: { body: z.object({ ... }), metrics: [...] } in customSource instead of data, and ingest data: { body, metrics? }; see custom content. Admission pins the quest's release and beneficiary, and budgets use the record's occurrence time.
Old:
trigger: content(posts, { posts: "after-enrollment", enrollment: "required" }),
completion: perContent(),
async evaluate({ content }) { return content!.text.includes("#launch") ? decision.accept() : decision.reject({ reason: "Include #launch." }) },New:
import { content, decision, defineQuest, defineTracker, settings, x } from "@domino-sdk/relay/authoring"
const posts = defineTracker({
key: "launch-posts", title: "Launch posts", source: x.search({ query: "#launch" }),
window: { start: Date.parse("2026-10-01T00:00:00Z") },
})
export default defineQuest({
key: "launch-post",
title: "Post about the launch",
settings: settings.object({}),
trigger: content({ tracker: posts, since: "enrollment", enrollment: "required" }),
rewards: [],
evaluate: async ({ tracker }) =>
tracker.content.body.text.includes("#launch") ? decision.accept() : decision.reject({ reason: "Include #launch." }),
})Why: content quests ran their own admission pipeline, for X only. On the shared trigger they get the enrollment, historical-activity and outcome rules of every tracker quest, and custom sources get content stages too.
Find it: rg -n 'content\(|perContent|ctx\.content|tracker\.x\.content|content\.(admissions|claim|approve|enroll)' --glob '!node_modules'
Evaluator protocol
Every published quest bundle declares evaluator protocol 2, and Relay refuses a bundle with no protocol, or one it does not support, before evaluating it. defineQuest() declares it on the bundle (protocol: 2) and its description (evaluatorProtocol: 2), and Relay checks they agree. 0.x bundles declare none, so rebuild and republish every quest with the 1.0 SDK; changing a version field is not enough. A tool that writes bundles itself drops runtimeVersion and writes evaluatorProtocol from @domino-sdk/relay/schema in both places.
Protocol 2 adds ctx.calendar.day(at?): the project-calendar day of ctx.eligibleAt, or of an epoch-millisecond instant, as { key, date, timeZone, start, end }, where key includes the zone (YYYY-MM-DD@Zone). It is pinned per evaluation run. Use ctx.calendar.day().key instead of a UTC date for daily completion keys. A test that calls evaluate() with a hand-built context needs a calendar, which createTestContext and runQuest from @domino-sdk/relay/testing supply.
Old:
export default { runtimeVersion: 2, describe, evaluate }
completion: keyed(({ eligibleAt }) => new Date(eligibleAt).toISOString().slice(0, 10)),New:
import { keyed } from "@domino-sdk/relay/authoring"
import { evaluatorProtocol } from "@domino-sdk/relay/schema"
// The activity's project-calendar day, such as 2026-10-15@Europe/Budapest.
export const completion = keyed(({ calendar }) => calendar.day().key)
export const bundleHeader = { protocol: evaluatorProtocol }Why: Relay runs each bundle with the host adapter for the context it was built against; protocol 2 is the first stable evaluator contract.
Find it: rg -n 'runtimeVersion|evaluatorProtocol|protocol:|toISOString\(\)\.slice\(0, ?10\)' --glob '!node_modules'
Declared integrations
A quest declares the provider modules whose readers it uses, integrations: [discord, x], imported from @domino-sdk/relay/authoring. ctx.discord and ctx.x are absent unless declared: add discord for Discord role rewards or resource selectors, and x to read an X image. Wallet, member, tracker, image and review readers need no declaration. discordRole(roleId) is discord.grantRole(roleId).
ctx.wallet.connection() and ctx.wallet.solanaBalance() are removed: use ctx.wallet.list() and ctx.wallet.balance({ chain: "solana" }), which totals every linked Solana wallet; to keep the old newest-wallet behavior, take the first Solana wallet from ctx.wallet.list() and pass its address. Both return null when no matching wallet is linked. Keep amounts as strings or BigInt, never Number.
Old:
rewards: [discordRole("123456789012345678")],
const balance = await ctx.wallet.solanaBalance()
if (BigInt(balance.lamports) > 0n) return decision.accept()New:
import { decision, defineQuest, discord, manual, once, settings } from "@domino-sdk/relay/authoring"
export default defineQuest({
key: "hold-sol",
title: "Hold SOL",
settings: settings.object({}),
integrations: [discord],
trigger: manual(),
completion: once(),
rewards: [discord.grantRole("123456789012345678")],
async evaluate(ctx) {
const balance = await ctx.wallet.balance({ chain: "solana" })
return balance && BigInt(balance.total) > 0n ? decision.accept() : decision.reject({ reason: "Hold SOL." })
},
})Why: an evaluator gets only the readers its quest declares, so a quest's providers are known at publication, and wallet reads cover every wallet a member links.
Find it: rg -n 'discordRole\(|ctx\.(discord|x)\.|wallet\.(connection|solanaBalance)\(' --glob '!node_modules'
Wallet reads return every linked wallet
Participant and management reads expose a member's wallets only as lists. In a management member read, member.wallet.connection is replaced by the linked entries of member.wallets, and member.wallet.observation by member.balanceChecks. In @domino-sdk/relay/schema, WalletConnection and walletConnectionSchema are LinkedWallet and linkedWalletSchema; the single-wallet challenge and proof schemas are walletAddressInputSchema, walletMessageChallengeSchema and walletSignatureSchema; and SolanaBalance and solanaBalanceSchema are walletBalanceSchema, whose integer total and per-wallet amount replace lamports, and whose chain-specific at replaces the top-level Solana slot. Fixtures and proxies must include fields 1.0 responses always carry: kind on a member account, wallets, balanceChecks, profile and identities on member detail, profile on a leaderboard row, and wallets on an auth provider.
Old:
const wallet: WalletConnection | null = detail.wallet.connectionNew:
import type { ManagementClient } from "@domino-sdk/relay/server"
import type { LinkedWallet } from "@domino-sdk/relay/schema"
declare const management: ManagementClient
const detail = await management.members.get("member_1")
export const solana: LinkedWallet[] = detail.wallets.filter((w) => w.status === "linked" && w.chain === "solana")Why: members link several wallets on several chains, and a single-wallet field showed only one.
Find it: rg -n 'walletConnectionSchema|WalletConnection|walletChallenge(Input)?Schema|walletProofSchema|solanaBalanceSchema|SolanaBalance|lamports|\bslot\b|\.wallet\.(connection|observation)' --glob '!node_modules'
Typed trigger policies
A quest's trigger decides which completion and prerequisites it may have, and TypeScript and defineQuest check them. A bare automatic() quest runs once its prerequisites hold, so it needs a nonempty requires list and completion: once(); use automatic({ tracker }) when activity supplies the completion key or streak day. A reusable defineQuestType may leave prerequisites to each Console instance. A content quest omits completion, and only a content quest can have a performance stage. A tracker handle limits beneficiary to its source's account roles; for a custom source, declare the role in accounts first.
Old:
trigger: automatic(),
completion: keyed(({ now }) => String(now)),New:
import { automatic, completed, once, type QuestHandle } from "@domino-sdk/relay/authoring"
declare const welcome: QuestHandle
export const policy = { trigger: automatic(), requires: [completed(welcome)], completion: once() }Why: these shapes used to type-check and fail at publication; a bare automatic quest has no activity to key a completion or count a streak day.
Find it: rg -n 'automatic\(\)|beneficiary:' --glob '!node_modules' --glob '*.{ts,tsx,js,mjs}'
Saved CLI mutation recovery
domino create, domino quests archive and unarchive, build submission and deployment publication save a recovery record under $RELAY_CONFIG_DIR/operations before sending. A failure that may leave work to finish carries operation in its JSON error, including known failures such as a checkout failure after the project was created, whose operation.scope.project is empty until the API assigns one. create and the quest lifecycle commands accept --operation-id UUID. Keep the directory between automation runs, and finish an interrupted command with the resume command its error names.
Old:
domino create --json || domino create --jsonNew:
domino operations list --json
domino operations resume OPERATION_ID --jsonWhy: a restarted process lost the action ID of a change that may have landed, so running the command again could create a second project. See CLI automation.
Find it: rg -n 'domino (create|quests (archive|unarchive)|stage|deploy)\b|RELAY_CONFIG_DIR' --glob '!node_modules'
Handle future tracker sources
Published tracker definitions read source as an open type, so a source kind a newer Relay adds keeps its payload instead of failing the tracker list. Narrow it with isKnownTrackerSource(source) from @domino-sdk/relay/schema before reading fields such as query or namespace, and show an unknown source by its kind, read-only.
Old:
if (definition.source.kind === "custom") return definition.source.namespaceNew:
import { isKnownTrackerSource, type TrackerDefinitionResponse } from "@domino-sdk/relay/schema"
export function describeSource({ source }: TrackerDefinitionResponse) {
if (!isKnownTrackerSource(source)) return source.kind
return source.kind === "custom" ? source.namespace : source.kind
}Why: with a closed source union, every new source kind would break apps that list trackers.
Find it: rg -n 'source\.kind|source\.(query|namespace)' --glob '!node_modules'
Discord role ownership and reconciliation
Relay owns the Discord role assignments it makes. A role's membership follows every accepted, unrevoked completion that grants it to that account in that server, across projects and test and live: Relay removes its own assignment when the last grant is revoked and restores a missing role while a grant stays accepted. Assignments Relay did not make, and grants without ownership evidence, such as roles granted before ownership was recorded, are left alone. A delivery has an optional reconciliation (status, ownership, at, reason), separate from its history. retryRewardDelivery on a revoked delivery reconciles without granting again. management.discordRoles.preserve(delivery, { reason }), with review permission, stops Relay from removing an assignment another bot or administrator also manages; see ownership and recovery.
Old:
await management.preserveRewardRole(deliveryId, { reason })New:
import type { ManagementClient } from "@domino-sdk/relay/server"
declare const management: ManagementClient
await management.discordRoles.preserve("delivery_1", { reason: "The moderation bot owns this role too" })Why: role membership has to follow the completions that grant it, also when projects share a server, without removing roles someone else assigned.
Find it: rg -n 'rewardDeliveries|retryRewardDelivery|preserveRewardRole|"grant-role"' --glob '!node_modules'
Prerequisite automatic outcomes
A bare automatic() quest records each decision as an outcome, admitted or skipped, in relay.quests.outcomes() and Console. These rows have automatic set, null tracker, record and observedAt, and claimable: false; key them by automatic, and show an unknown skip code by its reason. Outcome pages merge them with tracker decisions, so don't reuse a cursor saved before the upgrade.
Eligibility is checked after the change that affects it commits: a points entry, a completion, a group change, campaign enrollment or publication. A points mutation's response does not include the automatic attempt it leads to; read outcomes or progress. Events that arrive while the quest is paused are not replayed on resume: an operator previews and confirms a catch-up in Console, or with management.quests.previewAutomaticCatchUp and catchUpAutomatic, with review permission and a reason. See automatic outcomes and recovery.
Old:
const key = `${outcome.tracker}:${outcome.record}:${outcome.observedAt}`New:
import type { QuestOutcome } from "@domino-sdk/relay"
export const rowKey = (outcome: QuestOutcome) =>
outcome.automatic ?? `${outcome.tracker}:${outcome.record}:${outcome.observedAt}`Why: eligibility could change with no visible decision, and events during a pause were dropped silently.
Find it: rg -n 'quests\.outcomes|outcome\.(tracker|record)|automatic\(\)' --glob '!node_modules'