@runbandit/sdk 2.0.0 → 2.0.1
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/README.md +3 -1
- package/dist/index.d.ts +584 -1
- package/dist/index.js +502 -4
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ npm install @runbandit/sdk
|
|
|
14
14
|
import { BanditsClient } from '@runbandit/sdk'
|
|
15
15
|
|
|
16
16
|
const client = new BanditsClient({
|
|
17
|
-
apiUrl: 'https://
|
|
17
|
+
apiUrl: 'https://runbandit.com',
|
|
18
18
|
apiKey: 'your-api-key',
|
|
19
19
|
})
|
|
20
20
|
|
|
@@ -29,4 +29,6 @@ client.trackNamedEvent({
|
|
|
29
29
|
})
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
**Note:** The SDK automatically adds `/api` to your `apiUrl`, so use the base domain (e.g., `https://runbandit.com`) without the `/api` path. If your `apiUrl` already ends with `/api`, the SDK won't double-prefix it.
|
|
33
|
+
|
|
32
34
|
See the [SDK docs](https://runbandit.com/docs/sdk) for the full API.
|
package/dist/index.d.ts
CHANGED
|
@@ -63,7 +63,18 @@ interface Experiment {
|
|
|
63
63
|
createdAt: Date;
|
|
64
64
|
updatedAt: Date;
|
|
65
65
|
endAt?: Date;
|
|
66
|
+
placement?: Placement;
|
|
66
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
|
+
declare const PLACEMENTS: readonly ["hero_headline", "hero_subhead", "cta_primary", "banner_text", "badge_text"];
|
|
77
|
+
type Placement = (typeof PLACEMENTS)[number];
|
|
67
78
|
interface Treatment {
|
|
68
79
|
id: string;
|
|
69
80
|
experimentId: string;
|
|
@@ -76,12 +87,47 @@ interface Treatment {
|
|
|
76
87
|
}
|
|
77
88
|
interface AlgorithmResult {
|
|
78
89
|
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
|
+
*/
|
|
79
103
|
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
|
+
probabilityBest?: number;
|
|
80
123
|
}
|
|
81
124
|
interface BanditResult {
|
|
82
125
|
assignmentId: string;
|
|
83
126
|
treatmentId: string;
|
|
127
|
+
/** @deprecated see `AlgorithmResult.confidence`. */
|
|
84
128
|
confidence: number;
|
|
129
|
+
/** See `AlgorithmResult.probabilityBest`. */
|
|
130
|
+
probabilityBest?: number;
|
|
85
131
|
metadata?: Record<string, any>;
|
|
86
132
|
}
|
|
87
133
|
interface ContextualFeatures {
|
|
@@ -119,6 +165,28 @@ declare enum BanditAlgorithm {
|
|
|
119
165
|
THOMPSON_SAMPLING = "thompson_sampling",
|
|
120
166
|
CONTEXTUAL_LINEAR = "contextual_linear"
|
|
121
167
|
}
|
|
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
|
+
declare enum Posterior {
|
|
180
|
+
BETA = "beta",
|
|
181
|
+
NORMAL_GAMMA = "normal_gamma"
|
|
182
|
+
}
|
|
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
|
+
declare function normalizePosterior(posterior: string | null | undefined): Posterior;
|
|
122
190
|
/**
|
|
123
191
|
* How an algorithm forgets old observations.
|
|
124
192
|
*
|
|
@@ -217,6 +285,8 @@ interface AssignmentRequest {
|
|
|
217
285
|
userId: string;
|
|
218
286
|
context?: ContextualFeatures;
|
|
219
287
|
sessionId?: string;
|
|
288
|
+
/** Browser page the assignment was requested from (origin + pathname). */
|
|
289
|
+
pageUrl?: string;
|
|
220
290
|
}
|
|
221
291
|
interface TrackNamedEventRequest {
|
|
222
292
|
name: string;
|
|
@@ -300,6 +370,7 @@ interface BundleAssignmentRequest {
|
|
|
300
370
|
userId: string;
|
|
301
371
|
context?: ContextualFeatures;
|
|
302
372
|
sessionId?: string;
|
|
373
|
+
pageUrl?: string;
|
|
303
374
|
}
|
|
304
375
|
interface BundleSlotAssignment {
|
|
305
376
|
assignmentId: string;
|
|
@@ -385,6 +456,369 @@ declare function extractSubdomain(hostname: string, baseDomain: string): string
|
|
|
385
456
|
*/
|
|
386
457
|
declare function buildSubdomainUrl(subdomain: string, baseDomain: string, path?: string, protocol?: string): string;
|
|
387
458
|
|
|
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
|
+
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
|
+
interface SigningKey {
|
|
503
|
+
version: number;
|
|
504
|
+
secret: string;
|
|
505
|
+
}
|
|
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
|
+
interface EmailTokenPayload {
|
|
512
|
+
/** Campaign id. */
|
|
513
|
+
c: string;
|
|
514
|
+
/** Pseudonymous recipient - see `hashRecipient`. Never a raw identifier. */
|
|
515
|
+
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
|
+
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
|
+
l: number;
|
|
541
|
+
/** Signing key version. Set by `mintEmailToken`; do not pass it in. */
|
|
542
|
+
v?: number;
|
|
543
|
+
}
|
|
544
|
+
type VerifiedToken = {
|
|
545
|
+
valid: true;
|
|
546
|
+
payload: Required<EmailTokenPayload>;
|
|
547
|
+
} | {
|
|
548
|
+
valid: false;
|
|
549
|
+
reason: 'malformed' | 'unknown_key' | 'bad_signature';
|
|
550
|
+
};
|
|
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
|
+
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
|
+
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
|
+
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
|
+
declare function appendEmailToken(url: string, token: string): string;
|
|
602
|
+
|
|
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
|
+
interface PlanArm {
|
|
631
|
+
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
|
+
armIndex: number;
|
|
640
|
+
}
|
|
641
|
+
interface PlanSlot {
|
|
642
|
+
experimentId: string;
|
|
643
|
+
/** Slot name, for substituting into the template. */
|
|
644
|
+
name: string;
|
|
645
|
+
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
|
+
probabilities: number[];
|
|
655
|
+
/** Arm content, positionally aligned with `arms`. */
|
|
656
|
+
configs: unknown[];
|
|
657
|
+
}
|
|
658
|
+
interface EmailPlan {
|
|
659
|
+
campaignId: string;
|
|
660
|
+
slots: PlanSlot[];
|
|
661
|
+
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
|
+
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
|
+
signingKey: {
|
|
678
|
+
version: number;
|
|
679
|
+
secret: string;
|
|
680
|
+
};
|
|
681
|
+
/** Re-request after this many recipients to pick up what the send has learned. */
|
|
682
|
+
refreshEvery: number;
|
|
683
|
+
/** When this plan stops being usable. */
|
|
684
|
+
expiresAt: number;
|
|
685
|
+
}
|
|
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
|
+
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
|
+
declare function pickArm(probabilities: number[], uniform: number): number;
|
|
709
|
+
/** The arm each slot assigns this recipient, positionally by slot. */
|
|
710
|
+
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
|
+
declare function impressionManifest(plan: EmailPlan, recipientIds: string[]): Promise<Array<{
|
|
718
|
+
experimentId: string;
|
|
719
|
+
treatmentId: string;
|
|
720
|
+
count: number;
|
|
721
|
+
}>>;
|
|
722
|
+
|
|
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
|
+
interface SlotBinding {
|
|
756
|
+
$slot: string;
|
|
757
|
+
}
|
|
758
|
+
type Bound<T> = T | SlotBinding;
|
|
759
|
+
declare function isSlotBinding(value: unknown): value is SlotBinding;
|
|
760
|
+
interface HeadingBlock {
|
|
761
|
+
type: 'heading';
|
|
762
|
+
text: Bound<string>;
|
|
763
|
+
level?: 1 | 2 | 3;
|
|
764
|
+
align?: 'left' | 'center' | 'right';
|
|
765
|
+
color?: Bound<string>;
|
|
766
|
+
}
|
|
767
|
+
interface TextBlock {
|
|
768
|
+
type: 'text';
|
|
769
|
+
text: Bound<string>;
|
|
770
|
+
align?: 'left' | 'center' | 'right';
|
|
771
|
+
color?: Bound<string>;
|
|
772
|
+
}
|
|
773
|
+
interface ButtonBlock {
|
|
774
|
+
type: 'button';
|
|
775
|
+
text: Bound<string>;
|
|
776
|
+
href: Bound<string>;
|
|
777
|
+
backgroundColor?: Bound<string>;
|
|
778
|
+
textColor?: Bound<string>;
|
|
779
|
+
align?: 'left' | 'center' | 'right';
|
|
780
|
+
}
|
|
781
|
+
interface ImageBlock {
|
|
782
|
+
type: 'image';
|
|
783
|
+
src: Bound<string>;
|
|
784
|
+
alt: Bound<string>;
|
|
785
|
+
href?: Bound<string>;
|
|
786
|
+
width?: number;
|
|
787
|
+
}
|
|
788
|
+
interface DividerBlock {
|
|
789
|
+
type: 'divider';
|
|
790
|
+
color?: string;
|
|
791
|
+
}
|
|
792
|
+
interface SpacerBlock {
|
|
793
|
+
type: 'spacer';
|
|
794
|
+
height?: number;
|
|
795
|
+
}
|
|
796
|
+
type EmailBlock = HeadingBlock | TextBlock | ButtonBlock | ImageBlock | DividerBlock | SpacerBlock;
|
|
797
|
+
interface EmailDocument {
|
|
798
|
+
/** Subject line - a slot like any other, and usually the highest-leverage one. */
|
|
799
|
+
subject: Bound<string>;
|
|
800
|
+
preheader?: Bound<string>;
|
|
801
|
+
blocks: EmailBlock[];
|
|
802
|
+
backgroundColor?: string;
|
|
803
|
+
contentWidth?: number;
|
|
804
|
+
}
|
|
805
|
+
/** Every slot name the document refers to, in document order, de-duplicated. */
|
|
806
|
+
declare function extractDocumentSlots(document: EmailDocument): string[];
|
|
807
|
+
interface RenderedDocument {
|
|
808
|
+
subject: string;
|
|
809
|
+
html: string;
|
|
810
|
+
/** Slots the document referenced that no value was supplied for. */
|
|
811
|
+
missingSlots: string[];
|
|
812
|
+
}
|
|
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
|
+
declare function renderEmailDocument(document: EmailDocument, slots: Record<string, string>): RenderedDocument;
|
|
821
|
+
|
|
388
822
|
interface BanditsClientConfig {
|
|
389
823
|
apiUrl: string;
|
|
390
824
|
apiKey: string;
|
|
@@ -449,6 +883,11 @@ declare class BanditsClient {
|
|
|
449
883
|
getBundle(bundleId: string, userId: string, context?: ContextualFeatures): Promise<BundleAssignmentResponse>;
|
|
450
884
|
trackBundleReward(bundleId: string, userId: string, value: number): Promise<void>;
|
|
451
885
|
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
|
+
private normalizeApiUrl;
|
|
452
891
|
private getOrCreateSessionId;
|
|
453
892
|
private makeRequest;
|
|
454
893
|
private startBatchFlush;
|
|
@@ -559,4 +998,148 @@ interface SessionOptions {
|
|
|
559
998
|
*/
|
|
560
999
|
declare function getOrCreateSessionId(options?: SessionOptions): string;
|
|
561
1000
|
|
|
562
|
-
|
|
1001
|
+
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
|
+
linkTable: string[];
|
|
1008
|
+
/** One token per link table entry, positionally aligned. */
|
|
1009
|
+
tokens: string[];
|
|
1010
|
+
}
|
|
1011
|
+
interface RewriteResult {
|
|
1012
|
+
html: string;
|
|
1013
|
+
/** How many hrefs were tokenised. */
|
|
1014
|
+
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
|
+
untracked: string[];
|
|
1024
|
+
}
|
|
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
|
+
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
|
+
declare function rewriteEmailLinks(html: string, options: RewriteOptions): RewriteResult;
|
|
1045
|
+
|
|
1046
|
+
interface RenderEmailOptions {
|
|
1047
|
+
/** Chosen content per slot, keyed by slot name, from /api/email/render. */
|
|
1048
|
+
slots: Record<string, string>;
|
|
1049
|
+
/** The campaign's ordered destination URLs. */
|
|
1050
|
+
linkTable: string[];
|
|
1051
|
+
/** One token per link table entry, minted for this recipient. */
|
|
1052
|
+
tokens: string[];
|
|
1053
|
+
}
|
|
1054
|
+
interface RenderedEmail {
|
|
1055
|
+
html: string;
|
|
1056
|
+
/** How many links were tokenised. */
|
|
1057
|
+
rewritten: number;
|
|
1058
|
+
/** Trackable links not present in the campaign's link table. */
|
|
1059
|
+
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
|
+
missingSlots: string[];
|
|
1067
|
+
}
|
|
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
|
+
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
|
+
declare function extractSlotNames(templateHtml: string): string[];
|
|
1084
|
+
|
|
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
|
+
interface PlanRenderResult extends RenderedEmail {
|
|
1098
|
+
/** Arm chosen per slot, positionally against the plan's slots. */
|
|
1099
|
+
combination: number[];
|
|
1100
|
+
/** The token minted for each link, positionally against the link table. */
|
|
1101
|
+
tokens: string[];
|
|
1102
|
+
}
|
|
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
|
+
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
|
+
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
|
+
declare function reportPlanImpressions(plan: EmailPlan, recipientIds: string[], options: {
|
|
1137
|
+
apiUrl: string;
|
|
1138
|
+
apiKey: string;
|
|
1139
|
+
batchKey?: string;
|
|
1140
|
+
}): Promise<{
|
|
1141
|
+
recorded: boolean;
|
|
1142
|
+
alreadyRecorded: boolean;
|
|
1143
|
+
}>;
|
|
1144
|
+
|
|
1145
|
+
export { type APIResponse, type Account, type AlgorithmResult, type ApiKey, type Assignment, type AssignmentRequest, BanditAlgorithm, BanditsClient as BanditClient, type BanditConfig, type BanditResult, BanditsClient, type BanditsClientConfig, type Bound, type Bundle, type BundleAssignmentRequest, type BundleAssignmentResponse, type BundleMode, type BundleSlotAssignment, type ButtonBlock, type Company, type CompanySnapshot, ContentType, type ContextualFeatures, type CreateApiKeyRequest, type CreateApiKeyResponse, type CreateExperimentRequest, DEFAULT_HALF_LIFE_MS, DEFAULT_SESSION_TTL_MS, DEFAULT_WINDOW_MS, type DividerBlock, EMAIL_TOKEN_PARAM, type EmailBlock, type EmailDocument, type EmailPlan, type EmailTokenPayload, type Experiment, type ExperimentStats, ExperimentStatus, type ForgettingConfig, ForgettingMode, type Funnel, type FunnelRewardRequest, FunnelStatus, type HeadingBlock, type IdentifyMergeReason, type IdentifyUserRequest, type IdentifyUserResponse, type ImageBlock, PLACEMENTS, type Placement, type PlanArm, type PlanRenderResult, type PlanSlot, Posterior, type RenderEmailOptions, type RenderedDocument, type RenderedEmail, type ResolvedIdentity, type RewriteOptions, type RewriteResult, SESSION_CREATED_AT_KEY, SESSION_ID_KEY, STATIONARY, type Session, type SessionOptions, type SigningKey, type SlotBinding, SnapshotClient, type SnapshotClientConfig, type SnapshotExperiment, type SnapshotTreatment, type SpacerBlock, type SyncAssignment, type TextBlock, type TrackNamedEventRequest, type Treatment, type TreatmentMetadata, type TreatmentStats, type User, type VariantConfig, type VerifiedToken, appendEmailToken, buildSubdomainUrl, calculateConfidenceInterval, extractDocumentSlots, extractEmailLinks, extractSlotNames, extractSubdomain, generateId, generateSubdomain, getContentTypeCategory, getOrCreateSessionId, hashRecipient, impressionManifest, isSlotBinding, isValidEmail, isValidSubdomain, mintEmailToken, normalizeAlgorithm, normalizePosterior, normalizeTreatmentWeights, pickArm, pickTreatment, renderEmail, renderEmailDocument, renderFromPlan, reportPlanImpressions, resolveForgetting, rewriteEmailLinks, sampleCombination, sanitizeMetadata, shouldRefreshPlan, uniformFor, validateTreatmentWeights, verifyEmailToken };
|