Track posts and activity
Watch X, YouTube, TikTok, Discord, Telegram, members' wallet balances and your own systems for member activity, and reward what counts.
A tracker watches one place for activity and keeps a record of everything it finds. For example:
- Posts on X that match a search, such as
#OurLaunchor replies from your account. - Messages and reactions in your Discord server.
- Messages and reactions in your Telegram groups.
- YouTube Shorts your members post on the channels they linked.
- TikTok videos your members post on the accounts they linked.
- Token balances of the wallets your members linked, sampled by Domino once a day.
- Events your own systems report, such as a token balance, a game win, or kudos someone gives a member.
Collecting never pays anyone. A quest decides what counts: it reads the tracker, checks each new post or event, and rewards the member behind it. One tracker can feed several quests, and each quest can judge the same post differently.
What members see
Members link their X, Discord, Telegram or wallet account, or their YouTube channel or TikTok account, in your app. When a tracker collects their activity, the quests that tracker triggers check it for them automatically, with nothing to submit. A quest can also ask members to enroll first, and let them claim posts they made before they linked their account.
What your team sees
In Console, Community → Trackers lists each tracker with its records and how fresh they are. Your team can pause and resume collection, ask for a sync, and open any record to see which quests admitted it, which skipped it and why. Anyone with review permission can retry a skipped record that should count.
What gets collected
- X trackers keep every match. That includes unverified authors and people who have not joined your project. Quests and reads filter them later.
- Discord and Telegram trackers keep only members' activity. A message or reaction is stored only when it involves an account a member of your project linked; see Reward Discord messages and reactions.
- YouTube trackers read only members' channels. Domino reads the Shorts on channels members of your project linked; see Reward YouTube Shorts.
- TikTok trackers read only members' accounts. Domino reads the public videos on TikTok accounts members of your project linked, with each member's own permission; see Reward TikTok videos.
- Wallet balance trackers read only members' wallets. Domino samples the wallets members of your project linked, once a day; see Reward holding tokens.
- Custom trackers keep what your server sends. Your own servers can feed records from any system, including a blockchain indexer.
For a creator program, add a content quest to qualify posts and award once, then an optional engagement rule to credit measured growth daily. For the model behind this guide, read Trackers and datasets and UGC programs.
| To | Start here |
|---|---|
| Collect and inspect X posts | Publish an X tracker. |
| Let operators add a tracker without a deployment | Create a tracker without a deployment. |
| Qualify member posts and award once | Admit X posts automatically. |
| Require a metric threshold for that award | Add a performance stage. |
| Credit ongoing engagement | Settle engagement points daily. |
| Update future scoring | Change a formula. |
| Reward a value your server measures, such as a balance | Trigger a quest from tracker records. |
| Reward members when an account replies to them | Reward members when an account replies to them. |
| Reward members when an account quotes or reposts them | Reward members when an account quotes or reposts them. |
| Reward the member a record names, such as a kudos recipient | Reward the account a record names. |
| Reward Discord messages or reactions | Reward Discord messages and reactions. |
| Reward members' YouTube Shorts | Reward YouTube Shorts. |
| Reward members' TikTok videos | Reward TikTok videos. |
| Reward holding a token for days | Reward holding tokens. |
New and changed trackers go to test first. The CLI publishes directly to test; for live, domino promote prepares a promotion of test that a person with publish permission approves in Console. See publishing for the deployment workflow.
If the project has no trackers yet, Community → Trackers in Console offers example requests for a coding agent. Select Copy prompt on one and paste it into your agent, or select Send to Build to review it in the playground. The prompt includes the project, the environment, and the steps to define, preview, and publish the tracker. A copied prompt names the selected environment; one sent to the playground names Test.
The exact options, read filters, trigger rules and outcome codes are in the trackers reference.
Preview an X search
Try a search in Console before anyone writes code, to see what it would collect.
- Freshness. Engagement is as of the fetch time. After 15 minutes, Console suggests searching again.
- Nothing is stored. The sample exists only in that Console view. It is not saved, and leaving the view discards it.
- Access and limits. Previews require
publishpermission and allow 6 a minute per person and 60 an hour per organization. - Credentials stay on the server. A preview uses the platform's X credential on the server; the browser never receives it.
Search a sample
In Community → Trackers, select Preview X search. Enter the query and a From (UTC) day, then select Search. Console shows a sample of matching posts with authors, verification, and engagement.
Compare read filters
Switch between All authors, Verified, and Unverified to see how a read filter would change the results. The sample keeps unverified posts and posts with unknown verification.
Copy the result
| Action | Copies |
|---|---|
| Copy tracker definition | A defineTracker file to paste into your project. |
| Copy prompt | The query, the start day, what the sample showed, and your verification choice, for a coding agent. |
| Send to Build | The same prompt, for Test, as a new playground thread to review and send. |
Both use the query and day that were searched, even if you edited the form afterwards. Verification stays a read filter: the definition collects every match. Then publish the definition.
Publish an X tracker
Your developer defines the tracker in code, and it starts collecting once it is published.
Define the tracker
Create trackers/launch.ts:
import { defineTracker, x } from "@domino-sdk/relay/authoring";
export default defineTracker({
key: "launch-posts",
title: "Launch posts",
source: x.search({ query: '"Domino" OR #DominoLaunch' }),
window: { start: Date.parse("2026-09-01T00:00:00Z") },
});Add it to the manifest
Add the entry to your existing relay.json:
{
"trackers": [{ "entry": "trackers/launch.ts" }]
}Deploy to test
Run domino deploy in the project's test environment, review the preview, and publish. Definitions can ship alongside quests, ledgers, referrals, and leaderboards.
Monitor collection
Open Community → Trackers in Console to inspect records and freshness, filter verified accounts, or pause and resume collection. Editors can request a sync while collection is running. Audit history records each change.
When Domino pauses a tracker itself, for example because the project used its monthly X budget, Console says why and when collection resumes. See How often Domino reads X.
X posts show the author's avatar when available, linked mentions and hashtags, attached photos, and reported engagement counts. Direct video files play inline; other media opens through an attachment link.
Lists use a narrow feed with the author and publication date on one line. Photos fill a 4:3 preview frame; select a photo to open its full image. Select Show more to expand a long post, or Show less to collapse it. The quest-outcomes drawer shows the full post.
Counts are read-only; missing counts stay hidden. Select Quest outcomes at the top right of a stored post to open its detail drawer. In the drawer, select the X icon to open the original post or expand Collection details to inspect observation times and provider data.
How often it collects
By default, an X tracker searches every six hours and reads up to 60 pages of results each time, within the start and end dates you give it. Each search covers the whole date range again, and posts the tracker already knows are read again on a schedule. Your project's plan sets how often a tracker can search: every six hours on every plan but Enterprise.
How often Domino reads X
Domino reads X on a best-effort basis, through a shared provider, and limits how much each project reads.
Every search finds new posts in the tracker's dates and updates the numbers on each post it returns. Posts the tracker already knows but the search didn't return are read again by their age:
| Post age | Read again |
|---|---|
| Under 48 hours | Every 6 hours |
| 2 to 7 days | Once a day |
| Over 7 days | Never |
A post that's already more than 7 days old when Domino first finds it is read once. A leaderboard closing its scoring period asks for fresh numbers on the posts it scores, whatever their age.
Every X read counts toward the project's monthly X usage: tracker searches and refreshes, quest checks on X, tracker previews and handle lookups. Usage resets at the start of each month, in UTC.
- Live budget. Each plan includes a monthly X budget for live: 25,000 reads on Launch, 65,000 on Growth, 160,000 on Scale, and by agreement on Enterprise. Where plan limits apply, a project that uses its budget has its live X trackers paused until the next month, or until it moves to a plan with budget left. Quest checks are never paused.
- Test budget. An organization's test X trackers share 5,000 reads a month across its projects. When they're used, test X trackers pause until next month.
- Test window. A test X tracker collects for 14 days from its first sync, then pauses. Resume it to collect for another 14 days.
To see what a campaign's X trackers would read a day on each plan before you publish it, simulate it: the results estimate it from a sample of each tracker's search.
A tracker paused on its budget can't be resumed while the budget is still used. If you pause it yourself, it stays paused until you resume it. A resumed tracker searches its whole date range again, so it still finds posts made while it was paused, if its dates cover them.
Change a tracker
Changing an X query or window keeps collected records and starts new discovery from publication time. It does not silently backfill the expanded scope.
A new key's first publish searches from window.start, even when it is in the past, as far back as the provider's history reaches. Republishing an existing key with a wider query or an earlier window never searches the past: discovery for the changed scope starts at publication time.
Create a tracker without a deployment
A tracker of any source Domino collects itself can be data instead of code, so an operator or your own server adds a campaign without a code change. That covers X searches, Discord and Telegram messages and reactions, YouTube Shorts, TikTok videos and wallet balances. Trackers of your own custom sources stay in code. Data trackers are created and changed in test, and reach live through a promotion, like other definitions. See test and live.
Create the tracker in test
- In Console, open Community → Trackers in the test environment and select New tracker.
- Pick the Source, then enter a title and the window. The key follows the title until you type your own.
- Fill in the source's own fields, if it has any:
- X search: the search query. The sync interval and pages per sync sit under Advanced, which shows their values (every 6 hours, up to 60 pages, unless you change them).
- Discord messages: whether to Store message text.
- Wallet balances: the Chain and, unless you sample the chain's native coin, the Token: the mint on Solana, the ERC-20 contract on an EVM chain, or the denomination on a Cosmos chain. Neither changes later.
- Every other source has none.
- Select Create tracker.
The key is permanent and stays the same in live. An X tracker's first collection searches from the window's start.
A server does the same with the management client and a key with publish permission:
import { createManagementClient } from "@domino-sdk/relay/server";
const management = createManagementClient({
baseUrl: "https://api.example.com",
organization: "my-organization",
project: "community",
environment: "test",
getToken: async () => "SERVER_SIDE_MANAGEMENT_KEY",
});
const tracker = await management.trackers.create({
key: "spring-campaign",
title: "Spring campaign posts",
query: "#springcampaign",
window: { start: Date.parse("2026-04-01T00:00:00Z") },
maxPages: 20,
})
const reactions = await management.trackers.create({
key: "server-reactions",
title: "Server reactions",
source: { kind: "discord-reactions" },
window: { start: Date.parse("2026-04-01T00:00:00Z") },
})From the CLI, run domino trackers create spring-campaign --title "Spring campaign posts" --query "#springcampaign" --start 1775001600000, or name another source with --source, such as domino trackers create server-chat --title "Server chat" --source discord-messages --text on --start 1775001600000. A wallet balances tracker takes --chain and --token: domino trackers create usdc --title "USDC on Base" --source wallet-balances --chain eip155:8453 --token 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 --start 1775001600000.
Change it
Select the tracker in Console, then Edit. From a server, call management.trackers.update(tracker, changes) with only the fields to change. From the CLI, run domino trackers update spring-campaign --query "#springcampaign OR #spring26". The key and the source never change; to track another source, create another tracker.
A new query or window collects from now on and never searches the past, as when changing a tracker in code. A title, sync interval or page limit change leaves discovery where it was.
Reaching live
Live does not accept new or changed trackers directly, from Console or from API keys. A data tracker reaches live under the same key, still owned by data, through a promotion from test. See test and live.
Pause, resume and sync work in both environments, as they do on trackers in code. Archive and unarchive are definition changes, so make them in test.
A promotion copies the tracker, not the connections it reads. A Discord or Telegram tracker records nothing in live until live has its own Discord server or Telegram chats connected in Integrations, and the tracker says so in live until then.
Code or data owns each key
A key belongs either to code or to data, and only its owner changes the definition. Deployments never change or remove data trackers. To move a tracker between code and data, create one with a new key.
Inspect a post’s quest outcomes
Open Community → Trackers, select a tracker, and choose Quest outcomes on a stored record. Content quests on X and custom content sources show qualification, performance and engagement alongside shared admission outcomes. Search previews have no quest outcomes.
| Section | What it explains |
|---|---|
| Admission | Whether the quest admitted the post, or what blocks it. |
| Qualification | The current content decision, original reward outcome, and evaluation history. |
| Performance | The separate metric gate, sampled counters, and evaluation history when the quest has a performance stage. |
| Engagement | Paid, pending, excluded, and earned points for each qualifying rule, plus counter baselines, settlement receipts, and formula history. |
Choose Open quest to use the existing quest controls, or Review in Activity to open an attempt awaiting review. Retry admission releases eligible historical content into evaluation and does not approve its content or guarantee a reward. Relay derives the author’s member from its verified account; retry requires review permission.
Use Reload outcomes to refresh the post’s decisions and accounting. Formula-transition history distinguishes the provider observation time from the time points were credited. Budget-excluded points stay excluded; losing qualification stops future accrual while prior awards remain.
Read and filter the dataset
Your server can read everything a tracker collected and filter it, for example to verified authors with at least ten likes, or to one member's posts. Use the management SDK on your server with a management credential that has read permission. Keep the credential out of browser bundles.
import { createManagementClient } from "@domino-sdk/relay/server";
import { xTweetSchema } from "@domino-sdk/relay/schema";
const management = createManagementClient({
baseUrl: "https://api.example.com",
organization: "my-organization",
project: "community",
environment: "test",
getToken: async () => "SERVER_SIDE_MANAGEMENT_KEY",
});
const page = await management.trackers.records("launch-posts", {
verified: true,
minLikes: 10,
limit: 25,
});
const tweets = page.items.map((record) => xTweetSchema.parse(record.data));
const totals = await management.trackers.summary("launch-posts", {
member: "member_existing",
verified: true,
});Missing engagement counts stay unknown rather than zero, and posts X stops returning are still refreshed. Check freshness and coverage before treating a total as enough evidence.
Use tracker evidence in a quest
A quest can look through a tracker when it checks a member's submission. This example accepts one claim when the connected member has at least one verified post with ten likes:
import { defineQuest, settings, manual, once, points, decision, inGroup } from "@domino-sdk/relay/authoring";
import { community } from "./ledgers";
import { creators } from "./groups";
import { launchPosts } from "./trackers/launch-posts";
export default defineQuest({
key: "launch-contribution",
title: "Share the launch",
settings: settings.object({}),
trigger: manual(),
completion: once(),
// Only approved creators can be rewarded for posting on X.
requires: [inGroup(creators)],
rewards: [points(community, 50)],
async evaluate({ trackers, member }) {
const result = await trackers.summary(launchPosts, {
member: member.id,
verified: true,
minLikes: 10,
});
return result.records > 0
? decision.accept()
: decision.reject({ reason: "No qualifying post has been collected yet." });
},
});Publish the imported community ledger and launchPosts tracker in the same project. creators is defineGroup({ key: "approved-creators", title: "Approved creators" }); create the group in Console Groups and add the creators you approve. Use trackers.records when your code needs individual content, or decision.review when a person should decide.
- Quest rules still apply. Normal quest reward bounds, completion deduplication, review, and leaderboard scoring still apply.
- Reads are point in time. A quest attempt sees the records stored when it first read the tracker, and its retries see the same. See point-in-time reads.
- Each quest decides. Different quests can qualify the same post differently.
Admit X posts automatically
A content quest checks each collected post on its own and rewards it once, with nothing for the member to submit. It suits "post about our launch" campaigns: every qualifying post from an approved creator earns the reward, and a post that is edited or keeps collecting likes never pays twice.
Use content({ tracker, ...policy }) to evaluate one collected content item at a time. Content quests complete once per item automatically, so omit completion. X search provides a normalized post body and six counters; custom sources can declare their own body and metrics. Publish the tracker and quest in the same project deployment, or publish the tracker first.
import { defineQuest, defineTracker, defineGroup, inGroup, x, settings, content, decision } from "@domino-sdk/relay/authoring";
const posts = defineTracker({
key: "launch-posts",
title: "Launch posts",
source: x.search({ query: "#ourlaunch" }),
window: { start: Date.parse("2026-09-01T00:00:00Z") },
});
// Operators add the creators they approve in Console Groups.
const creators = defineGroup({ key: "approved-creators", title: "Approved creators" });
export default defineQuest({
key: "launch-post",
title: "Share a launch post",
settings: settings.object({}),
trigger: content({ tracker: posts,
enrollment: "required",
since: "window",
historical: "claim",
}),
requires: [inGroup(creators)],
rewards: [],
async evaluate({ tracker }) {
const post = tracker.content.body;
if (post.isRetweet) {
return decision.reject({ reason: "Publish an original post." });
}
return decision.accept();
},
});The tracker must also have its own published tracker entry. Import that definition into the quest in your project to keep its window consistent. Add your declared rewards to rewards when ready. The inGroup(creators) prerequisite admits only posts by members of the approved-creators group; create it and add members in Console Groups, with domino groups, or with management.groups. See groups.
A post counts only once Domino can tie it to a member: through a verified X account a member of your project linked, never through a matching username or a verification badge. Choose who takes part and from when:
| Choice | Option | What it means |
|---|---|---|
| Members sign up first | enrollment | "required" counts only members who enrolled in this quest. The default, "none", counts everyone. |
| Which posts count | since | "activation" (the default): posts since the quest was published. "window": any post within the tracker's dates. "enrollment": posts after each member enrolled; it needs enrollment: "required". |
| Posts from before they linked | historical | "claim" (the default): the member claims them. "automatic": they count. "staff": a reviewer releases them. |
Posts must be published within the tracker's dates. A claim cannot get around dates, enrollment, ownership, prerequisites, or a paused quest. See content quest admission for how admission pins a post's rules.
Enroll and release historical posts
Content quests use the same enrollment, outcome and historical-claim APIs as other tracker quests. See member outcomes and claims for a complete example. Operators can retry a skipped post in Console under Quest outcomes → Admission. A retry releases historical activity but cannot bypass other admission rules.
Follow qualification after an edit
When a member edits an admitted post, Domino checks the new version again. The post keeps the reward it already earned: a later qualifying edit pays nothing more, and a post that stops qualifying keeps its earlier award, because automatic reversals are not implemented. Disconnecting the account, relinking it to another member, or removing the post blocks rewards that have not been paid yet. Add an independent engagement rule for daily growth payouts.
Require an engagement threshold before awarding
Add a performance stage when the reward should wait for a post to perform, such as reaching 100 likes:
import { defineQuest, defineTracker, defineGroup, inGroup, x, content, settings, decision } from "@domino-sdk/relay/authoring";
const posts = defineTracker({
key: "launch-posts",
title: "Launch posts",
source: x.search({ query: "#launch" }),
window: { start: Date.parse("2026-09-01T00:00:00Z") },
});
const creators = defineGroup({ key: "approved-creators", title: "Approved creators" });
export default defineQuest({
key: "popular-launch-post",
title: "Publish a launch post that reaches 100 likes",
settings: settings.object({}),
trigger: content({ tracker: posts, historical: "automatic" }),
requires: [inGroup(creators)],
rewards: [],
evaluate: async ({ tracker }) => tracker.content.body.text.includes("#launch")
? decision.accept()
: decision.reject({ reason: "Include #launch." }),
performance: {
metrics: ["likes"],
maxAgeMs: 3_600_000,
evaluate: ({ metrics }) => metrics.likes >= 100
? decision.accept()
: decision.reject({ reason: "Reach 100 likes." }),
},
});Publish the tracker as its own entry or in the same deployment, then add the desired rewards.
The content check and the performance check are separate, and both must pass before the reward pays. At 20 likes, performance can reject while the content stays qualified. A later sample at 150 likes runs performance again and can issue the reward. If the likes later drop, the reward stays; if they recover, it does not pay again.
Declare custom content
Use customSource(namespace, { content }) when each record represents a creator's content item, such as a video on another platform. Declare a Zod object for body and the counter names that performance and engagement may read. This replaces the source's data option.
import { z, customSource, defineTracker, defineQuest, content, settings, decision } from "@domino-sdk/relay/authoring";
const videos = defineTracker({
key: "creator-videos",
title: "Creator videos",
source: customSource("videos", {
identities: ["member"],
content: {
body: z.object({ title: z.string(), url: z.string() }),
metrics: ["plays", "likes"],
},
}),
window: { start: Date.parse("2026-09-01T00:00:00Z") },
});
export default defineQuest({
key: "launch-video",
title: "Publish a launch video",
settings: settings.object({}),
trigger: content({ tracker: videos, historical: "automatic" }),
rewards: [],
evaluate: async ({ tracker }) => tracker.content.body.title.includes("Domino")
? decision.accept()
: decision.reject({ reason: "Mention Domino in the title." }),
performance: {
metrics: ["plays"],
evaluate: ({ metrics }) => metrics.plays >= 100
? decision.accept()
: decision.reject({ reason: "Reach 100 plays." }),
},
});Ingest records with data: { body: { title, url }, metrics: { plays, likes } }. The record ID identifies the content item, author identifies its creator through the declared identity kinds, and occurredAt is its publication time. Update the same record with a later observedAt for edits and new counter samples. Ingestion enforces the body schema and declared metric names. Omit an unmeasured counter; a missing value is unknown, while zero is a real measurement. Metrics are nonnegative whole numbers. A plain custom source without a content declaration and Discord sources cannot use content().
Feed another source, including a blockchain indexer
When the activity happens somewhere Domino doesn't watch, such as your shop, your game or a blockchain, your server can send it to a custom tracker. Declare a custom source and the shape of each record's data, as a zod object schema:
import { defineTracker, customSource, z } from "@domino-sdk/relay/authoring";
export default defineTracker({
key: "transfers",
title: "Token transfers",
source: customSource("chain-transfers", {
data: z.object({
chain: z.string(),
// Base-unit amounts as decimal strings: large amounts overflow JavaScript numbers.
amount: z.string(),
confirmations: z.number().int().min(0),
}),
}),
window: { start: Date.parse("2026-09-01T00:00:00Z") },
});A server sends records through management.trackers.ingest(reference, { actionId, records }) with a management key that has ingest permission. ingest allows custom tracker records and nothing else, so it suits an indexer: in Console, open Settings → API keys, and under Create an API key select only Ingest. Editors hold ingest, and a key never exceeds its creator's permissions. A hosted app's own key (hostedManagementClient(env)) has ingest. Declare a scheduled job when external records need refreshing independently of member visits. Verify Relay's signed call before collecting data, then use the run ID as ingestion's actionId.
For example code, open the tracker in Community → Trackers and select Send records. Console opens it on its own while a custom tracker has no records and is not paused. The example uses the selected project and environment, your tracker's key, one record with an author and named accounts of the source's identity kinds, and data that matches its schema. Select Copy code, or Copy prompt or Send to Build to hand it to a coding agent. Replace the placeholder IDs and data with your own. A tracker without a data schema shows that it refuses records instead.
Under Ingestion trackers, choose Selected trackers only to restrict the key to specific custom trackers. Your active keys lists the stored tracker IDs; Edit tracker scope changes the restriction without rotating the secret. No selection means all custom trackers in the key's project and environment. A denied tracker stores no records. The restriction applies to both tracker-key and tracker-ID ingestion calls. Hosted app keys keep project-wide ingestion.
| Record field | Required | Notes |
|---|---|---|
id | Yes | |
occurredAt | Yes | When the event happened. |
observedAt | Yes | Orders updates to the same record. |
data | Yes | A JSON object of at most 128 KB that matches the source's data schema. |
author | No | { id, kind? }. Required when the source declares identities. |
accounts | No | Named accounts by role, such as { recipient: { kind: "x", id: "…" } }. Only roles the source declares; kind as for author. |
removed | No | Marks the record removed. |
- Batches. Batches accept up to 100 records, each with at most 128 KB of data, within the tracker window. Batches into one tracker are stored one at a time, so concurrent calls into the same tracker wait for each other: send many records in one batch rather than one call per record or member.
- Retries. Reuse an action ID only when retrying exactly the same request. An action ID is 1–100 letters, digits,
_or-, so the management client refusesingest:member:2026-10-20before sending it, with aninvalid-inputerror that namesactionId. For a retry-stable ID, hash what makes the batch itself to hex, such as each record'sidandobservedAt:`ingest-${createHash("sha256").update(JSON.stringify(records.map((r) => [r.id, r.observedAt]))).digest("hex")}`. An ID from one record'sidalone comes back with a newer observation of that record, which is a different request and is refused withaction-id-reused. For a one-off write, usecrypto.randomUUID(). - Newer observations win. A newer
observedAtupdates the current record while preserving the prior snapshot. Older observations cannot overwrite newer values; conflicting data at the same observation time is rejected. - Custom records stay tracker-local. Reusing a namespace or record ID in another custom tracker does not share or overwrite its records.
Declare record data
customSource(namespace, { data }) requires a schema, and Domino holds every record to it. A record that doesn't match is refused with its batch, quests read the data with its declared types, and trigger filters are checked against it.
Blockchain events
Use a stable chain/transaction/log identity as the record ID. Your indexer owns chain access, decoding, confirmations, and reorganization detection. It can mark an event removed: true with a newer observation, and reads exclude removed records by default.
The tracker does not operate a blockchain node or reverse rewards automatically.
Attribute records to members
For a quest to pay someone, each record has to point at a member. Declare the identity kinds a custom source's accounts can be when records belong to members, so quests can trigger from them:
| Kind | Account ID | Attributed to |
|---|---|---|
wallet | The wallet's canonical CAIP-10 account ID | The member who currently links that wallet in the project |
x | The stable numeric X user ID, not the handle | The member whose selected, verified X account has that ID |
discord | The stable numeric Discord user ID | The member whose selected, verified Discord account has that ID |
member | A Domino member ID | That member |
app:<name>, such as app:game | The stable user ID your app signs the user in with | The member your app login integration of that kind signed in with that ID in the project |
A record has an author, and can name other accounts in roles the source declares with accounts. Each account states its kind unless the source declares exactly one:
import { defineTracker, customSource, z } from "@domino-sdk/relay/authoring";
// Each record's author is a Discord or X account; a record can also name a recipient.
export const kudos = defineTracker({
key: "kudos",
title: "Kudos",
source: customSource("kudos", {
identities: ["discord", "x"],
accounts: ["recipient"],
data: z.object({ message: z.string() }),
}),
window: { start: Date.parse("2026-10-01T00:00:00Z") },
});
// Sent with management.trackers.ingest("kudos", { actionId, records: [record] }).
const record = {
id: "kudos-1842",
author: { kind: "discord", id: "1180234567890123456" },
accounts: { recipient: { kind: "x", id: "1492837465" } },
occurredAt: Date.now(),
observedAt: Date.now(),
data: { message: "Thanks for the walkthrough" },
};A published custom tracker's namespace, identities and accounts cannot change; use a new key. Records with an undeclared kind or role, or an ID in the wrong form, are refused. See source and identity kinds for ingestion checks and reading kinds as an open set.
Find an X account's user ID
Trackers, the --author read filter, custom x identities and ctx.x.follows({ userId }) take an X account's stable numeric user ID, not its handle. Resolve a handle with a credential that has read permission:
domino x lookup @XDevelopers --jsonimport { createManagementClient } from "@domino-sdk/relay/server";
const management = createManagementClient({
baseUrl: "https://api.example.com",
organization: "my-organization",
project: "community",
environment: "test",
getToken: async () => "SERVER_SIDE_MANAGEMENT_KEY",
});
// { id, username, name } for the account that holds the handle.
const account = await management.x.lookup({ username: "@XDevelopers" });
console.log(account.id);The handle can start with @; X ignores its letter case. username comes back as X shows it now.
- A lookup resolves the handle, nothing more. It tells you which account held the handle when Domino's X provider last read it, at most a day ago. It does not show who owns or controls that account, and a handle can pass to another account later. Store the
id, and attribute records to members only through their verified links. - Lookups share Domino's X quota. Each user can make 30 lookups a minute, and an organization 300 an hour. Resolve a campaign's list once and keep the IDs.
- X has to be configured. Lookups need Domino's X provider.
Your app's user IDs
When your app knows its users by its own IDs, such as a game account or a Telegram user ID, create its app login integration with an identity kind such as app:game, and declare that kind. Records then name your app's user IDs, and Relay resolves each to the member your app signed in with it:
import { defineTracker, customSource, z } from "@domino-sdk/relay/authoring";
export const wins = defineTracker({
key: "game-wins",
title: "Game wins",
source: customSource("game-wins", {
identities: ["app:game"],
data: z.object({ score: z.number() }),
}),
window: { start: Date.parse("2026-10-01T00:00:00Z") },
});
// The author is the user ID your server passes to the app login integration.
const record = {
id: "match-90311",
author: { id: "player-4417" },
occurredAt: Date.now(),
observedAt: Date.now(),
data: { score: 3 },
};A user ID is linked when the user signs in through your app. A record for a user ID no member has signed in with yet, or one an operator unlinked, is skipped with unlinked-account. Disconnecting the app login key keeps its links. The rules for naming an app kind are in source and identity kinds.
Wallet accounts
For records that belong to wallets, such as balance samples, declare customSource("token-balance", { identities: ["wallet"], data }). Each wallet account's ID must be its canonical CAIP-10 account ID, the form Domino stores for linked wallets: cosmos:cosmoshub-4:cosmos1… (any Cosmos chain ID, lowercase bech32), eip155:1:0xAbC… (EIP-55 checksum), or solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:… (base58). caip10(wallet) from @domino-sdk/relay/schema builds it from a linked wallet, such as an entry of (await management.members.get(id)).wallets. For a value sampled per wallet, use the account ID as the record ID too, so the tracker holds one current sample per wallet:
import { createHash } from "node:crypto";
import { createManagementClient } from "@domino-sdk/relay/server";
const management = createManagementClient({
baseUrl: "https://api.example.com",
organization: "my-organization",
project: "community",
environment: "test",
getToken: async () => "SERVER_SIDE_MANAGEMENT_KEY",
});
const account = "cosmos:cosmoshub-4:cosmos19rl4cm2hmr8afy4kldpxz3fka4jguq0auqdal4";
const sampledAt = Date.now();
await management.trackers.ingest("token-balance", {
// Hash the external account ID, which contains colons, into identifier-safe text.
actionId: `token-balance-${createHash("sha256").update(account).digest("hex")}-${sampledAt}`,
records: [
{
id: account,
author: { id: account },
occurredAt: sampledAt,
observedAt: sampledAt,
// Base-unit amounts as decimal strings: large balances overflow JavaScript numbers.
data: { amount: "1523400000", decimals: 6 },
},
],
});- Wallet IDs are canonical. A wallet account in any other form is refused with 422, and the error names the expected canonical ID.
- A Cosmos wallet is its address. A Cosmos account ID is attributed to the member who links that bech32 address, whichever chain ID it names.
caip10(wallet)uses the chain the wallet was linked on.
Read one member's current samples with management.trackers.records("token-balance", { member }). It returns records for every wallet the member currently links, with a Cosmos wallet's records under any chain ID; an unlinked wallet's records drop out. Rank an external score samples every linked wallet this way through a readBalance(account) function your app supplies.
Trigger a quest from tracker records
A tracker trigger runs a quest by itself each time new activity arrives, for the member behind it. Use it to reward a balance that crosses a threshold, a reply from your account, posting several days in a row, or kudos a member receives. You choose three things:
| Choice | Option | Default |
|---|---|---|
| When it runs | on | Each new record on X, Discord and Telegram trackers ("new-record"); each new sample on other custom trackers ("new-observation"), which in SDK 1.x include YouTube Shorts, TikTok and wallet balance trackers, so each refresh or daily sample runs it again. "data-changed" runs only when a record's data changes. A published quest keeps the mode it was published with. |
| Which records count | where | Every record. A filter on the record's data, such as a balance at or above a threshold operators can change. |
| Who it pays | beneficiary | The record's author. Or the author of the post a reply answers, the author of a message someone reacted to, or an account the record names. |
A record never pays its own author through another role, so nobody earns from replying to themselves or reacting to their own message. Quest code cannot choose another member: Domino finds the member through verified account links.
This quest completes once, when a wallet sample first reaches a threshold operators can change:
import {
automatic,
customSource,
decision,
defineQuest,
defineTracker,
once,
points,
settings,
z,
} from "@domino-sdk/relay/authoring";
import { pointsLedger } from "./ledgers";
const tokenBalance = defineTracker({
key: "token-balance",
title: "Token balance",
source: customSource("token-balance", {
identities: ["wallet"],
data: z.object({ amount: z.string(), decimals: z.number().int() }),
}),
window: { start: Date.parse("2026-10-01T00:00:00Z") },
});
export default defineQuest({
key: "balance-threshold",
title: "Reach a balance threshold",
settings: settings.object({
// Base units, exact at any scale; Console shows and edits whole tokens.
threshold: settings.amount({ label: "Threshold", default: "1000000000000000000", decimals: 18 }),
}),
// Only changed samples at or above the setting run the quest; the rest cost nothing.
trigger: automatic({
tracker: tokenBalance,
on: "data-changed",
where: { path: "amount", gte: { setting: "threshold" } },
}),
completion: once(),
review: () => ({ enabled: false }),
rewards: [points(pointsLedger, 1000)],
async evaluate() {
return decision.accept();
},
});A quest that totals several accounts reads them in its evaluator instead, such as trackers.records(tokenBalance, { member: member.id }) for every wallet the member links, because a filter judges one record. packages/sdk/examples/hold-balance-quest.ts totals linked wallets that way.
In a project, keep the tracker in its own module listed under trackers in relay.json and import it from the quest. Publish both in the same deployment, or the tracker first.
Reward members when an account replies to them
An X tracker that collects an account's replies can pay the member each reply answers, with beneficiary: "inReplyTo". This quest pays for each reply from @examplelabs, at most seven a week per member. Operators choose in Console what a reply must contain, such as a keyword or an emoji:
import {
automatic,
budget,
decision,
defineQuest,
defineTracker,
points,
settings,
x,
} from "@domino-sdk/relay/authoring";
import { pointsLedger } from "./ledgers";
const replies = defineTracker({
key: "examplelabs-replies",
title: "Replies from @examplelabs",
// Collect every reply; the quest checks the text, so changing it needs no new tracker.
source: x.search({ query: "(from:examplelabs) filter:replies" }),
window: { start: Date.parse("2026-10-01T00:00:00Z") },
refreshSeconds: 900,
});
export default defineQuest({
key: "account-reply",
title: "Get a reply from @examplelabs on X",
settings: settings.object({
requiredText: settings.text({
label: "Required text",
description: "Text a reply must contain. Empty: every reply counts.",
default: "",
minLength: 0,
maxLength: 100,
}),
}),
trigger: automatic({ tracker: replies, beneficiary: "inReplyTo" }),
completion: ({ keyed }) => keyed(({ tracker }) => `x-reply:${tracker.record.id}`),
budgets: [budget({ key: "replies", scope: "member", period: "week", limit: 7, cost: 1 })],
review: () => ({ enabled: false }),
rewards: [points(pointsLedger, 500)],
async evaluate({ tracker, settings }) {
const reply = tracker.content!.body;
const required = settings.requiredText.trim().replaceAll("️", "").toLowerCase();
if (!reply.isReply) {
return decision.reject({ reason: "The post is not a reply." });
}
return !required || reply.text.replaceAll("️", "").toLowerCase().includes(required)
? decision.accept()
: decision.reject({ reason: `The reply does not contain "${settings.requiredText.trim()}".` });
},
});- Each reply pays once. The completion key is the reply's post ID, so a second reply to the same member post pays again. Key on
tracker.content.body.references.replyIdinstead to pay once per member post. - The weekly budget caps it.
period: "week"counts per ISO week of the project calendar, starting Monday at midnight in the project timezone, byeligibleAt(when the reply was published). The eighth accepted reply in a week is rejected with "Budget exhausted: replies"; the next Monday starts a new week. - Identity is the stable ID. The search matches handles, which can change owner. To pin the replying account, compare
reply.authorIdwith the account's stable X user ID, whichdomino x lookup HANDLEreturns. - Self-replies never pay. A reply to the replying account's own post, or to another account of the same member, is skipped with
beneficiary-is-author. - Members link X first. A reply to someone with no selected, verified X account is skipped with "No member has linked this X account". When they link it, the reply is decided again; it was published before the link, so it is
historicaland counts once a reviewer retries it. Sethistorical: "claim"to let the member claim it, or"automatic"to admit it.
packages/sdk/examples/account-reply-quest.ts is a complete version: operators also set the replying accounts' stable IDs and the points per reply.
Reward members when an account quotes or reposts them
An X tracker that collects an account's reposts or quotes can pay the member whose post it picked up: beneficiary: "reposted" pays the author of the reposted post, and beneficiary: "quoted" the author of the quoted post. This quest pays 100 points each time @examplelabs reposts a member's post:
import { automatic, decision, defineQuest, defineTracker, points, x } from "@domino-sdk/relay/authoring";
import { pointsLedger } from "./ledgers";
const reposts = defineTracker({
key: "examplelabs-reposts",
title: "Reposts by @examplelabs",
source: x.search({ query: "from:examplelabs filter:nativeretweets" }),
window: { start: Date.parse("2026-10-01T00:00:00Z") },
});
export default defineQuest({
key: "get-noticed",
title: "Get reposted by @examplelabs on X",
trigger: automatic({ tracker: reposts, beneficiary: "reposted" }),
completion: ({ keyed }) => keyed(({ tracker }) => `x-repost:${tracker.record.id}`),
review: () => ({ enabled: false }),
rewards: [points(pointsLedger, 100)],
async evaluate() {
return decision.accept();
},
});- Quotes work the same way. Collect
from:examplelabs filter:quoteand paybeneficiary: "quoted". To reward both, publish one quest per role; each post the tracker collects pays each quest at most once. - This isn't a reward for posting. Like reply quests, it pays the member whose post an account you choose picked up, so it needs no approved-creators group and doesn't raise
x-post-rewards-ungated. - Self-quotes and self-reposts never pay. A quote or repost of the account's own post, or of another account of the same member, is skipped with
beneficiary-is-author. A post that is no quote or repost is skipped with "Post is not a quote" or "Post is not a repost". - Members link X first. A quote or repost of someone with no selected, verified X account is skipped with "No member has linked this X account" (
unlinked-account), and is decided again when they link it. As with replies, a quote or repost published before they linked it ishistorical; the original post's date doesn't count.
Reward consecutive days
streak(...) on a tracker trigger rewards days of activity in a row, such as posting on X five days running or holding a balance for a week. Each accepted observation marks the calendar day of its record's occurredAt, and the milestone pays once per member. This quest pays approved creators who post about @examplelabs on five consecutive days:
import { automatic, defineGroup, defineQuest, defineTracker, inGroup, points, settings, streak, x } from "@domino-sdk/relay/authoring";
import { pointsLedger } from "./ledgers";
// X does not allow rewarding everyone who posts: operators add approved creators in Console Groups.
const creators = defineGroup({ key: "approved-creators", title: "Approved creators" });
const mentions = defineTracker({
key: "examplelabs-mentions",
title: "Posts mentioning @examplelabs",
source: x.search({ query: "@examplelabs -filter:replies" }),
window: { start: Date.parse("2026-10-01T00:00:00Z") },
refreshSeconds: 900,
});
export default defineQuest({
key: "posting-streak",
title: "Post about @examplelabs five days in a row",
settings: settings.object({}),
// Runs once for each newly collected post, for the member whose verified X account wrote it.
trigger: automatic({ tracker: mentions }),
// Posts by members outside the group are skipped with prerequisites-incomplete.
requires: [inGroup(creators)],
completion: streak({ days: 5 }),
review: () => ({ enabled: false }),
rewards: [points(pointsLedger, 2500)],
});- A day is when the activity happened. For a post, that is when it was published, not when the collector found it, so a post collected late fills its own day and can join two runs.
- Days are the project's. A day ends at midnight in the project calendar's timezone, UTC unless you choose another.
- One day counts once. Several posts, or several samples of one record, on the same calendar day occupy one day.
- Activity before publication does not count. Observations that occurred before the quest was published are skipped with
before-activation. - The milestone ends the quest for that member. After it pays, the trigger admits nothing more for that member and skips their later observations with
already-completed, as for a completedonce()quest.
For "hold 1,000 tokens for 7 days", use a wallet balances tracker: Domino samples every linked wallet once a day, so your app runs no balance job.
A custom tracker can feed a streak too, for a value your server measures. Use the default on: "new-observation", never "data-changed", since an unchanged value still has to fill its day, and give every sample its own occurredAt, the time it was measured: the streak counts the day of occurredAt, so samples that keep reusing the record's first time all land on that one day.
packages/sdk/examples/posting-streak-quest.ts is the complete posting example.
Reward the account a record names
A custom record can name accounts besides its author, in roles the source declares, and a quest can pay any of them. Kudos are one use: your Discord bot or app server records who thanked whom, and the quest pays the recipient.
import {
automatic,
budget,
customSource,
decision,
defineQuest,
defineTracker,
points,
settings,
z,
} from "@domino-sdk/relay/authoring";
import { pointsLedger } from "./ledgers";
const kudos = defineTracker({
key: "kudos",
title: "Kudos",
// The giver is the author, the recipient a named account: a Discord user ID or a member ID.
source: customSource("kudos", {
identities: ["discord", "member"],
accounts: ["recipient"],
data: z.object({ message: z.string().max(2000) }),
}),
window: { start: Date.parse("2026-10-01T00:00:00Z") },
});
export default defineQuest({
key: "kudos-received",
title: "Receive kudos",
settings: settings.object({}),
// First sight of each kudos only: an edited message does not pay again.
trigger: automatic({ tracker: kudos, on: "new-record", beneficiary: "recipient" }),
completion: ({ keyed }) => keyed(({ tracker }) => `kudos:${tracker.record.id}`),
budgets: [budget({ key: "kudos", scope: "member", period: "week", limit: 5, cost: 1 })],
review: () => ({ enabled: false }),
rewards: [points(pointsLedger, 50)],
async evaluate() {
return decision.accept();
},
});Each kudos your server sends is one record, with a stable ID such as the Discord interaction ID:
const record = {
id: "interaction-1842",
author: { kind: "discord", id: "1180234567890123456" },
accounts: { recipient: { kind: "member", id: "member_existing" } },
occurredAt: Date.now(),
observedAt: Date.now(),
data: { message: "Thanks for the walkthrough" },
};- Givers never pay themselves. Kudos to the giver's own account are skipped with
beneficiary-is-author, and so are kudos to another account of the giver's member once the giver's account belongs to exactly one member. - The giver need not be a member. Kudos from an account no member has linked still pay the recipient. If only members should give kudos, have your bot send kudos only from members. To limit what each giver gives, add a per-giver budget.
- A rejected kudos stays rejected. With
on: "new-record", only the first observation of a record runs the quest, so a kudos rejected once, such as for a message too short, and edited later never pays. - The server vouches for the event. Relay checks that the recipient is a verified identity, not that the kudos happened. Send records only from code you trust, with a key limited to Ingest.
packages/sdk/examples/kudos-quest.ts is a complete version with a record helper and a minimum message length operators can change.
Cap what each giver gives
When a quest pays someone other than the record's author, such as a reaction's message author, a kudos recipient or the member an account replied to, a "member" budget caps what each receiver gets. A budget with scope: "author" caps what each giver gives: it counts per record author, the account that caused the record. This tipping quest pays a message's author 10 points for each ⚡ reaction, at most 20 tips a day per receiver and 10 a day per reactor:
import { automatic, budget, decision, defineQuest, defineTracker, discord, points, settings } from "@domino-sdk/relay/authoring";
import { pointsLedger } from "./ledgers";
const reactions = defineTracker({
key: "discord-reactions",
title: "Discord reactions",
source: discord.reactions(),
window: { start: Date.parse("2026-10-01T00:00:00Z") },
});
export default defineQuest({
key: "tip",
title: "Get tipped",
settings: settings.object({}),
trigger: automatic({ tracker: reactions, beneficiary: "messageAuthor" }),
// Every tip pays: one completion per reaction.
completion: ({ keyed }) => keyed(({ tracker }) => tracker.record.id),
budgets: [
budget({ key: "tips-received", scope: "member", period: "day", limit: 20, cost: 1 }),
budget({ key: "tips-given", scope: "author", period: "day", limit: 10, cost: 1 }),
],
review: () => ({ enabled: false }),
rewards: [points(pointsLedger, 10)],
async evaluate({ tracker }) {
return tracker.record.data.emoji.name === "⚡"
? decision.accept()
: decision.reject({ reason: "Tip with ⚡." });
},
});- Givers are counted by account. The cap counts the reactor's Discord account, whether or not a member linked it, so it holds for admins and moderators who never joined the project. A record with no author pays nothing from a quest with an author budget.
- Over either cap, the tip is refused. The reactor's eleventh tip of the day is rejected with "Budget exhausted: tips-given", and it doesn't pay later; other reactors' tips keep paying. Days follow the project calendar.
- Only for tracker triggers.
domino check,domino buildanddomino deployrefuse an author budget on a quest without a tracker trigger;checkreportsAUTHOR_BUDGET_TRACKER_REQUIRED. Quests that share an author budget's key share each giver's count, like any shared budget. - Members don't see it. A member's quest
budgetslist leaves author budgets out, since they cap someone else's giving. A quest's published description lists them inauthorBudgets, apart frombudgets.
Explain trigger outcomes
Every record a quest's trigger selects ends up Admitted, with an attempt to check, or Skipped, with the first rule that stopped it and what to do about it. The most common skips:
| Skip | Usually means | What happens next |
|---|---|---|
Not linked (unlinked-account) | No member has linked the account yet | Linking it decides the skip again. |
Not joined (not-joined) | The account's owner hasn't joined this project yet | Signing in to your app decides it again. |
Not enrolled (not-enrolled) | The quest, or the campaign it belongs to, requires enrollment; the outcome's enrollment says which | Enrolling decides it again, or an operator enrolls them. |
Historical (historical) | The activity came before the member linked the account or enrolled | A reviewer retries it, or the member claims it when the quest allows claims. |
Prerequisites (prerequisites-incomplete) | The member wasn't in a required group or hadn't finished a required quest | Adding them later does not decide it again; retry it if it should count. |
Already completed (already-completed) | A one-time quest or streak already paid | Nothing. |
A quest budget doesn't skip a record: the record is admitted, and its attempt is rejected when the budget can't cover another completion.
When a member links an account, removes a conflicting link, joins the project, or enrolls, Domino decides their skips with the matching code again, shortly after, under each quest's current rules. Activity from before the link or enrollment then meets the historical rule: see Historical activity. The full list of codes, in the order Domino applies them, is in trigger outcome codes.
In Console, open Community → Trackers, select the tracker, and select Quest outcomes on a record, or open the quest and find Trigger outcomes. Each skip says what to do about its code. Open attempt shows the evaluation and its decision. Retry admission re-runs the rules for that observation under the quest's current release, links, membership, and enrollment, and releases historical activity; it needs review permission. When it still cannot admit, it shows the current reason and the outcome stays skipped. On a not-enrolled skip, Enroll the member enrolls them on their behalf; it needs enroll permission. An outcome a member claimed shows Claimed.
From a server, read outcomes with read permission and retry with review:
import { createHash } from "node:crypto";
import { createManagementClient } from "@domino-sdk/relay/server";
const management = createManagementClient({
baseUrl: "https://api.example.com",
organization: "my-organization",
project: "community",
environment: "test",
getToken: async () => "SERVER_SIDE_MANAGEMENT_KEY",
});
const unlinked = await management.trackers.triggerOutcomes("token-balance", {
quest: "balance-threshold",
code: "unlinked-account",
});
for (const outcome of unlinked.items) {
await management.trackers.retryOutcome("token-balance", {
// External record IDs can contain punctuation. Hash the tuple for a stable action ID.
actionId: `retry-${createHash("sha256").update(JSON.stringify([outcome.quest, outcome.record, outcome.observedAt])).digest("hex")}`,
quest: outcome.quest,
record: outcome.record,
observedAt: outcome.observedAt,
}).catch(() => undefined); // 409 with the current reason when it still cannot admit
}Let members enroll, read their outcomes, and claim
In a signed-in participant app, members enroll in quests that require it, read their own outcomes, and claim historical activity on quests with historical: "claim":
import { createRelayClient } from "@domino-sdk/relay";
const relay = createRelayClient({ baseUrl: "/relay" });
await relay.quests.enroll({ actionId: crypto.randomUUID(), quest: "launch-post" });
const claimable = await relay.quests.outcomes({ quest: "launch-post", code: "historical" });
for (const outcome of claimable.items.filter((item) => item.claimable)) {
await relay.quests.claim({
actionId: crypto.randomUUID(),
quest: outcome.quest,
tracker: outcome.tracker!,
record: outcome.record!,
observedAt: outcome.observedAt!,
});
}- Members act only for themselves. No input names a member; Relay uses the session's. Outcomes list only the decisions Relay attributed to the member, newest first, a page of up to 50: pass
cursor: page.nextCursorwhile it is not null. - Enrolling records consent. Repeating an active enrollment keeps its current time. Re-enrolling after operator withdrawal starts a new consent time. A quest whose trigger does not require enrollment refuses with the code
enrollment-not-required, and a paused quest withquest-paused. - A claim releases only historical activity. Relay decides the outcome again under the quest's current rules. A rule that still fails rejects with its skip code, such as
prerequisites-incomplete; a skip that is nothistorical, or a quest that does not take claims, rejects withnot-claimable.
To rank members by the value itself, give it its own point ledger, updated with absolute values: see Rank an external score.
Reward Discord messages and reactions
Reward members for taking part in your Discord server: posting in a channel, reacting with a certain emoji, or getting reactions on their own messages. Domino's Discord gateway writes your server's messages and reactions into Discord trackers as they happen. Connect the server to the project first, as described in Connect Discord to a project. Without a ready connection, a Discord tracker stays empty.
Define the trackers
Create trackers/discord.ts with the sources you need:
import { defineTracker, discord } from "@domino-sdk/relay/authoring";
export const messages = defineTracker({
key: "discord-messages",
title: "Discord messages",
source: discord.messages(),
window: { start: Date.parse("2026-10-01T00:00:00Z") },
});
export const reactions = defineTracker({
key: "discord-reactions",
title: "Discord reactions",
source: discord.reactions(),
window: { start: Date.parse("2026-10-01T00:00:00Z") },
});Add them to the manifest
{
"trackers": [
{ "entry": "trackers/discord.ts", "exportName": "messages" },
{ "entry": "trackers/discord.ts", "exportName": "reactions" }
]
}Deploy to test
Run domino deploy in the project's test environment. Records arrive as activity happens in the server: Relay does not collect earlier activity. You cannot send records to a Discord tracker or refresh it. To try its quests in test without a Discord account, send a test member's message or reaction with management.testMembers.record (Send X and Discord activity).
What a Discord tracker stores
- Only members' activity is kept. A record is stored only when at least one of its accounts is a Discord account a member of the tracker's project has linked: the poster of a message, or the reactor or the message's author for a reaction. Activity from before the account was linked is not stored and never arrives later.
- Text is opt-in.
discord.messages({ text: true })stores each message's text indata.text. It isnullwhen Domino's bot does not receive message content; see Read a message from quest code. Publishing the tracker without the option erases stored text. - Bots are left out. Messages from bots and webhooks, and reactions added by bots, are not recorded.
- Threads count toward their channel. A message or reaction in a thread, including a forum post, names both the thread and the channel it belongs to, so a quest can count a channel's threads.
- Edits and deletes are followed. On a tracker that keeps text, an edit updates it. Deleting a message marks its record removed and erases its text. Removing a reaction changes nothing.
Discord can leave gaps, such as reactions replayed after a reconnect; see Coverage limits.
Telegram reactions
telegram.reactions() records the reactions members add to messages in the project's connected Telegram groups and supergroups, one record per added reaction, with the reactor as author and the message's author as the messageAuthor account. Its data names the emoji and whether the reactor is a chat administrator, for a trigger's where. Like a Discord tracker, it keeps only reactions that involve a member's linked Telegram account, and you cannot send records to it. See Reward reactions.
Telegram messages
telegram.messages() records the messages members post in the project's connected Telegram groups and supergroups, one record per message, with the poster as author. Its data names the chat, the message, the message it replies to and its topic thread, never the text, for a trigger's where. It keeps only messages whose poster linked Telegram in the project, and leaves out bots' messages, service messages such as joins and pins, and channel posts. An edit is the same message, and a deleted message stays recorded, since Telegram doesn't tell bots about deletions. You cannot send records to it. See Reward messages.
Trigger a quest
automatic({ tracker }) on a Discord tracker runs once for each new message or reaction, with the default on: "new-record", for the member whose linked Discord account is the beneficiary. A where value is fixed in code; check a value operators choose in Console, such as a channel, in the evaluator.
This quest pays for a message in a channel operators choose, or in one of its threads, once per member per project-calendar day:
import {
automatic,
decision,
defineLedger,
defineQuest,
defineTracker,
discord,
keyed,
points,
settings,
} from "@domino-sdk/relay/authoring";
const community = defineLedger({ key: "community", name: "Community" });
const messages = defineTracker({
key: "discord-messages",
title: "Discord messages",
source: discord.messages(),
window: { start: Date.parse("2026-10-01T00:00:00Z") },
});
export default defineQuest({
key: "discord-daily-message",
title: "Post in the community channel",
// A Discord channel setting needs the quest to declare the integration.
integrations: [discord],
settings: settings.object({
channelId: settings.resource({ label: "Channel", source: discord.channels() }),
}),
trigger: automatic({ tracker: messages }),
// The project day of the message: a second message that day does not pay again.
completion: keyed(({ calendar }) => calendar.day().key),
review: () => ({ enabled: false }),
rewards: [points(community, 10)],
async evaluate({ tracker, settings }) {
const { channelId, parentChannelId } = tracker.record.data;
// In a thread, channelId is the thread and parentChannelId the channel it belongs to.
return channelId === settings.channelId || parentChannelId === settings.channelId
? decision.accept()
: decision.reject({ reason: "The message is not in the community channel." });
},
});| To reward | Trigger |
|---|---|
| Posting a message | automatic({ tracker: messages }) |
| Adding a reaction | automatic({ tracker: reactions }), optionally with where: { path: "emoji.name", equals: "🫡" } |
| Receiving a reaction | automatic({ tracker: reactions, beneficiary: "messageAuthor" }) |
| Days in a row | Any of the above with streak(...). Each accepted record fills the calendar day of its occurredAt. |
When a quest pays the message's author:
- Reactions to one's own message never pay. A reaction from the message's author, or from another Discord account of the same member, is skipped with
beneficiary-is-author. - The reactor need not be a member. The record is kept because the message's author linked Discord. To pay only for reactions from certain people, such as moderators, check
data.reactorRolesin the evaluator. - An unnamed author is skipped. When Discord could not name the message's author, the reaction has no
messageAuthoraccount and is skipped withno-account.
Members link Discord in your app first. A member whose linked account is the beneficiary but who has not joined the project is skipped with not-joined; when they sign in to your app, it is decided again. A reaction whose beneficiary no member has linked, such as the reactor when only the message's author is a member, is skipped with unlinked-account. Activity from before a member linked Discord is historical, and so is all activity of a member whose Discord connection predates account selection and has no link time: they select their Discord account again to give it one. See Explain trigger outcomes.
Reward YouTube Shorts
youtube.shorts() collects the Shorts your members post on their own YouTube channels. A member links their channel in your app as a creator account; from then on, the tracker finds their new Shorts and keeps each one's views, likes and comments up to date. There's no search: only channels your members linked are read, with each member's own permission.
Define the tracker and a quest
A Short counts when it's at most three minutes long (one minute for uploads before 2024-10-15) and vertical or square. Use it with a content quest like any other content: here, a Short about the launch earns its reward once it reaches 1,000 views.
import { defineQuest, defineTracker, youtube, content, settings, decision } from "@domino-sdk/relay/authoring";
const shorts = defineTracker({
key: "launch-shorts",
title: "Launch Shorts",
source: youtube.shorts(),
window: { start: Date.parse("2026-11-01T00:00:00Z") },
// How often Domino looks for new Shorts and refreshes their counts, in seconds.
// Your plan sets the shortest interval: six hours on every plan but Enterprise.
refreshSeconds: 3600,
});
export default defineQuest({
key: "launch-short",
title: "Post a launch Short that reaches 1,000 views",
settings: settings.object({}),
trigger: content({ tracker: shorts }),
rewards: [],
evaluate: async ({ tracker }) =>
tracker.content.body.tags.includes("launch")
? decision.accept()
: decision.reject({ reason: "Tag the Short with #launch." }),
performance: {
metrics: ["views"],
evaluate: ({ metrics }) => metrics.views >= 1000
? decision.accept()
: decision.reject({ reason: "Reach 1,000 views." }),
},
});Each record is one Short, keyed by its video ID. Its author is the channel, so Domino credits the member who linked it, and the usual admission choices apply: a Short published before the member linked the channel is historical. An engagement rule can credit growth in views, likes or comments.
Deploy and follow collection
Deploy to test as for any tracker. Community → Trackers shows each sync. When a sync can't read a channel, for example because its owner revoked access, the tracker reports it and the channel's Shorts are left as they were; the member links the channel again to fix it.
| What happens | What Domino does |
|---|---|
| A Short is deleted or made private | Removes it, which blocks rewards not yet paid. |
| A member unlinks their channel, or links another | Stops reading the old channel. Its Shorts stay. |
| A channel needs reconnecting | Skips it until the member links it again. Nothing is removed. |
| The platform's daily YouTube allowance runs out | Pauses collection until it resets at midnight Pacific time, then continues where it stopped. |
Reward TikTok videos
tiktok.videos() collects the public videos your members post on their own TikTok accounts. A member links their TikTok account in your app as a creator account; from then on, the tracker finds their new videos and keeps each one's views, likes, comments and shares up to date. There's no search: TikTok shares a member's videos only with that member's own permission, so only accounts your members linked are read.
Define the tracker and a quest
Use it with a content quest like any other content: here, a video that isn't labelled as AI-generated earns its reward once it reaches 10,000 views.
import { defineQuest, defineTracker, tiktok, content, settings, decision } from "@domino-sdk/relay/authoring";
const videos = defineTracker({
key: "launch-tiktoks",
title: "Launch TikToks",
source: tiktok.videos(),
window: { start: Date.parse("2026-11-01T00:00:00Z") },
// How often Domino looks for new videos and refreshes their counts, in seconds.
// Your plan sets the shortest interval: six hours on every plan but Enterprise.
refreshSeconds: 3600,
});
export default defineQuest({
key: "launch-tiktok",
title: "Post a launch TikTok that reaches 10,000 views",
settings: settings.object({}),
trigger: content({ tracker: videos }),
rewards: [],
evaluate: async ({ tracker }) =>
tracker.content.body.isAigc
? decision.reject({ reason: "Post a video you filmed yourself." })
: decision.accept(),
performance: {
metrics: ["views"],
evaluate: ({ metrics }) => metrics.views >= 10000
? decision.accept()
: decision.reject({ reason: "Reach 10,000 views." }),
},
});Each record is one video, keyed by its video ID. Its author is the TikTok account, so Domino credits the member who linked it, and the usual admission choices apply: a video posted before the member linked the account is historical. An engagement rule can credit growth in views, likes, comments or shares.
Deploy and follow collection
Deploy to test as for any tracker. Community → Trackers shows each sync. When a sync can't read an account, for example because its owner removed Domino's access, the tracker reports it and the account's videos are left as they were; the member links the account again to fix it.
| What happens | What Domino does |
|---|---|
| The account no longer returns a video, for example because it was deleted | Removes it, which blocks rewards not yet paid. |
| A member unlinks their account, or links another | Stops reading the old account. Its videos stay. |
| An account needs reconnecting, for example because its owner removed Domino's access | Skips it until the member links it again, and shows Needs reconnect on the member as soon as TikTok refuses the access. Nothing is read from it, and nothing is removed. |
| Domino reaches TikTok's per-minute request limit | Pauses collection for a minute, then continues where it stopped. |
Reward holding tokens
wallet.balances({ chain, token }) samples the balance of one token in every wallet your members linked, once a day. Use it to reward holding, such as "hold 1,000 USDC for 7 days": each daily sample at or above your minimum fills one day of a streak. Your app runs no balance job, and members can't time a transfer between wallets around the sample.
Install the snippet
pnpm dlx shadcn@4.21.0 add https://snippets.domino.run/r/token-hold-streak.jsonIt adds a tracker for USDC on Base and a quest that pays once a member's wallet holds at least the minimum for 7 days in a row. Set the chain and token in the tracker file before the first deploy, and add it to trackers in relay.json. The tracker and quest look like this:
import { automatic, defineQuest, defineTracker, points, settings, streak, wallet } from "@domino-sdk/relay/authoring";
const balances = defineTracker({
key: "usdc-balances",
title: "USDC balances on Base",
source: wallet.balances({ chain: "eip155:8453", token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" }),
window: { start: Date.parse("2026-11-01T00:00:00Z") },
});
export default defineQuest({
key: "hold-usdc",
title: "Hold 1,000 USDC for 7 days",
settings: settings.object({
threshold: settings.amount({ label: "Minimum balance", default: "1000000000", decimals: 6 }),
balance: settings.pointLedger({ label: "Point ledger" }),
}),
trigger: automatic({ tracker: balances, where: { path: "amount", gte: { setting: "threshold" } } }),
completion: streak({ days: 7 }),
review: () => ({ enabled: false }),
rewards: ({ settings }) => (settings.balance ? [points(settings.balance, 500)] : []),
});The chain and token are those of ctx.wallet.balance(): an EVM chain with its native coin or an ERC-20 contract, Solana with SOL or an SPL or Token-2022 mint, or a Cosmos chain Domino reads with a bank denomination. They can't change once the tracker is published; to sample another token, create another tracker.
Deploy and follow the samples
Deploy to test as for any tracker. Community → Trackers shows each wallet as one record whose latest sample is its current balance, and when it next runs: the start of the next day, then that day's sample time.
- Once a day, at a time members can't predict. Each day of the project calendar, Domino picks a time from a secret only it holds, in the first three quarters of the day.
- Every wallet at one block. One sample reads every wallet at the same EVM block or Cosmos height, so tokens moved between two members' wallets can't be counted twice. Solana can't read a past slot: there every read must reach the slot the sample started at.
- Only members' wallets. A sample reads each wallet of the chain's family linked by a member who joined the project: every EVM wallet for an EVM chain, every Solana wallet for Solana, and Cosmos wallets linked on that exact chain. An unlinked wallet is no longer read; its records stay.
- A wallet judged on its own. A
wherefilter compares one wallet's sample. To total a member's wallets, read them in an evaluator withtrackers.records(balances, { member: member.id }).
| What happens | What Domino does |
|---|---|
| The chain's provider fails | Writes nothing, shows the error on the tracker, and tries again 15 minutes later at the same block. |
| Every try of a day fails | That day has no sample, and a streak breaks on it: Domino never writes a balance it didn't read. Grant the quest by hand to a member who lost a day this way. |
| A sample doesn't finish before its day ends | Stops it, so a later read never fills the wrong day. The wallets it didn't reach have no sample that day. |
| The chain or token isn't one Domino can read | Shows why on the tracker and tries again hourly. A tracker's chain and token can't change: create a tracker with a new key and the right token, point the quest at it, and archive the wrong one. |
Settle engagement points daily
An engagement rule keeps paying members as their posts keep performing: for example one point for every ten likes, settled once a day, with daily and lifetime caps you set. It runs alongside a content quest: the quest decides which posts qualify and pays its one-time reward, and the rule pays for growth after that. Rules apply to posts first discovered after the rule was published. Existing discoveries are not backfilled.
Create engagement.ts:
import { defineEngagementReward } from "@domino-sdk/relay/authoring";
import { pointsLedger } from "./ledgers";
import launchPost from "./quests/launch-post.quest";
export default defineEngagementReward({
key: "launch-growth",
qualifiedBy: launchPost,
ledger: pointsLedger,
baseline: "historical",
metrics: ["likes"],
budgets: [
{ scope: "project", period: "day", limit: 10000 },
{ scope: "member", period: "day", limit: 500 },
{ scope: "project", period: "lifetime", limit: 100000 },
],
target: ({ metrics }) => Math.floor(metrics.likes / 10),
});The target is cumulative: ten likes earn one point, nineteen still earn one, and twenty earn two. Each budget caps the points paid for the whole project or for each member, per day, week, month or lifetime; growth over a cap is excluded for good, not carried into tomorrow. Days follow the project calendar.
Add an explicit entry to relay.json:
{
"engagement": [{ "entry": "engagement.ts" }]
}Keep the existing connection, tracker, quest, and points declarations in the manifest. Preview and publish the complete project to test:
pnpm exec domino deploy --preview --environment test --json
pnpm exec domino deploy --resume DEPLOYMENT_ID --environment test --yesReplace DEPLOYMENT_ID with the preview's deployment. The preview labels each engagement rule create, unchanged, or change. A tracker, content quest, points balance, and engagement rule can publish in one transaction. The CLI publishes only to test, and live refuses a deployment preview with promotion-required.
You can also publish one rule through the management API. Use a server-side management key with publish permission and keep it out of browser code. Management keys can publish in test; live publication follows the existing Console-session approval policy.
import { buildQuest } from "@domino-sdk/relay/build";
import { createManagementClient } from "@domino-sdk/relay/server";
const management = createManagementClient({
baseUrl: "https://relay.domino.run",
organization: "your-organization",
project: "your-project",
environment: "test",
getToken: async () => process.env.DOMINO_MANAGEMENT_KEY ?? null,
});
const { code } = await buildQuest("./engagement.ts");
const preview = await management.deployments.preview({ engagement: [{ code }] });
await management.deployments.publish(preview.id);Pausing the qualifying quest stops accrual; points already earned stay. Deleted content, changed ownership, pending review and failed qualification block further accounting until resolved, and credited points are never reversed automatically.
Change a formula without repricing past engagement
Change how future engagement is scored without changing what members already earned. For example, double a rule's rate from metrics.likes to 2 * metrics.likes: points earned under the old rate keep their old price, and only growth after the switch earns at the new rate.
Edit the rule's target, bundle it, and preview the change. Keep the same rule key, qualifying quest, balance, declared metrics, baseline policy, freshness policy, and budgets. This workflow changes the formula only.
For a manifest entry, run domino deploy --preview --json. Review its engagement entries, including the formula change's accounting summary, then run domino deploy --resume DEPLOYMENT_ID --yes. A normal domino deploy also previews and publishes these changes, displaying engagement details in its terminal confirmation.
Alternatively, use a management key with publish permission on your server:
import { buildQuest } from "@domino-sdk/relay/build";
import { createManagementClient } from "@domino-sdk/relay/server";
const management = createManagementClient({
baseUrl: "https://relay.domino.run",
organization: "your-organization",
project: "your-project",
environment: "test",
getToken: async () => process.env.DOMINO_MANAGEMENT_KEY ?? null,
});
const { code } = await buildQuest("./engagement.ts");
const preview = await management.deployments.preview({ engagement: [{ code }] });
// Review the formula change and accounting summary in preview.engagement.
const requested = await management.deployments.publish(preview.id);Each post switches using its latest fresh, qualified measurement, which can be up to the rule's freshness limit old, rather than a measurement taken the moment you publish. At the switch Domino settles what the old formula owes under the existing caps, then starts the new formula from the same counts. Caps and excluded growth don't reset, and a post whose likes dip and recover earns nothing for the recovery.
| Change | Result after the switch |
|---|---|
| Rate changes from one to two at 100 likes; next sample is 110. | Earn 20 new points. |
| Likes previously reached 100 but now read 80; then recover to 100. | No reward for recovery; the baseline retains 100. |
| A target capped at 100 changes to a cap of 200 when counters are 150. | No top-up for the old 150; 160 earns 10 new points. |
Use Domino with Reown AppKit
Let members sign in to Domino with the wallet they connect through Reown AppKit, WalletConnect included, and link more wallets the same way.
Referrals and inviter bonuses
Reward members for inviting friends, reward the friends who join, and share a cut of what friends earn with the person who invited them.