@runbandit/sdk 2.0.1 → 2.1.0

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 {
@@ -382,33 +282,14 @@ interface BundleAssignmentResponse {
382
282
  bundleAssignmentId: string;
383
283
  slots: Record<string, BundleSlotAssignment>;
384
284
  }
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
285
  interface SnapshotTreatment {
393
286
  id: string;
394
287
  name?: string;
395
288
  config: unknown;
396
- /** Probability the next visitor is assigned this arm. Sums to ~1 per experiment. */
397
289
  pct: number;
398
290
  }
399
291
  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
292
  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
293
  assignmentTtl: number;
413
294
  treatments: SnapshotTreatment[];
414
295
  }
@@ -427,118 +308,21 @@ declare function calculateConfidenceInterval(successes: number, trials: number,
427
308
  };
428
309
  declare function isValidEmail(email: string): boolean;
429
310
  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
311
  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
312
  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
313
  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
314
  declare function buildSubdomainUrl(subdomain: string, baseDomain: string, path?: string, protocol?: string): string;
458
315
 
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
316
  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
317
  interface SigningKey {
503
318
  version: number;
504
319
  secret: string;
505
320
  }
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
321
  interface EmailTokenPayload {
512
- /** Campaign id. */
513
322
  c: string;
514
- /** Pseudonymous recipient - see `hashRecipient`. Never a raw identifier. */
515
323
  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
324
  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
325
  l: number;
541
- /** Signing key version. Set by `mintEmailToken`; do not pass it in. */
542
326
  v?: number;
543
327
  }
