@runbandit/sdk 2.0.1 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -65,14 +65,6 @@ interface Experiment {
65
65
  endAt?: Date;
66
66
  placement?: Placement;
67
67
  }
68
- /**
69
- * Storefront slots a theme app extension block can occupy.
70
- *
71
- * Closed set: theme block settings are a static schema compiled into the
72
- * extension, so a placement cannot be added without shipping a new extension
73
- * version. Keep in sync with the `placement` select in
74
- * extensions/bandit-storefront/blocks/bandit_text.liquid.
75
- */
76
68
  declare const PLACEMENTS: readonly ["hero_headline", "hero_subhead", "cta_primary", "banner_text", "badge_text"];
77
69
  type Placement = (typeof PLACEMENTS)[number];
78
70
  interface Treatment {
@@ -87,46 +79,13 @@ interface Treatment {
87
79
  }
88
80
  interface AlgorithmResult {
89
81
  treatmentId: string;
90
- /**
91
- * @deprecated Per-algorithm, not comparable across algorithms, and about to
92
- * be redefined in a future major version. Historically a single raw
93
- * posterior draw for the selected arm (e.g. one Beta or LinUCB-squash
94
- * sample) - in [0,1], but a *volatile* one: it is a single sample, not an
95
- * estimate, so it swings arm-to-arm and request-to-request even when the
96
- * underlying posteriors haven't moved. Kept for backward compatibility with
97
- * documented example values (`api/assignments/route.doc.ts`,
98
- * `src/lib/api-reference.ts`, `specs/assignments.md`). New consumers should
99
- * use `probabilityBest` instead. See
100
- * `docs/plans/2026-09-14-gaussian-thompson-migration-plan.md` task 4 for
101
- * why this is an additive field rather than an in-place redefinition.
102
- */
103
82
  confidence: number;
104
- /**
105
- * P(this arm is the best arm), estimated by Monte Carlo sampling of the
106
- * arms' posteriors many times and tallying wins (see
107
- * `MultiArmBandit.getSelectionProbabilities()`). In [0,1], sums to ~1
108
- * across an experiment's arms, and is far less volatile than `confidence`
109
- * since it averages over many draws instead of reporting one.
110
- *
111
- * Not every algorithm can produce this cheaply on the assignment hot path:
112
- * - `ThompsonSampling` populates it (reduced sample count - see comment at
113
- * the call site for the cost tradeoff).
114
- * - `ContinuousThompson` and `FunnelBandit` support Monte Carlo estimation
115
- * via their own `getSelectionProbabilities()` / repeated `selectArm()`
116
- * calls, but neither is wired into `probabilityBest` on the live
117
- * assignment path (todo - see migration plan task 7 rollout).
118
- * - `LinearContextualBandit` does not populate this field at all: LinUCB is
119
- * not a posterior sampler, so there is nothing to Monte Carlo over. The
120
- * field is simply absent (`undefined`), not a stand-in zero.
121
- */
122
83
  probabilityBest?: number;
123
84
  }
124
85
  interface BanditResult {
125
86
  assignmentId: string;
126
87
  treatmentId: string;
127
- /** @deprecated see `AlgorithmResult.confidence`. */
128
88
  confidence: number;
129
- /** See `AlgorithmResult.probabilityBest`. */
130
89
  probabilityBest?: number;
131
90
  metadata?: Record<string, any>;
132
91
  }
@@ -154,55 +113,15 @@ interface VariantConfig {
154
113
  content: string;
155
114
  metadata?: TreatmentMetadata & Record<string, any>;
156
115
  }
157
- /**
158
- * Bandit algorithm families.
159
- *
160
- * Every family is Thompson-style: a Bayesian posterior per arm, sampled at
161
- * selection time. How much of the past that posterior remembers is controlled
162
- * separately, by `ForgettingMode`.
163
- */
164
116
  declare enum BanditAlgorithm {
165
117
  THOMPSON_SAMPLING = "thompson_sampling",
166
118
  CONTEXTUAL_LINEAR = "contextual_linear"
167
119
  }
168
- /**
169
- * Which posterior a Thompson-family experiment samples from.
170
- *
171
- * See the migration plan (docs/plans/2026-09-14-gaussian-thompson-migration-plan.md,
172
- * task 7): this is a per-experiment rollout flag, not a replacement of
173
- * `BanditAlgorithm`. `BETA` is the existing Beta-Binomial posterior
174
- * (`ThompsonSampling`); `NORMAL_GAMMA` is the extracted Normal-Gamma one
175
- * (`ContinuousThompson`). Missing/undefined means `BETA` - every experiment
176
- * written before this field existed must keep selecting exactly as it does
177
- * today.
178
- */
179
120
  declare enum Posterior {
180
121
  BETA = "beta",
181
122
  NORMAL_GAMMA = "normal_gamma"
182
123
  }
183
- /**
184
- * Map a stored posterior string onto a supported one, defaulting missing or
185
- * unrecognised values to `BETA` - the behaviour every existing experiment
186
- * already has. Only `create` (new experiments) or an explicit switch should
187
- * ever produce `NORMAL_GAMMA`.
188
- */
189
124
  declare function normalizePosterior(posterior: string | null | undefined): Posterior;
190
- /**
191
- * How an algorithm forgets old observations.
192
- *
193
- * Standard bandits assume the arms are stationary: a conversion from six months
194
- * ago counts exactly as much as one from this morning. Real treatments are
195
- * restless - creative fatigues, seasons turn, traffic mix shifts - so the two
196
- * non-stationary modes below down-weight or drop stale evidence.
197
- *
198
- * - NONE stationary; every observation counts forever
199
- * - DISCOUNTED observation weight decays as 0.5^(age / halfLifeMs)
200
- * - SLIDING_WINDOW observations outside the last `windowMs` are dropped entirely
201
- *
202
- * Both non-stationary modes measure age in wall-clock time rather than in
203
- * observation count, so a quiet experiment forgets at the same rate as a busy
204
- * one.
205
- */
206
125
  declare enum ForgettingMode {
207
126
  NONE = "none",
208
127
  DISCOUNTED = "discounted",
@@ -210,31 +129,13 @@ declare enum ForgettingMode {
210
129
  }
211
130
  interface ForgettingConfig {
212
131
  mode: ForgettingMode;
213
- /** Half-life in milliseconds. DISCOUNTED only. */
214
132
  halfLifeMs?: number;
215
- /** Window length in milliseconds. SLIDING_WINDOW only. */
216
133
  windowMs?: number;
217
134
  }
218
- /** Default half-life for DISCOUNTED: one week. */
219
135
  declare const DEFAULT_HALF_LIFE_MS: number;
220
- /** Default window for SLIDING_WINDOW: two weeks. */
221
136
  declare const DEFAULT_WINDOW_MS: number;
222
- /** The stationary default applied when an experiment has no forgetting config. */
223
137
  declare const STATIONARY: ForgettingConfig;
224
- /**
225
- * Fill in the defaults for a partially-specified forgetting config.
226
- * An unrecognised or missing mode degrades to NONE rather than throwing, so a
227
- * stale row written by an older deploy still selects treatments.
228
- */
229
138
  declare function resolveForgetting(config?: Partial<ForgettingConfig> | null): ForgettingConfig;
230
- /**
231
- * Map a stored algorithm string onto a supported algorithm.
232
- *
233
- * Retired names (epsilon_greedy, ucb1) map onto standard Thompson Sampling,
234
- * which reads the same successes/trials counters they did, so a running
235
- * experiment keeps its learned history. contextual_neural maps onto
236
- * contextual_linear, which is what it already fell back to at runtime.
237
- */
238
139
  declare function normalizeAlgorithm(algorithm: string | null | undefined): BanditAlgorithm;
239
140
  declare enum ContentType {
240
141
  PLAIN_TEXT = "plain_text",
@@ -285,7 +186,6 @@ interface AssignmentRequest {
285
186
  userId: string;
286
187
  context?: ContextualFeatures;
287
188
  sessionId?: string;
288
- /** Browser page the assignment was requested from (origin + pathname). */
289
189
  pageUrl?: string;
290
190
  }
291
191
  interface TrackNamedEventRequest {
@@ -306,7 +206,8 @@ interface Funnel {
306
206
  name: string;
307
207
  description?: string;
308
208
  status: FunnelStatus;
309
- conversionWindowMs: number;
209
+ inactivityTimeoutMs?: number;
210
+ revisionWindowMs?: number;
310
211
  companyId: string;
311
212
  createdAt: Date;
312
213
  updatedAt: Date;
@@ -361,6 +262,8 @@ interface Bundle {
361
262
  funnelId?: string;
362
263
  assignmentTtl: number;
363
264
  conversionWindowMs: number;
265
+ inactivityTimeoutMs?: number;
266
+ revisionWindowMs?: number;
364
267
  companyId: string;
365
268
  createdAt: Date;
366
269
  updatedAt: Date;
@@ -382,33 +285,14 @@ interface BundleAssignmentResponse {
382
285
  bundleAssignmentId: string;
383
286
  slots: Record<string, BundleSlotAssignment>;
384
287
  }
385
- /**
386
- * One treatment's published allocation within a snapshot experiment.
387
- *
388
- * This is the wire contract between the snapshot publisher (dashboard API)
389
- * and its only consumer (the SDK). Declared once here so both sides share
390
- * the same shape instead of drifting apart.
391
- */
392
288
  interface SnapshotTreatment {
393
289
  id: string;
394
290
  name?: string;
395
291
  config: unknown;
396
- /** Probability the next visitor is assigned this arm. Sums to ~1 per experiment. */
397
292
  pct: number;
398
293
  }
399
294
  interface SnapshotExperiment {
400
- /**
401
- * 'snapshot' means the client may decide locally from `pct`. 'network' means
402
- * it must call /api/assignments, because the experiment's state cannot be
403
- * reduced to a static distribution.
404
- */
405
295
  mode: 'snapshot' | 'network';
406
- /**
407
- * Assignment lifetime in SECONDS (the unit `assignments.expiresAt` uses).
408
- * The client holds a decision this long so that repeated pageviews in one
409
- * session report one impression, matching the server path's one assignment
410
- * per (experiment, user).
411
- */
412
296
  assignmentTtl: number;
413
297
  treatments: SnapshotTreatment[];
414
298
  }
@@ -427,118 +311,21 @@ declare function calculateConfidenceInterval(successes: number, trials: number,
427
311
  };
428
312
  declare function isValidEmail(email: string): boolean;
429
313
  declare function sanitizeMetadata(metadata: Record<string, any>): Record<string, any>;
430
- /**
431
- * Generates a URL-safe subdomain from a company name
432
- * @param name Company name
433
- * @returns URL-safe subdomain
434
- */
435
314
  declare function generateSubdomain(name: string): string;
436
- /**
437
- * Validates if a subdomain is properly formatted
438
- * @param subdomain Subdomain to validate
439
- * @returns true if valid, false otherwise
440
- */
441
315
  declare function isValidSubdomain(subdomain: string): boolean;
442
- /**
443
- * Extracts subdomain from hostname
444
- * @param hostname Full hostname (e.g., 'acme.example.com')
445
- * @param baseDomain Base domain (e.g., 'example.com')
446
- * @returns Subdomain or null if not found
447
- */
448
316
  declare function extractSubdomain(hostname: string, baseDomain: string): string | null;
449
- /**
450
- * Builds full URL with subdomain
451
- * @param subdomain Subdomain
452
- * @param baseDomain Base domain
453
- * @param path Optional path
454
- * @param protocol Protocol (default: https)
455
- * @returns Full URL
456
- */
457
317
  declare function buildSubdomainUrl(subdomain: string, baseDomain: string, path?: string, protocol?: string): string;
458
318
 
459
- /**
460
- * The email tracking token.
461
- *
462
- * Every tracked link in an email carries one of these as a query parameter.
463
- * It is the impression record: which campaign, which recipient, which arm was
464
- * chosen in every slot, and which link was clicked - all of it self-contained
465
- * and signed, so no row has to be written at send time.
466
- *
467
- * Three constraints shape the format, and all three are unusual:
468
- *
469
- * 1. **It must be mintable offline.** In `plan` mode the customer's own SDK
470
- * mints tokens for millions of recipients with no network calls, so the
471
- * token cannot be a database id we hand out. Minting is a pure function of
472
- * (payload, key).
473
- *
474
- * 2. **It must outlive everything else in the system.** A link minted today
475
- * may be clicked in three years. That makes this format a public contract
476
- * with no deprecation path: campaign link tables are never deleted, slot
477
- * order is never changed, and several signing keys stay valid at once so a
478
- * rotation cannot break links already sitting in inboxes.
479
- *
480
- * 3. **It travels somewhere hostile.** URLs get forwarded, logged by proxies
481
- * and leaked through `Referer`. Nothing in here may be a secret, and
482
- * nothing in here may be trusted before the signature is checked.
483
- *
484
- * See specs/email.md, "Tokens".
485
- */
486
- /**
487
- * The query parameter tracked links are tagged with.
488
- *
489
- * Kept distinct from the well-known ad-tech parameters (`fbclid`, `gclid`,
490
- * `utm_*`): Safari's Link Tracking Protection and Firefox's strict ETP strip
491
- * params from a curated list of known trackers, and sharing a name with one of
492
- * them would mean being stripped by association.
493
- */
494
319
  declare const EMAIL_TOKEN_PARAM = "bt";
495
- /**
496
- * One signing key. Several are valid concurrently - see `verifyEmailToken`.
497
- *
498
- * `version` is carried in the token so verification can select the right key
499
- * directly instead of trying each in turn, which would leak timing information
500
- * and get slower with every rotation.
501
- */
502
320
  interface SigningKey {
503
321
  version: number;
504
322
  secret: string;
505
323
  }
506
- /**
507
- * The signed body of a token. Field names are single characters because this
508
- * is base64'd into a URL that also has to survive an email client's line
509
- * wrapping - the verbosity is not worth the bytes.
510
- */
511
324
  interface EmailTokenPayload {
512
- /** Campaign id. */
513
325
  c: string;
514
- /** Pseudonymous recipient - see `hashRecipient`. Never a raw identifier. */
515
326
  r: string;
516
- /**
517
- * The arm chosen in each slot, POSITIONALLY: `k[i]` is the arm index chosen
518
- * for the campaign's `slots[i]`.
519
- *
520
- * Positional rather than a `{ slotId: armId }` map because ids are ~32
521
- * characters each and a six-slot email would spend over 200 characters
522
- * restating what the campaign already knows. The cost is that a campaign's
523
- * slot order becomes load-bearing forever, which is why reordering slots
524
- * after a send is rejected at the campaign level.
525
- *
526
- * The whole combination is here, not just the clicked slot, because a click
527
- * has to credit every slot the recipient saw - and because in `plan` mode
528
- * this token IS the impression record, reconstructable from a first click
529
- * even if the send-time manifest never arrived.
530
- */
531
327
  k: number[];
532
- /**
533
- * Index into the campaign's link table - never a URL.
534
- *
535
- * A token carrying its own destination would be an open redirect the moment
536
- * the signature check had a bug, and would make every token long enough to
537
- * matter. An index can only ever address a link the campaign already
538
- * declared.
539
- */
540
328
  l: number;
541
- /** Signing key version. Set by `mintEmailToken`; do not pass it in. */
542
329
  v?: number;
543
330
  }
544
331
  type VerifiedToken = {
@@ -548,210 +335,43 @@ type VerifiedToken = {
548
335
  valid: false;
549
336
  reason: 'malformed' | 'unknown_key' | 'bad_signature';
550
337
  };
551
- /**
552
- * Pseudonymise a recipient identifier.
553
- *
554
- * Deliberately a one-way HMAC rather than something we can reverse. We do not
555
- * need to: the destination page is the customer's own, and their SDK resolves
556
- * the real identity through `identify()` on arrival. What Bandit needs is a
557
- * stable key to attribute a click and to dedupe first-touch, and a hash serves
558
- * that without ever putting a customer's user ids in a URL that gets
559
- * forwarded, proxy-logged and leaked through `Referer`.
560
- *
561
- * Keyed on a company-level secret rather than a campaign one, so the same
562
- * recipient hashes identically across every campaign that company sends. That
563
- * is what makes cross-campaign frequency and fatigue analysis possible later -
564
- * an unsubscribe is usually the result of the fifth email, not the first.
565
- *
566
- * Truncated to 16 bytes: 128 bits is far beyond collision range for any real
567
- * list, and the 22 characters saved matter in a URL.
568
- */
569
338
  declare function hashRecipient(recipientId: string, companySecret: string): Promise<string>;
570
- /**
571
- * Mint a token. Pure function of (payload, key) - the same inputs always
572
- * produce the same token, which is what lets the SDK mint offline in `plan`
573
- * mode and the server arrive independently at the identical value.
574
- */
575
339
  declare function mintEmailToken(payload: EmailTokenPayload, key: SigningKey): Promise<string>;
576
- /**
577
- * Verify a token against every currently-held key.
578
- *
579
- * Never throws. This is called from a redirect endpoint and a click ingest
580
- * route, both of which take their input straight off a URL - a crash there is
581
- * a broken link in a real person's inbox, and the input is attacker-controlled
582
- * by definition.
583
- *
584
- * Pass every key still in rotation, oldest included. Dropping a key
585
- * permanently breaks every link ever minted under it, and those links live in
586
- * inboxes we cannot reach.
587
- */
588
340
  declare function verifyEmailToken(token: string, keys: SigningKey[]): Promise<VerifiedToken>;
589
- /**
590
- * Append a token to a destination URL.
591
- *
592
- * Appends rather than replaces, which is the whole reason the default tracking
593
- * mode needs no redirect service, no DNS setup and no custom certificate: the
594
- * link still points at the customer's own domain, and their existing Bandit
595
- * SDK reads the parameter on page load. Established practice - every ESP and
596
- * marketer already decorates outbound links this way.
597
- *
598
- * Existing query parameters and fragments are preserved; a fragment must stay
599
- * last or the browser will treat the token as part of it.
600
- */
601
341
  declare function appendEmailToken(url: string, token: string): string;
602
342
 
603
- /**
604
- * `plan` mode - assignment for bulk sends, with no per-recipient round trip.
605
- *
606
- * `resolve` and `batch` both make an HTTP call carrying recipients. That is
607
- * fine to ten thousand and untenable at two million: the customer's send
608
- * pipeline would spend its entire budget waiting on us.
609
- *
610
- * So instead of answering per recipient, we hand over the *policy*. The plan
611
- * carries each slot's arm set and the selection probabilities the bandit's
612
- * posterior currently implies, plus a salt and the campaign's signing key. The
613
- * SDK then derives each recipient's arm locally and mints their token offline -
614
- * no network, perfectly parallel, as fast as the customer's own loop.
615
- *
616
- * Two properties make this work, and both are load-bearing:
617
- *
618
- * **Determinism.** The sampler here is the single implementation. The server
619
- * and the customer's SDK must derive the identical arm for the identical
620
- * recipient, or the impression manifest reports one thing and the tokens say
621
- * another. It is pure, keyed only on (salt, recipientId, slot), and pinned by
622
- * test.
623
- *
624
- * **Staleness is bounded by chunking.** A plan is one snapshot of the
625
- * posterior, so a whole blast served from a single plan is one learning epoch.
626
- * `refreshEvery` lets the caller re-request every N recipients, turning a 2M
627
- * send into a few dozen epochs instead of one - the customer's own dial
628
- * between throughput and adaptivity.
629
- */
630
343
  interface PlanArm {
631
344
  treatmentId: string;
632
- /**
633
- * Position of this arm in its slot's treatment list.
634
- *
635
- * This is what a token carries, so it must be the same index the render and
636
- * click paths resolve against - not this array's position, which is only
637
- * incidentally the same.
638
- */
639
345
  armIndex: number;
640
346
  }
641
347
  interface PlanSlot {
642
348
  experimentId: string;
643
- /** Slot name, for substituting into the template. */
644
349
  name: string;
645
350
  arms: PlanArm[];
646
- /**
647
- * P(each arm is best), positionally aligned with `arms`, summing to ~1.
648
- *
649
- * Thompson sampling's allocation, computed once from the posterior rather
650
- * than re-sampled per recipient. Sampling locally from these probabilities
651
- * is distributionally identical to sampling the posterior directly, which is
652
- * what makes the offline derivation legitimate rather than an approximation.
653
- */
654
351
  probabilities: number[];
655
- /** Arm content, positionally aligned with `arms`. */
656
352
  configs: unknown[];
657
353
  }
658
354
  interface EmailPlan {
659
355
  campaignId: string;
660
356
  slots: PlanSlot[];
661
357
  linkTable: string[];
662
- /**
663
- * Per-plan randomisation. Two campaigns must not assign the same recipient
664
- * the same way merely because the probabilities happen to match, or the
665
- * population splits into permanent cohorts and cross-campaign comparisons
666
- * quietly stop being independent.
667
- */
668
358
  salt: string;
669
- /**
670
- * The campaign's current signing key, so the SDK can mint offline.
671
- *
672
- * This is the customer's own campaign secret, sent to their server - not to
673
- * a browser. It lets them mint tokens for their own campaign, which they
674
- * could already do by calling the click endpoint directly; it does not let
675
- * them touch anyone else's.
676
- */
677
359
  signingKey: {
678
360
  version: number;
679
361
  secret: string;
680
362
  };
681
- /** Re-request after this many recipients to pick up what the send has learned. */
682
363
  refreshEvery: number;
683
- /** When this plan stops being usable. */
684
364
  expiresAt: number;
685
365
  }
686
- /**
687
- * A uniform draw in [0,1) for one recipient and slot.
688
- *
689
- * HMAC rather than a plain hash so the mapping cannot be reproduced by anyone
690
- * without the salt - otherwise a recipient could work out which arm they were
691
- * in, and more importantly a third party could reconstruct the whole
692
- * assignment for a campaign they observed one token from.
693
- *
694
- * Six bytes gives 2^48 distinct values, far finer than any arm allocation
695
- * needs, and avoids the precision loss of stuffing 64 bits into a double.
696
- */
697
366
  declare function uniformFor(salt: string, recipientId: string, slotIndex: number): Promise<number>;
698
- /**
699
- * Pick an arm index from a probability vector given a uniform draw.
700
- *
701
- * Separated from the draw so the selection itself is synchronous and trivially
702
- * testable against hand-written vectors.
703
- *
704
- * Degenerate vectors are handled rather than trusted: probabilities that do
705
- * not quite sum to 1 (Monte Carlo estimates never do) must not leave a
706
- * recipient unassigned, so the last arm absorbs the remainder.
707
- */
708
367
  declare function pickArm(probabilities: number[], uniform: number): number;
709
- /** The arm each slot assigns this recipient, positionally by slot. */
710
368
  declare function sampleCombination(plan: EmailPlan, recipientId: string): Promise<number[]>;
711
- /**
712
- * Tally a plan-mode send into per-arm impression counts.
713
- *
714
- * Aggregated because a send is a burst: the bandit needs a denominator, not a
715
- * row per recipient, and the per-recipient detail already lives in each token.
716
- */
717
369
  declare function impressionManifest(plan: EmailPlan, recipientIds: string[]): Promise<Array<{
718
370
  experimentId: string;
719
371
  treatmentId: string;
720
372
  count: number;
721
373
  }>>;
722
374
 
723
- /**
724
- * Email as a structured document.
725
- *
726
- * The other authoring mode takes the customer's own HTML template and
727
- * substitutes slots into it. This one inverts that: the email is authored in
728
- * Bandit as a block tree, and every block property is a candidate slot. Four
729
- * slots with four options each is sixteen authored pieces and 256 possible
730
- * emails, none of which anyone hand-writes.
731
- *
732
- * This is about AUTHORING, not learning - cross-campaign learning works
733
- * identically either way. What it buys is that a marketer can run a
734
- * multi-armed email without owning an HTML template at all.
735
- *
736
- * ## Why this renderer is hand-written
737
- *
738
- * MJML is the obvious choice and was the original plan. Two things rule it
739
- * out. The dashboard runs on Cloudflare Workers, where MJML's several
740
- * megabytes sit badly against the bundle limit; and the same rendering has to
741
- * be available in `@runbandit/sdk`, which ships with no runtime dependencies
742
- * because customers did not agree to a transitive tree in order to send an
743
- * email. One small renderer serving both beats two that can disagree.
744
- *
745
- * ## Why the output looks like 1999
746
- *
747
- * Because email clients do. Outlook renders through Word, which ignores
748
- * `div`-based layout, flexbox and grid entirely; Gmail strips `<style>` blocks
749
- * in several contexts. Tables with inline styles are not nostalgia, they are
750
- * the only layout primitive that survives everywhere - and a button that needs
751
- * a background colour in Outlook needs a VML rectangle, which is why the
752
- * button block emits a conditional comment.
753
- */
754
- /** A value that comes from a bandit slot rather than being authored directly. */
755
375
  interface SlotBinding {
756
376
  $slot: string;
757
377
  }
@@ -795,28 +415,18 @@ interface SpacerBlock {
795
415
  }
796
416
  type EmailBlock = HeadingBlock | TextBlock | ButtonBlock | ImageBlock | DividerBlock | SpacerBlock;
797
417
  interface EmailDocument {
798
- /** Subject line - a slot like any other, and usually the highest-leverage one. */
799
418
  subject: Bound<string>;
800
419
  preheader?: Bound<string>;
801
420
  blocks: EmailBlock[];
802
421
  backgroundColor?: string;
803
422
  contentWidth?: number;
804
423
  }
805
- /** Every slot name the document refers to, in document order, de-duplicated. */
806
424
  declare function extractDocumentSlots(document: EmailDocument): string[];
807
425
  interface RenderedDocument {
808
426
  subject: string;
809
427
  html: string;
810
- /** Slots the document referenced that no value was supplied for. */
811
428
  missingSlots: string[];
812
429
  }
813
- /**
814
- * Render a document to email-safe HTML.
815
- *
816
- * The output is deliberately a complete document with the table scaffolding
817
- * every client needs, not a fragment: there is no "outer template" the
818
- * customer supplies in this mode, which is the entire point of it.
819
- */
820
430
  declare function renderEmailDocument(document: EmailDocument, slots: Record<string, string>): RenderedDocument;
821
431
 
822
432
  interface BanditsClientConfig {
@@ -833,6 +443,7 @@ interface Assignment {
833
443
  experimentId: string;
834
444
  userId: string;
835
445
  config: any;
446
+ funnelId?: string;
836
447
  }
837
448
  declare class BanditsClient {
838
449
  private config;
@@ -841,11 +452,6 @@ declare class BanditsClient {
841
452
  private flushTimer?;
842
453
  constructor(config: BanditsClientConfig);
843
454
  getAssignment(experimentId: string, userId: string, context?: ContextualFeatures): Promise<Assignment>;
844
- /**
845
- * Track a named event for session-based attribution (recommended)
846
- * Events are matched to bandits by sessionId — no assignmentId needed
847
- * Set immediate: true to fire synchronously (survives navigation)
848
- */
849
455
  trackNamedEvent(event: {
850
456
  name: string;
851
457
  category?: string;
@@ -855,38 +461,12 @@ declare class BanditsClient {
855
461
  immediate?: boolean;
856
462
  }): void;
857
463
  flush(): Promise<void>;
858
- /**
859
- * Link an anonymous user ID to a durable identity (e.g. email, account ID).
860
- * All historical assignments and events for the anonymous ID are re-attributed
861
- * to the identified user. Future calls to getAssignment() with either ID will
862
- * resolve to the same assignment.
863
- *
864
- * Requires admin authentication — the client must be configured with a
865
- * personal access token (PAT) instead of an API key, and companyId must
866
- * be provided. This is a management operation, not an SDK client operation.
867
- *
868
- * @param anonymousId - The temporary/anonymous user ID (e.g. crypto.randomUUID())
869
- * @param identifiedUserId - The permanent user ID (e.g. email, database ID)
870
- * @param companyId - The company ID (required for PAT auth)
871
- * @param mergeReason - Why the identification is happening
872
- */
873
464
  identify(anonymousId: string, identifiedUserId: string, companyId: string, mergeReason?: 'user_signup' | 'email_verification' | 'social_login' | 'account_recovery' | 'manual'): Promise<void>;
874
- /**
875
- * Record a funnel payout event.
876
- * Call this when the terminal conversion happens (e.g., purchase).
877
- *
878
- * @param funnelId - The funnel ID
879
- * @param userId - The user who converted
880
- * @param value - The payout value (e.g., purchase amount in dollars)
881
- */
465
+ label(userId: string, labels: Record<string, string | boolean>): Promise<void>;
882
466
  trackFunnelReward(funnelId: string, userId: string, value: number): Promise<void>;
883
467
  getBundle(bundleId: string, userId: string, context?: ContextualFeatures): Promise<BundleAssignmentResponse>;
884
468
  trackBundleReward(bundleId: string, userId: string, value: number): Promise<void>;
885
469
  private generateId;
886
- /**
887
- * Normalize the apiUrl by adding /api prefix if not present.
888
- * Supports both 'https://runbandit.com' and 'https://runbandit.com/api'.
889
- */
890
470
  private normalizeApiUrl;
891
471
  private getOrCreateSessionId;
892
472
  private makeRequest;
@@ -903,28 +483,12 @@ interface SyncAssignment {
903
483
  interface SnapshotClientConfig {
904
484
  apiUrl: string;
905
485
  publishableKey: string;
906
- /** Defaults to window.localStorage. Injectable for tests and SSR. */
907
486
  storage?: Storage;
908
- /** Injectable clock. Defaults to Date.now. */
909
487
  now?: () => number;
910
- /** Session lifetime in ms. Must match the BanditsClient using the same storage. */
911
488
  sessionTtl?: number;
912
- /**
913
- * A snapshot embedded in the page at render time. When present there is no
914
- * cold start at all - the decision happens with zero network calls, which is
915
- * the whole point of the design.
916
- */
917
489
  inlineSnapshot?: CompanySnapshot;
918
490
  fetchImpl?: typeof fetch;
919
491
  }
920
- /**
921
- * Pick an arm from a published allocation.
922
- *
923
- * One uniform draw against the cumulative distribution. This is equivalent to
924
- * Thompson sampling because `pct` *is* the distribution Thompson sampling
925
- * draws from - the Monte Carlo ran server-side, once, instead of here on every
926
- * pageview.
927
- */
928
492
  declare function pickTreatment(treatments: SnapshotTreatment[], draw: number): SnapshotTreatment | null;
929
493
  declare class SnapshotClient {
930
494
  private config;
@@ -936,203 +500,63 @@ declare class SnapshotClient {
936
500
  private sessionId;
937
501
  private inFlight;
938
502
  constructor(config: SnapshotClientConfig);
939
- /**
940
- * Decide an arm with no network call, or return null.
941
- *
942
- * Null means "this caller must fall back" - a cold cache, an expired one, an
943
- * unknown experiment, or one whose state cannot be served from a static
944
- * document. Returning null rather than the control is deliberate: the caller
945
- * renders control itself, and no impression is reported for it. Reporting a
946
- * forced-control impression would inflate the control arm's trials with
947
- * decisions the bandit never made.
948
- */
949
503
  getAssignmentSync(experimentId: string, userId: string): SyncAssignment | null;
950
- /** Fetch a fresh snapshot and cache it. Safe to call on every pageview. */
951
504
  refresh(): Promise<void>;
952
- /**
953
- * Post queued impressions.
954
- *
955
- * The queue is only cleared on success. A failed flush leaves the reports in
956
- * storage, so a pageview that happened offline is still counted when the
957
- * next one succeeds - the trial really did occur, and dropping it would bias
958
- * the arm's denominator downward.
959
- */
960
505
  flush(): Promise<void>;
961
506
  private doFlush;
962
- /** The session id these reports are attributed under. */
963
507
  getSessionId(): string;
964
508
  pendingReportCount(): number;
965
- /** The cached snapshot, or null when absent or past its ttl. */
966
509
  private freshSnapshot;
967
510
  private readSnapshot;
968
511
  private persistSnapshot;
969
512
  private readQueue;
970
- /** Stored decisions, pruned of expired entries. */
971
513
  private readDecisions;
972
514
  private writeDecisions;
973
515
  private persistQueue;
974
516
  }
975
517
 
976
- /**
977
- * Session identity, shared by every client in this package.
978
- *
979
- * Reward attribution joins `assignments` by `sessionId`, so a decision made by
980
- * `SnapshotClient` and an event tracked by `BanditsClient` must agree on the
981
- * id or the conversion never lands on the impression. One implementation,
982
- * used by both, is the only way that rule stays true.
983
- */
984
518
  declare const SESSION_ID_KEY = "bandits_session_id";
985
519
  declare const SESSION_CREATED_AT_KEY = "bandits_session_created_at";
986
- /** 24 hours, in milliseconds. */
987
520
  declare const DEFAULT_SESSION_TTL_MS = 86400000;
988
521
  interface SessionOptions {
989
- /** Where the session lives. Null (no browser storage) means ephemeral. */
990
522
  storage?: Storage | null;
991
- /** Injectable clock, in milliseconds. */
992
523
  now?: () => number;
993
524
  ttlMs?: number;
994
525
  }
995
- /**
996
- * Return the current session id, minting and persisting a new one when there
997
- * is none or the stored one has aged past `ttlMs`.
998
- */
999
526
  declare function getOrCreateSessionId(options?: SessionOptions): string;
1000
527
 
1001
528
  interface RewriteOptions {
1002
- /**
1003
- * The campaign's ordered destination URLs. A token carries an index into
1004
- * THIS array, not into the order links happen to appear in the document -
1005
- * otherwise moving a paragraph would repoint every token already sent.
1006
- */
1007
529
  linkTable: string[];
1008
- /** One token per link table entry, positionally aligned. */
1009
530
  tokens: string[];
1010
531
  }
1011
532
  interface RewriteResult {
1012
533
  html: string;
1013
- /** How many hrefs were tokenised. */
1014
534
  rewritten: number;
1015
- /**
1016
- * Trackable links found in the document but absent from the link table.
1017
- *
1018
- * Reported rather than guessed at: a link we cannot attribute is left
1019
- * exactly as authored, because a dead link is far worse than an untracked
1020
- * one. Surfacing them is what lets the campaign append the missing entries
1021
- * (appending is always allowed; reordering is not).
1022
- */
1023
535
  untracked: string[];
1024
536
  }
1025
- /**
1026
- * Every trackable destination in the document, in order, de-duplicated.
1027
- *
1028
- * Used at campaign creation to build the link table that tokens index into.
1029
- */
1030
537
  declare function extractEmailLinks(html: string): string[];
1031
- /**
1032
- * Tokenise every trackable link in the document.
1033
- *
1034
- * Tokens are appended, not substituted, so the link still points at the
1035
- * customer's own domain: no redirect service, no DNS record, no certificate,
1036
- * and no shared link-domain reputation to poison. Their landing page's
1037
- * existing Bandit SDK reads the parameter on load.
1038
- *
1039
- * Synchronous and pure: minting is asynchronous (WebCrypto), so the caller
1040
- * mints one token per link table entry first and passes them in. That keeps
1041
- * this function trivially testable and lets a batch send mint once per
1042
- * recipient rather than once per link per parse.
1043
- */
1044
538
  declare function rewriteEmailLinks(html: string, options: RewriteOptions): RewriteResult;
1045
539
 
1046
540
  interface RenderEmailOptions {
1047
- /** Chosen content per slot, keyed by slot name, from /api/email/render. */
1048
541
  slots: Record<string, string>;
1049
- /** The campaign's ordered destination URLs. */
1050
542
  linkTable: string[];
1051
- /** One token per link table entry, minted for this recipient. */
1052
543
  tokens: string[];
1053
544
  }
1054
545
  interface RenderedEmail {
1055
546
  html: string;
1056
- /** How many links were tokenised. */
1057
547
  rewritten: number;
1058
- /** Trackable links not present in the campaign's link table. */
1059
548
  untracked: string[];
1060
- /**
1061
- * Placeholders the template asked for that no slot supplied.
1062
- *
1063
- * Reported rather than left in place as literal `{{bandit.x}}`, which is
1064
- * what a recipient would otherwise read in their inbox.
1065
- */
1066
549
  missingSlots: string[];
1067
550
  }
1068
- /**
1069
- * Substitute slot values, then tokenise every trackable link.
1070
- *
1071
- * Order matters: substitution happens first, so a slot whose value contains a
1072
- * link gets that link tokenised too. Doing it the other way round would leave
1073
- * links from bandit-selected content untracked - exactly the content whose
1074
- * performance we are trying to measure.
1075
- */
1076
551
  declare function renderEmail(templateHtml: string, options: RenderEmailOptions): RenderedEmail;
1077
- /**
1078
- * The slot names a template refers to.
1079
- *
1080
- * Used at authoring time to check a template against a campaign's slots before
1081
- * anything is sent, rather than discovering the mismatch in an inbox.
1082
- */
1083
552
  declare function extractSlotNames(templateHtml: string): string[];
1084
553
 
1085
- /**
1086
- * Offline rendering from a campaign plan.
1087
- *
1088
- * This is what makes a two-million-recipient send possible. The plan is
1089
- * fetched once; every recipient after that is derived locally - arm selection,
1090
- * token minting, slot substitution and link rewriting - with no network call
1091
- * at all. The customer's send loop runs at whatever speed their own ESP client
1092
- * manages, and we are not in it.
1093
- *
1094
- * Nothing here needs to reach us, which is also why the customer's template
1095
- * and merge data never leave their infrastructure.
1096
- */
1097
554
  interface PlanRenderResult extends RenderedEmail {
1098
- /** Arm chosen per slot, positionally against the plan's slots. */
1099
555
  combination: number[];
1100
- /** The token minted for each link, positionally against the link table. */
1101
556
  tokens: string[];
1102
557
  }
1103
- /**
1104
- * Render one recipient's email from a plan.
1105
- *
1106
- * Derivation is deterministic in (salt, recipientId, slot), so re-rendering the
1107
- * same recipient - a retry, a resumed batch, a preview - produces byte-identical
1108
- * output and the same tokens. A send that crashes halfway can simply be run
1109
- * again without double-counting anyone or issuing a second set of links.
1110
- */
1111
558
  declare function renderFromPlan(plan: EmailPlan, recipientId: string, templateHtml: string, companySecret: string): Promise<PlanRenderResult>;
1112
- /**
1113
- * Whether a plan should be refreshed before continuing a send.
1114
- *
1115
- * A plan is one snapshot of the posterior, so serving a whole blast from it
1116
- * makes the entire send a single learning epoch - the bandit cannot react to
1117
- * anything it learns partway through. Refreshing every `refreshEvery`
1118
- * recipients turns a 2M send into a few dozen epochs for a few dozen API
1119
- * calls, which is the trade the customer gets to make rather than us.
1120
- */
1121
559
  declare function shouldRefreshPlan(plan: EmailPlan, recipientsRenderedSincePlan: number, now?: number): boolean;
1122
- /**
1123
- * Post a plan-mode send's impressions so the bandit has a denominator.
1124
- *
1125
- * This is not optional bookkeeping. `plan` mode makes no per-recipient call,
1126
- * so without this the only observations that ever reach the posterior are
1127
- * clicks - every one of them a success - and every arm converges on
1128
- * "everything works" while the dashboard shows excellent numbers measuring
1129
- * nothing. The same omission was caught once already in the `batch` render
1130
- * path; it is easier to make here precisely because nothing else round-trips.
1131
- *
1132
- * Aggregated per arm, so ten thousand recipients across a handful of arms is
1133
- * one small request. `batchKey` makes it idempotent: a retried post must not
1134
- * double the denominator and quietly halve every arm's apparent rate.
1135
- */
1136
560
  declare function reportPlanImpressions(plan: EmailPlan, recipientIds: string[], options: {
1137
561
  apiUrl: string;
1138
562
  apiKey: string;