Build with Dualkind
Configure the choices your site, app or game can make, connect real events, and measure what improves.
Decide → Apply → Expose → TrackA decision selects settings. Your product applies them. Exposure confirms they were used. Later events tell the learner what happened.
- Choose a paid plan and create a project, then open its configuration editor.
- Start with one decision, a few meaningful alternatives, and one measurable objective.
- Connect the SDK, consent and real events. Keep the server key on your backend.
- Check that events arrive and observation windows mature before judging the result.
Configurations you can start with
Every example below is fictional. Download a complete JSON configuration to import into your project’s editor; adapt the available choices to your actual product.
More completed signups
One layout decision and a signup within 24 hours. Start here to validate the integration with a small number of choices.
JSON configuration
{
"objectives": {
"signup": {
"metric": "conversion",
"event": "signup",
"unit": "user",
"anchor": "first_seen",
"untilSeconds": 86400
}
},
"decisions": {
"experience": {
"mode": "settings",
"assignment": "user",
"variables": {
"layout": {
"type": "string",
"values": [
"compact",
"detailed"
],
"default": "compact"
}
},
"inputs": {},
"optimize": {
"signup": 1
},
"learning": {
"exploration": 0.15,
"holdout": 0.15,
"decay": 0.997,
"transfer": "preserve"
},
"semanticsVersion": 1
}
}
}
Retention and net revenue
Separate onboarding, offer and nudge decisions. Learn from D1, D3 and seven-day revenue, account for refunds, and protect D3 with a holdout guardrail.
JSON configuration
{
"objectives": {
"d1": {
"metric": "retention",
"event": "session_started",
"unit": "user",
"anchor": "first_seen",
"fromSeconds": 86400,
"untilSeconds": 172800
},
"d3": {
"metric": "retention",
"event": "session_started",
"unit": "user",
"anchor": "first_seen",
"fromSeconds": 259200,
"untilSeconds": 345600
},
"revenue7d": {
"metric": "revenue_per_user",
"event": "purchase",
"value": "amountMinor",
"currency": "EUR",
"unit": "user",
"anchor": "first_seen",
"untilSeconds": 604800,
"refunds": {
"event": "purchase_refunded",
"purchaseId": "purchaseEventId",
"value": "amountMinor"
}
}
},
"decisions": {
"onboarding": {
"mode": "settings",
"assignment": "user",
"variables": {
"flow": {
"type": "string",
"values": [
"guided",
"explore"
],
"default": "guided"
}
},
"inputs": {},
"optimize": {
"d1": 2,
"d3": 2,
"revenue7d": {
"weight": 1,
"target": 10000
}
}
},
"offer": {
"mode": "settings",
"assignment": "session",
"variables": {
"presentation": {
"type": "string",
"values": [
"compact",
"detailed"
],
"default": "compact"
}
},
"inputs": {
"productViews": {
"type": "integer",
"minimum": 0,
"maximum": 1000000,
"buckets": [
{
"maximum": 2,
"value": "0-2"
},
{
"maximum": 1000000,
"value": "3+"
}
],
"source": {
"event": "product_viewed",
"windowSeconds": 604800
}
}
},
"optimize": {
"revenue7d": {
"weight": 1,
"target": 10000
}
}
},
"return_nudge": {
"mode": "action",
"actions": {
"none": {
"channel": "none"
},
"email_tomorrow": {
"channel": "email",
"template": "come_back",
"delaySeconds": 86400
},
"push_later": {
"channel": "push",
"template": "continue",
"delaySeconds": 21600
},
"in_app_now": {
"channel": "in_app",
"template": "pick_up_where_you_left_off",
"delaySeconds": 0
}
},
"optimize": {
"d3": 2,
"revenue7d": {
"weight": 1,
"target": 10000
}
}
}
},
"measurement": {
"d1": {},
"d3": {},
"revenue7d": {
"target": 10000,
"refundUntilSeconds": 1209600
}
},
"guardrails": {
"d3": {
"maximumDrop": 0.02,
"minimumUsers": 1000
}
}
}
Return after a cart change
A fresh cart change opens a recurring window. Wait one day, cancel on purchase, then choose an allowed nudge or no action. Quiet hours and frequency limits apply.
JSON configuration
{
"objectives": {
"return3d": {
"metric": "retention",
"event": "session_started",
"unit": "user",
"anchor": "event",
"anchorEvent": "cart_updated",
"fromSeconds": 86400,
"untilSeconds": 259200
},
"revenue3d": {
"metric": "revenue_per_user",
"event": "purchase",
"value": "amountMinor",
"currency": "EUR",
"unit": "user",
"anchor": "event",
"anchorEvent": "cart_updated",
"untilSeconds": 259200
}
},
"decisions": {
"return_nudge": {
"mode": "action",
"trigger": {
"event": "cart_updated",
"delaySeconds": 86400,
"cancelOn": [
"purchase"
]
},
"eligibility": {
"quietHours": {
"from": "21:00",
"until": "09:00",
"timezone": "user"
},
"frequency": {
"maximum": 2,
"windowSeconds": 604800
}
},
"actions": {
"none": {
"channel": "none"
},
"email_now": {
"channel": "email",
"template": "come_back",
"delaySeconds": 0
},
"push_later": {
"channel": "push",
"template": "continue",
"delaySeconds": 21600
},
"in_app_next_session": {
"channel": "in_app",
"template": "pick_up_where_you_left_off",
"delaySeconds": 0,
"delivery": "next_session"
}
},
"optimize": {
"return3d": 2,
"revenue3d": {
"weight": 1,
"target": 10000
}
}
}
}
}
Choose a starting point
Choose the outcome before adding variables. These examples are hypothetical products, not customer integrations. Begin with two to four candidate combinations per decision and a small number of observed contexts.
A stationery shop: net revenue
Vary product-detail length and whether recommendations appear. Optimize net revenue per user plus purchase conversion. The €50 target normalizes the revenue learning score; it is not a price, quota or promised result. Record confirmed purchases and refunds from your backend.
JSON configuration
{
"objectives": {
"net_revenue": {
"metric": "revenue_per_user",
"event": "purchase",
"value": "amountMinor",
"currency": "EUR",
"unit": "user",
"anchor": "first_seen",
"untilSeconds": 86400,
"refunds": {
"event": "purchase_refunded",
"purchaseId": "purchaseEventId",
"value": "amountMinor"
}
},
"purchase": {
"metric": "conversion",
"event": "purchase",
"unit": "user",
"anchor": "first_seen",
"untilSeconds": 86400
}
},
"measurement": {
"net_revenue": {
"target": 5000,
"refundUntilSeconds": 604800
},
"purchase": {}
},
"decisions": {
"product_page": {
"mode": "settings",
"assignment": "session",
"variables": {
"detailLevel": {
"type": "string",
"values": [
"compact",
"expanded"
],
"default": "compact"
},
"recommendations": {
"type": "boolean",
"values": [
false,
true
],
"default": false
}
},
"inputs": {},
"optimize": {
"net_revenue": {
"weight": 3,
"target": 5000
},
"purchase": 1
},
"learning": {
"exploration": 0.2,
"holdout": 0.2,
"decay": 0.9999,
"transfer": "preserve"
},
"semanticsVersion": 1
}
}
}
A learning app: useful first sessions
Choose guided or self-directed onboarding for the same signed-in user across visits. A completed task and D1 return teach the learner after 48 hours; D3 remains an independent measurement. Fire task_completed only when the user actually finishes a task.
JSON configuration
{
"objectives": {
"first_task": {
"metric": "conversion",
"event": "task_completed",
"unit": "user",
"anchor": "first_seen",
"untilSeconds": 86400
},
"d1": {
"metric": "retention",
"event": "session_started",
"unit": "user",
"anchor": "first_seen",
"fromSeconds": 86400,
"untilSeconds": 172800
},
"d3": {
"metric": "retention",
"event": "session_started",
"unit": "user",
"anchor": "first_seen",
"fromSeconds": 259200,
"untilSeconds": 345600
}
},
"measurement": {
"first_task": {},
"d1": {},
"d3": {}
},
"decisions": {
"onboarding": {
"mode": "settings",
"assignment": "user",
"variables": {
"start": {
"type": "string",
"values": [
"guided",
"self_directed"
],
"default": "guided"
}
},
"inputs": {},
"optimize": {
"first_task": 1,
"d1": 3
},
"learning": {
"exploration": 0.2,
"holdout": 0.2,
"decay": 0.9999,
"transfer": "preserve"
},
"semanticsVersion": 1
}
}
}
A puzzle game: returning players
Choose one or two hints and a short or guided tutorial. Apply settings at the next round boundary, keeping an in-progress round stable. Learn from first-level completion and D1 return; monitor D3 and D7 without making them delay the launch reward.
JSON configuration
{
"objectives": {
"first_level": {
"metric": "conversion",
"event": "level_completed",
"unit": "user",
"anchor": "first_seen",
"untilSeconds": 3600
},
"d1": {
"metric": "retention",
"event": "session_started",
"unit": "user",
"anchor": "first_seen",
"fromSeconds": 86400,
"untilSeconds": 172800
},
"d3": {
"metric": "retention",
"event": "session_started",
"unit": "user",
"anchor": "first_seen",
"fromSeconds": 259200,
"untilSeconds": 345600
},
"d7": {
"metric": "retention",
"event": "session_started",
"unit": "user",
"anchor": "first_seen",
"fromSeconds": 604800,
"untilSeconds": 691200
}
},
"measurement": {
"first_level": {},
"d1": {},
"d3": {},
"d7": {}
},
"decisions": {
"round": {
"mode": "settings",
"assignment": "session",
"variables": {
"hintAllowance": {
"type": "number",
"values": [
1,
2
],
"default": 1
},
"tutorial": {
"type": "string",
"values": [
"short",
"guided"
],
"default": "short"
}
},
"inputs": {},
"optimize": {
"first_level": 1,
"d1": 4
},
"learning": {
"exploration": 0.2,
"holdout": 0.2,
"decay": 0.9999,
"transfer": "preserve"
},
"semanticsVersion": 1
}
}
}
Put settings at a safe boundary
For the puzzle example, call dk.decide('round') before a fresh round, validate and apply choice.values.hintAllowance and choice.values.tutorial in your game, then call dk.expose('round'). Record dk.track('level_completed', {}, { eventId: completedRoundId }) only on a real completion. Reuse that event ID if delivery is retried. Keep the current round and saved progression unchanged if a request fails.
Weights, targets and time windows
A weight of 4 versus 1 gives an objective four times the influence in the combined learning score. It does not impose a hard minimum or guarantee a result. Use measurement and guardrails for independent checks. A €50 revenue target is represented by 5000 integer cents. It caps the score used to learn; uncapped revenue is still reported.
The current learner waits for all optimized windows before settling a combined reward. The puzzle and app samples therefore begin updating after 48 hours; their D7/D3 measurements mature later. The shop’s purchase window is one day; refund corrections can change the learned evidence later, and its holdout revenue comparison waits for the seven-day refund deadline. The advanced retention sample trades slower feedback for longer-term objectives.
At roughly 1,000 plays or visits per day, count eligible consenting users, not just page loads. Start small, retain a randomized holdout, and wait for mature observation windows. Review the uncertainty before adding choices or contexts; the available traffic may not identify small effects.
Plans and session counting
Dualkind has no free service plan. Launch is €19 per month for 100,000 sessions and five projects. Growth is €59 per month for 1,000,000 sessions and 25 projects, before applicable tax. You can sign in and manage your account before subscribing; a paid plan is required to create customer projects and serve adaptive decisions.
One billed session is a unique project, visitor and session ID within a UTC calendar month. Several decisions and events in that session count once. Your allowance is shared across your projects. Keep the same SDK instance during a visit, and use dk.newSession() when a genuinely new visit starts, not on every click. Native apps and backends should send a stable sessionId for each real visit. Delayed purchase/refund events do not need a fabricated visit.
At the monthly limit, new sessions stop; existing sessions can still report their outcomes. When a subscription expires or is cancelled, new adaptive decisions and delivery claims stop, including requests that reuse an old session. Previously earned outcomes, refunds and channel withdrawal remain available for known users. Your product must keep its own defaults and handle unavailable learning gracefully.
Connect your product
Import the hosted browser module directly, as shown below. The example matches the signup configuration. Backend examples use the downloadable backend.mjs client with Node.js 20 or newer; save it beside your integration code. No package installation is required. No baseUrl is needed for the default deployment; set it only when targeting another deployment, such as localhost.
import { createDualkind } from 'https://dualkind.com/sdk/index.js';
const dk = createDualkind({ projectKey: 'YOUR_PROJECT_KEY' });
// Connect your actual consent control before collecting anything.
yourCmp.onConsentChanged(async allowed => {
dk.setConsent(allowed === true);
if (allowed !== true) return;
// If signed in, use the same opaque ID as your backend.
dk.identify('account-42');
const choice = await dk.decide('experience');
// Apply choice.values.layout in your own UI first.
// Keep your normal UI if values are empty.
await dk.expose('experience');
});
// Call from your actual completed-signup handler.
async function onSignupCompleted() {
await dk.track('signup', {}, { eventId: 'signup-account-42' });
}
Consent comes first
Collection is off by default. Before permission, Dualkind does not create visitor IDs or send events. A detected TCF CMP takes precedence and requires a real registered vendor ID and the required purpose consents; Dualkind does not currently provide a registered GVL vendor ID. Do not invent one. For another CMP, wire its actual permission callback as above. Withdrawal stops collection and clears SDK identity; withheld events are not replayed.
Before consent or during an outage, keep your authored UI. You may bundle configuration when creating the client so defaults are available without fetching. This is optional and does not grant consent.
Choose useful objectives
Optimize a real product outcome, not a guess about what someone wants. A completed purchase, meaningful return or finished level is more useful than an invented “buying intent” flag.
- Conversion: did the configured event occur within the window?
- Retention: did the user return in a later window? D1 is 24–48 elapsed hours; D3 is 72–96 hours. These are not calendar-day buckets.
- Revenue: eligible spend per user in a declared currency. Use integer minor units:
4900 EUR means €49.00.
Users with zero outcomes count once their windows mature. Pending windows are not failures. A revenue target: 10000 caps that objective’s learning score at €100, but it does not cap reported revenue. Weights express trade-offs between normalized scores, not a guaranteed percentage of attention or an outcome floor.
Choose a first-seen anchor for acquisition and early retention. Choose anchor: "event" with an anchorEvent for recurring moments such as a cart update. A newer anchor opens a new cohort; overlapping purchases are not counted twice within an objective. Objectives used together by a policy must share its anchor.
Record facts once
Use stable event IDs for retries. Record a purchase from your trusted order or payment handler, rather than both the browser and backend. You do not pass a decision ID into outcome tracking; Dualkind associates outcomes with eligible exposed decisions automatically.
import { createBackend } from './backend.mjs';
const backend = createBackend({
projectKey: process.env.DUALKIND_PROJECT_KEY,
serverKey: process.env.DUALKIND_SERVER_KEY
});
// Record confirmed outcomes from your payment/order handler.
await backend.record({
userId: 'account-42', event: 'purchase', eventId: 'order-123',
properties: { amountMinor: 4900, currency: 'EUR' }
});
// A partial refund references that original purchase event.
await backend.record({
userId: 'account-42', event: 'purchase_refunded', eventId: 'refund-456',
properties: { purchaseEventId: 'order-123', amountMinor: 1000, currency: 'EUR' }
});
Refunds require backend authentication, the original purchase ID, a positive minor-unit amount and the same currency. Partial refunds cannot cumulatively exceed the purchase. They correct the original cohort and retained learning without adding another sample. Currency conversion is your application’s responsibility.
Across sessions and devices
Anonymous browser identity persists after consent. Use the same opaque account ID in your frontend and backend when a user is known. Dualkind does not fingerprint people or infer cross-device identity; identifying an account does not merge all earlier anonymous history. Call dk.newSession() when your product begins a new session. The first decision, tracked event or interaction records session_started automatically; do not send that reserved event yourself.
You can record custom interactions with dk.interact('product_viewed', { properties: { productId: 'notebook' } }). Track observable facts such as views and cart changes. The SDK supplies identity, session and timezone after consent; it does not infer every useful device or product input. If your configuration declares client inputs, pass those observations to decide.
Choose the moment, too
Action policies can choose email, push, in-app delivery or nothing. The third sample waits after cart_updated. Dualkind’s background worker evaluates the timer even while the visitor is away; a later purchase cancels it.
// Backend: enable only channels the user has permitted.
await backend.identify({
userId: 'account-42', timezone: 'Europe/Lisbon',
channels: { email: true, push: false, in_app: true }
});
// Browser: a real cart change opens a cohort and arms its timer.
await dk.track('cart_updated', { itemCount: 2 }, { eventId: 'cart-change-123' });
trigger.delaySeconds is the wait before choosing. An action’s delaySeconds adds a wait before delivery. delivery: "next_session" waits for a subsequent real session, but still needs a connected client to acknowledge delivery.
Your backend supplies recipients and delivery adapters. The application worker claims jobs and acknowledges actual delivery; Dualkind does not send customer messages merely because a policy exists. Keep a none option, respect channel consent and suppression, and deduplicate deliveries with job.idempotencyKey. The complete worker below polls for jobs, checks permission again, sends through an adapter and acknowledges the result.
Run a delivery worker
Download all three modules into one directory. This email adapter connects directly to your SMTP server using Nodemailer, a local JavaScript library. No email API service is required. Configure your SMTP credentials and an authorized sender address. For this example, enable only the email_now and none actions in the nudge configuration. Unsupported channels are cancelled. Add separate adapters before enabling push or in-app actions.
# Node.js 20 or newer; download these into the same directory.
for file in backend.mjs delivery-worker.mjs provider-adapter.mjs; do
curl -fsS https://dualkind.com/docs/examples/$file -o $file
done
npm install nodemailer
# Supply DUALKIND_PROJECT_KEY, DUALKIND_SERVER_KEY, SMTP_HOST,
# SMTP_USERNAME, SMTP_PASSWORD and MAIL_FROM through your secret store.
# SMTP_PORT=587 and SMTP_TLS=starttls are the defaults.
# For implicit TLS use SMTP_PORT=465 and SMTP_TLS=tls.
node delivery-worker.mjs
Create contacts.json beside the worker. Replace the sample recipient with your own test address and set permission only after obtaining it. In a real integration, replace the file lookup with your current contact, consent and suppression database. Keep recipients in your application.
{
"account-42": {
"email": "your-test-recipient@example.com",
"emailConsent": false,
"suppressed": false
}
}
Run one worker process for this file-based example. Its private delivery-receipts directory must persist across restarts. SMTP acceptance is the exposure point here; it does not prove inbox delivery or that someone read a message. Handle bounces and unsubscribes in your suppression database. Failed acknowledgements reclaim the same job and reuse its accepted receipt without sending again. SMTP does not guarantee deduplication. An attempt without a saved acceptance receipt is never automatically sent again: inspect the receipt’s Message-ID in your server logs. If accepted, mark its receipt accepted; delete the receipt to retry only after confirming the server did not accept it. A Message-ID alone does not prevent duplicates.
Download delivery-worker.mjs ↓import {pathToFileURL} from 'node:url';
import {setTimeout as sleep} from 'node:timers/promises';
import {createBackend} from './backend.mjs';
import {canDeliver, deliver} from './provider-adapter.mjs';
export async function deliverBatch(backend, adapter = {canDeliver, deliver}, now = Date.now) {
const {jobs} = await backend.claim();
for (const job of jobs) {
if (job.leaseUntil * 1000 <= now()) continue;
let delivered = false, cancelled = false;
try {
cancelled = await adapter.canDeliver(job) !== true;
if (!cancelled) {
// Do not begin an SMTP attempt near the end of this lease.
if (job.leaseUntil * 1000 - now() < 12000) continue;
delivered = (await adapter.deliver(job))?.delivered === true;
}
} catch {
console.error('Delivery unconfirmed; inspect the SMTP receipt and server log before retrying', job.jobId);
}
try {
await backend.ack({jobId: job.jobId, lease: job.lease, delivered, cancelled});
} catch {
// Reclaim reads the receipt. Accepted sends are acknowledged without another SMTP submission.
console.error('Acknowledgement pending', job.jobId);
}
}
}
export async function run() {
const backend = createBackend();
let stopping = false;
process.on('SIGINT', () => { stopping = true; });
process.on('SIGTERM', () => { stopping = true; });
do {
try { await deliverBatch(backend); }
catch { console.error('Worker request failed; retrying in five seconds'); }
if (process.argv.includes('--once') || stopping) break;
await sleep(5000);
} while (!stopping);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await run();
Complete SMTP adapter
Download provider-adapter.mjs ↓// Single-process SMTP adapter. Replace the contact file with your user database.
import {readFile, writeFile, mkdir, rename} from 'node:fs/promises';
import {createHash} from 'node:crypto';
import nodemailer from 'nodemailer';
async function contact(job) {
const contacts = JSON.parse(await readFile(process.env.DUALKIND_CONTACTS_FILE || './contacts.json', 'utf8'));
return contacts[job.userId];
}
export async function canDeliver(job) {
const user = await contact(job);
return job.channel === 'email' && job.template === 'come_back' &&
user?.emailConsent === true && user?.suppressed === false && typeof user.email === 'string';
}
export function smtpOptions() {
const host = process.env.SMTP_HOST;
const mode = process.env.SMTP_TLS || 'starttls';
const port = Number(process.env.SMTP_PORT || (mode === 'tls' ? 465 : 587));
if (!host || !['starttls', 'tls', 'none'].includes(mode) || !Number.isInteger(port) || port < 1 || port > 65535)
throw Error('Configure SMTP_HOST, SMTP_PORT and SMTP_TLS (starttls or tls)');
if (mode === 'none' && !['localhost', '127.0.0.1', '::1'].includes(host))
throw Error('Unencrypted SMTP is only allowed for a local relay');
const user = process.env.SMTP_USERNAME, pass = process.env.SMTP_PASSWORD;
if (Boolean(user) !== Boolean(pass)) throw Error('Supply both SMTP_USERNAME and SMTP_PASSWORD, or neither for a trusted relay');
return {host, port, secure: mode === 'tls', requireTLS: mode === 'starttls', ignoreTLS: mode === 'none',
...(user ? {auth: {user, pass}} : {}), connectionTimeout: 5000, greetingTimeout: 5000,
socketTimeout: 5000, dnsTimeout: 5000, disableFileAccess: true, disableUrlAccess: true};
}
export async function deliver(job) {
const user = await contact(job);
if (!(job.channel === 'email' && job.template === 'come_back' && user?.emailConsent === true &&
user?.suppressed === false && typeof user.email === 'string')) throw Error('Permission or suppression changed');
const directory = process.env.DUALKIND_DELIVERY_STORE || './delivery-receipts';
await mkdir(directory, {recursive: true, mode: 0o700});
const key = createHash('sha256').update(job.idempotencyKey).digest('hex');
const file = `${directory}/${key}.json`;
let saved;
try { saved = JSON.parse(await readFile(file, 'utf8')); }
catch (error) { if (error.code !== 'ENOENT') throw error; }
if (saved?.accepted) return {delivered: true};
// SMTP has no idempotency-key contract. Message-ID alone does not prevent duplicates.
// A prior attempt without a receipt needs reconciliation against the mail server log.
if (saved) throw Error('SMTP attempt needs manual reconciliation; not sending again');
const options = smtpOptions();
const from = process.env.MAIL_FROM;
if (!from || /[\r\n]/.test(from)) throw Error('Configure MAIL_FROM');
saved = {started: Date.now(), messageId: `<${key}@dualkind-worker.invalid>`,
body: {from, to: {address: user.email}, subject: 'Continue where you left off',
text: 'Your saved items are waiting. Return whenever you are ready.'}};
// Write before contacting SMTP; reclaiming a job must never blindly submit it again.
await writeFile(file, JSON.stringify(saved), {flag: 'wx', mode: 0o600});
const transport = nodemailer.createTransport(options);
let timer;
try {
const result = await Promise.race([
transport.sendMail({...saved.body, messageId: saved.messageId}),
new Promise((_, reject) => { timer = setTimeout(() => { transport.close(); reject(Error('SMTP deadline exceeded')); }, 10000); })
]);
if (!result.accepted?.length || result.rejected?.length) throw Error('SMTP did not accept the recipient');
await writeFile(`${file}.tmp`, JSON.stringify({...saved, accepted: true, response: result.response}), {mode: 0o600});
await rename(`${file}.tmp`, file);
return {delivered: true};
} finally { clearTimeout(timer); transport.close(); }
}
Complete authenticated API client
Download backend.mjs ↓// Node.js 20+. Keep this module and server credentials on your backend.
export function createBackend({projectKey = process.env.DUALKIND_PROJECT_KEY,
serverKey = process.env.DUALKIND_SERVER_KEY,
baseUrl = process.env.DUALKIND_BASE_URL || 'https://dualkind.com',
fetchImpl = fetch} = {}) {
if (!projectKey || !serverKey) throw Error('Set DUALKIND_PROJECT_KEY and DUALKIND_SERVER_KEY');
const url = new URL(baseUrl);
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) throw Error('Use HTTPS or localhost');
let runId;
async function request(operation, data) {
const response = await fetchImpl(`${baseUrl.replace(/\/$/, '')}/api/v1/${operation}`, {
method: 'POST', headers: {'Content-Type': 'application/json', Authorization: `Bearer ${serverKey}`},
body: JSON.stringify({...data, projectKey}), signal: AbortSignal.timeout(15000)
});
const result = await response.json();
if (!response.ok) throw Error(result.error || `HTTP ${response.status}`);
return result;
}
async function send(operation, data = {}) {
if (!runId) runId = (await request('runs', {mode: 'backend'})).runId;
return request(operation, {...data, runId});
}
return {identify: data => send('identify', data), record: data => send('event', data),
claim: () => send('claim'), ack: data => send('ack', data)};
}
Transport reference: SMTP configuration. For multiple worker processes, use a shared transactional delivery ledger in place of local receipt files.
Give learning a fair chance
- Start with distinct, useful choices. Two or three credible alternatives give each option more evidence than hundreds of tiny variations. Multiple variables multiply the possible combinations.
- Validate the event path first. Check the dashboard after a real interaction, then confirm exposure and the eventual outcome. Missing events teach the wrong lesson.
- Wait for the right window. A D3 objective cannot report a completed result on day one. Recurring results count cohorts, not unique people.
- Keep traffic and outcomes representative. Separate synthetic tests from your real project. Do not optimize a click that harms completion or long-term retention.
- Change deliberately. Leave time to observe outcomes, then make a clear change. Constant configuration revisions prevent prospective measurement windows from finishing.
- Use constraints and guardrails for real trade-offs. Permit only settings your product can support, and protect important outcomes independently of the reward mix.
More learning does not mean 100% conversion.Exploration still tries alternatives; visitors differ, outcomes are noisy, and some people will not convert under any configuration. The random baseline is not automatically 1 divided by the number of outcomes. It depends on how often each allowed choice succeeds.
Measure improvement. Protect what matters.
Open your project’s Analytics tab to select an objective, decision and date range. The trend follows when outcome windows began; pending windows stay separate from completed results. Explore patterns compares setting values or observed signals, with sample counts and separate adaptive and holdout populations. These associations can suggest what to investigate, but do not prove that a setting caused the outcome. Historical revisions stay separate, and changes to an objective definition are not pooled together.
The randomized holdout compares the combined adaptive experience with random choices. It does not isolate each decision’s causal contribution. Measurement starts prospectively with new first-seen users, and configuration changes start a new measurement period.
The 95% range accounts for repeated looks, configured objectives and successive periods under the documented randomization assumptions. A positive difference alone does not establish improvement: the entire range must be above zero. If it crosses zero, evidence is still inconclusive. There is no universal number of visits that guarantees significance.
Revenue measurement uses a capped score and an explicit refund horizon, separate from uncapped operational revenue. Refunds after that cutoff still correct operational net revenue and learning, but do not rewrite the frozen comparison. Formal measurement currently supports first-seen objectives; recurring cohorts remain descriptive.
In the retention sample, maximumDrop: 0.02 protects D3 against a drop greater than two percentage points. Only when the upper effect bound is below that limit and each group has at least minimumUsers does the guardrail pause adaptive choices. Settings fall back to authored defaults and pending adaptive actions are cancelled. The pause stays latched until an explicit configuration change; it is not a promise that every user’s outcome is safe or that already-rendered UI can be instantly revoked.
Keep configuration in your repository
The Dualkind CLI is written in Abla. Download the Linux x86-64 package, extract it and put its directory on your PATH. The package includes its runtime libraries; no Node.js or private repository access is needed.
Download CLI for Linux x86-64 ↓curl -fsS https://dualkind.com/downloads/dualkind-linux-x86_64.tar.gz -o dualkind.tar.gz
tar -xzf dualkind.tar.gz
export PATH="$PWD/dualkind-cli:$PATH"
dualkind --help
dualkind login opens your browser for account authorization and receives a one-time code on a temporary loopback port. Your credential stays in your user configuration directory, scoped to the deployment, and expires after 30 days. dualkind logout revokes it. Use --no-browser to open the printed URL yourself on the same computer.
dualkind login
dualkind projects
dualkind config pull --project PROJECT_ID --file dualkind.config.json
# Edit the JSON, then apply it with the recorded revision.
dualkind config push --project PROJECT_ID --file dualkind.config.json
# config export/import are aliases for pull/push.
dualkind logout
Pull saves JSON and an adjacent .sync.json revision file. Keep both with your project. Push rejects a stale revision instead of overwriting another edit. It uses your account permissions, so you do not need to copy a project server key into your terminal. Use --base-url http://127.0.0.1:8080 only for an alternate local deployment; each deployment has separate credentials.
Compatible changes can retain learning, including replaying recorded objective evidence with new weights and targets. New options begin without their own observations. Removing an option removes its credited evidence; reintroducing it does not resurrect that credit. Changing objective definitions or explicitly resetting creates a boundary. Older models without a complete evidence ledger require a reset for reweighting. A measurement period can restart while compatible learner evidence is retained.
When something looks wrong
- Only defaults appear
- Check consent, the named decision, configured inputs and connectivity. A missing recurring anchor or a paused guardrail also returns a fallback. Read the response’s
reason; before consent, empty values mean you should keep your baseline UI. - Events arrive but learning does not move
- Confirm the decision was actually exposed, IDs match across frontend and backend, and the objective window has ended. Control observations do not train the adaptive learner.
- A nudge never arrives
- Check channel permissions, cancellation events, quiet hours, frequency limits, cohort expiry and guardrails. Confirm the background worker and application delivery adapter are running.
- Saving fails
- The floating save bar shows the error. Check lowercase identifiers, matching anchors, valid targets and refund horizons. For a stale revision, pull or reload before applying your edits.
Configure your project ↗