544
328
  type VerifiedToken = {
@@ -548,210 +332,43 @@ type VerifiedToken = {
548
332
  valid: false;
549
333
  reason: 'malformed' | 'unknown_key' | 'bad_signature';
550
334
  };
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
335
  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
336
  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
337
  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
338
  declare function appendEmailToken(url: string, token: string): string;
602
339
 
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
340
  interface PlanArm {
631
341
  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
342
  armIndex: number;
640
343
  }
641
344
  interface PlanSlot {
642
345
  experimentId: string;
643
- /** Slot name, for substituting into the template. */
644
346
  name: string;
645
347
  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
348
  probabilities: number[];
655
- /** Arm content, positionally aligned with `arms`. */
656
349
  configs: unknown[];
657
350
  }
658
351
  interface EmailPlan {
659
352
  campaignId: string;
660
353
  slots: PlanSlot[];
661
354
  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
355
  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
356
  signingKey: {
678
357
  version: number;
679
358
  secret: string;
680
359
  };
681
- /** Re-request after this many recipients to pick up what the send has learned. */
682
360
  refreshEvery: number;
683
- /** When this plan stops being usable. */
684
361
  expiresAt: number;
685
362
  }
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
363
  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
364
  declare function pickArm(probabilities: number[], uniform: number): number;
709
- /** The arm each slot assigns this recipient, positionally by slot. */
710
365
  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
366
  declare function impressionManifest(plan: EmailPlan, recipientIds: string[]): Promise<Array<{
718
367
  experimentId: string;
719
368
  treatmentId: string;
720
369
  count: number;
721
370
  }>>;
722
371
 
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
372
  interface SlotBinding {
756
373
  $slot: string;
757
374
  }
@@ -795,28 +412,18 @@ interface SpacerBlock {
795
412
  }
796
413
  type EmailBlock = HeadingBlock | TextBlock | ButtonBlock | ImageBlock | DividerBlock | SpacerBlock;
797
414
  interface EmailDocument {
798
- /** Subject line - a slot like any other, and usually the highest-leverage one. */
799
415
  subject: Bound<string>;
800
416
  preheader?: Bound<string>;
801
417
  blocks: EmailBlock[];
802
418
  backgroundColor?: string;
803
419
  contentWidth?: number;
804
420
  }
805
- /** Every slot name the document refers to, in document order, de-duplicated. */
806
421
  declare function extractDocumentSlots(document: EmailDocument): string[];
807
422
  interface RenderedDocument {
808
423
  subject: string;
809
424
  html: string;
810
- /** Slots the document referenced that no value was supplied for. */
811
425
  missingSlots: string[];
812
426
  }
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
427
  declare function renderEmailDocument(document: EmailDocument, slots: Record<string, string>): RenderedDocument;
821
428
 
822
429
  interface BanditsClientConfig {
@@ -841,11 +448,6 @@ declare class BanditsClient {
841
448
  private flushTimer?;
842
449
  constructor(config: BanditsClientConfig);
843
450
  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
451
  trackNamedEvent(event: {
850
452
  name: string;
851
453
  category?: string;
@@ -855,38 +457,12 @@ declare class BanditsClient {
855
457
  immediate?: boolean;
856
458
  }): void;
857
459
  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
460
  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
- */
461
+ label(userId: string, labels: Record<string, string | boolean>): Promise<void>;
882
462
  trackFunnelReward(funnelId: string, userId: string, value: number): Promise<void>;
883
463
  getBundle(bundleId: string, userId: string, context?: ContextualFeatures): Promise<BundleAssignmentResponse>;
884
464
  trackBundleReward(bundleId: string, userId: string, value: number): Promise<void>;
885
465
  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
466
  private normalizeApiUrl;
891
467
  private getOrCreateSessionId;
892
468
  private makeRequest;
@@ -903,28 +479,12 @@ interface SyncAssignment {
903
479
  interface SnapshotClientConfig {
904
480
  apiUrl: string;
905
481
  publishableKey: string;
906
- /** Defaults to window.localStorage. Injectable for tests and SSR. */
907
482
  storage?: Storage;
908
- /** Injectable clock. Defaults to Date.now. */
909
483
  now?: () => number;
910
- /** Session lifetime in ms. Must match the BanditsClient using the same storage. */
911
484
  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
485
  inlineSnapshot?: CompanySnapshot;
918
486
  fetchImpl?: typeof fetch;
919
487
  }
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
488
  declare function pickTreatment(treatments: SnapshotTreatment[], draw: number): SnapshotTreatment | null;
929
489
  declare class SnapshotClient {
930
490
  private config;
@@ -936,203 +496,63 @@ declare class SnapshotClient {
936
496
  private sessionId;
937
497
  private inFlight;
938
498
  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
499
  getAssignmentSync(experimentId: string, userId: string): SyncAssignment | null;
950
- /** Fetch a fresh snapshot and cache it. Safe to call on every pageview. */
951
500
  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
501
  flush(): Promise<void>;
961
502
  private doFlush;
962
- /** The session id these reports are attributed under. */
963
503
  getSessionId(): string;
964
504
  pendingReportCount(): number;
965
- /** The cached snapshot, or null when absent or past its ttl. */
966
505
  private freshSnapshot;
967
506
  private readSnapshot;
968
507
  private persistSnapshot;
969
508
  private readQueue;
970
- /** Stored decisions, pruned of expired entries. */
971
509
  private readDecisions;
972
510
  private writeDecisions;
973
511
  private persistQueue;
974
512
  }
975
513
 
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
514
  declare const SESSION_ID_KEY = "bandits_session_id";
985
515
  declare const SESSION_CREATED_AT_KEY = "bandits_session_created_at";
986
- /** 24 hours, in milliseconds. */
987
516
  declare const DEFAULT_SESSION_TTL_MS = 86400000;
988
517
  interface SessionOptions {
989
- /** Where the session lives. Null (no browser storage) means ephemeral. */
990
518
  storage?: Storage | null;
991
- /** Injectable clock, in milliseconds. */
992
519
  now?: () => number;
993
520
  ttlMs?: number;
994
521
  }
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
522
  declare function getOrCreateSessionId(options?: SessionOptions): string;
1000
523
 
1001
524
  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
525
  linkTable: string[];
1008
- /** One token per link table entry, positionally aligned. */
1009
526
  tokens: string[];
1010
527
  }
1011
528
  interface RewriteResult {
1012
529
  html: string;
1013
- /** How many hrefs were tokenised. */
1014
530
  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
531
  untracked: string[];
1024
532
  }
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
533
  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
534
  declare function rewriteEmailLinks(html: string, options: RewriteOptions): RewriteResult;
1045
535
 
1046
536
  interface RenderEmailOptions {
1047
- /** Chosen content per slot, keyed by slot name, from /api/email/render. */
1048
537
  slots: Record<string, string>;
1049
- /** The campaign's ordered destination URLs. */
1050
538
  linkTable: string[];
1051
- /** One token per link table entry, minted for this recipient. */
1052
539
  tokens: string[];
1053
540
  }
1054
541
  interface RenderedEmail {
1055
542
  html: string;
1056
- /** How many links were tokenised. */
1057
543
  rewritten: number;
1058
- /** Trackable links not present in the campaign's link table. */
1059
544
  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
545
  missingSlots: string[];
1067
546
  }
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
547
  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
548
  declare function extractSlotNames(templateHtml: string): string[];
1084
549
 
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
550
  interface PlanRenderResult extends RenderedEmail {
1098
- /** Arm chosen per slot, positionally against the plan's slots. */
1099
551
  combination: number[];
1100
- /** The token minted for each link, positionally against the link table. */
1101
552
  tokens: string[];
1102
553
  }
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
554
  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
555
  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
556
  declare function reportPlanImpressions(plan: EmailPlan, recipientIds: string[], options: {
1137
557
  apiUrl: string;
1138
558
  apiKey: string;