@bakaburg24/decisioning-js
v0.1.5
Published
Decisioning platform SDK for browsers. Calls /v1/decide, manages anonymous_id, fires telemetry, renders via a tenant-supplied adapter. Zero runtime deps.
Maintainers
Readme
@bakaburg24/decisioning-js
Browser SDK for the decisioning platform. Drop it on your site once — the platform decides what to show, when, and to whom.
pnpm add @bakaburg24/decisioning-jsQuick start — autonomous mode (recommended)
You declare slots once. The SDK observes user behavior (page-views, scroll, dwell, idle, exit-intent), calls /v1/decide per slot, installs the variant's trigger, and renders only when the platform-supplied trigger fires.
import { DecisioningClient } from '@bakaburg24/decisioning-js';
const client = new DecisioningClient({
apiKey: process.env.NEXT_PUBLIC_DECISIONING_KEY!,
endpoint: 'https://decision-service-production.up.railway.app',
eventsEndpoint: 'https://event-ingest-production-5912.up.railway.app',
consent: { behavioral: true, cookies: true },
renderAdapter: async (variant, ctx) => {
const el = document.createElement('div');
el.innerHTML = `
<h3>${variant.creative.headline}</h3>
<p>${variant.creative.subhead}</p>
<button>${variant.creative.cta}</button>
`;
el.querySelector('button')!.addEventListener('click', () => {
ctx.sdk.trackConversion({ decisionId: ctx.decisionId, outcomeName: 'newsletter_signup' });
ctx.sdk.unmount(ctx.decisionId);
});
ctx.target.appendChild(el);
return { unmount: () => el.remove() };
},
});
// Once per page. SDK does everything else.
client.observe([
{ name: 'newsletter', target: () => document.getElementById('newsletter-slot') },
{ name: 'cross_promo', target: () => document.getElementById('cross-promo-slot') },
]);That's it. The operator hardcodes nothing about when to show — variants carry their trigger config, set by the AI generator or by the operator in the wizard. The platform's bandit (Phase 2+) learns which trigger+variant combo wins per audience.
What observe() does behind the scenes
| Step | Detail |
|---|---|
| 1. Telemetry start | Fires page-view event (URL, referrer, locale, device class, viewport). Arms scroll-depth milestones (25/50/75/100%) and a 30s dwell pulse. |
| 2. Decisions | Calls /v1/decide once per slot with the SDK-managed anonymous_id + your context. |
| 3. Trigger install | Reads the variant's trigger (immediate / scroll / dwell / idle / exit-intent / custom-event) and installs the matching listener. |
| 4. Render | When the trigger fires, the SDK looks up the slot's target, calls the render adapter, fires the impression event server-side (via decision-service). |
| 5. Cleanup | stopObserving() tears down all listeners. Call it on SPA route change before the next observe(). |
Custom trigger from your code
If a variant uses custom-event type, dispatch the matching event from your own code at the right moment:
// Variant has trigger { type: 'custom-event', name: 'puzzle:completed' }
document.dispatchEvent(new CustomEvent('puzzle:completed'));This lets you bridge app-specific moments into the platform's trigger model without hardcoding "when" elsewhere.
Manual mode — single decision
When you want to control /decide explicitly (e.g. server-rendered slots, deferred mount):
await client.decideAndRender({
slot: 'newsletter',
context: { page: { topic: 'finance' } },
target: document.getElementById('newsletter-slot')!,
});Returns the DecideResponse so you can inspect or skip rendering yourself.
Anonymous ID + consent
- Anonymous ID is generated on first call, stored in
localStorage(1-year sliding expiry), falls back to cookie, then to per-page UUID. - Consent gating — when
consent.behavioralisfalse, the SDK omits the persistedanonymous_id(sends"anonymous") so decisions are context-only. - Update at runtime —
client.setConsent({ behavioral: true, cookies: true }). Existing cached decisions are not invalidated.
Telemetry events fired automatically
| Event | When | Payload |
|---|---|---|
| engagement:page_view | Every observe() call | URL, referrer, locale, device class, viewport |
| engagement:scroll_depth | Crossing 25 / 50 / 75 / 100% | depth_pct |
| engagement:dwell_pulse | Every 30s while tab is foreground | interval_ms |
| impression | When a variant renders | Server-emitted by decision-service |
Manual events (call from your code):
client.trackEngagement({ decisionId, engagementType: 'click', payload: { target: 'cta' } });
client.trackConversion({ decisionId, outcomeName: 'newsletter_signup', value: 1.0 });
client.trackDismiss({ decisionId, dismissedAfterMs: 4200 });Render adapter contract
type RenderAdapter = (
variant: Variant,
ctx: { decisionId: string; target: HTMLElement; sdk: DecisioningClientApi },
) => Promise<{ unmount: () => void } | void> | { unmount: () => void } | void;Return { unmount } so client.unmount(decisionId) can clean up on route change.
What the SDK doesn't do
- Bundle any framework. Render adapters are the framework boundary.
- Retry failed
/v1/decide(1000ms timeout by default, then cache fallback or no-op — retries hurt conversion more than they help). Override withtimeoutMs. - Block page load. Initialize early; lazy slot targets are fine.
What a variant looks like (generic creative shape)
A variant returned from /v1/decide carries everything needed to
render an end-to-end UX, not just copy:
{
variant_id: "v_xxx",
slot: "newsletter",
format: "modal" | "slide-in" | "inline" | ...,
placement: "...",
tags: [...],
creative: {
headline: string,
subhead: string,
cta: string,
kind?: "newsletter" | "discount" | "upsell" | "lead_capture" | "survey" | "announcement",
fields?: Array<{ type: "email" | "text" | "textarea" | "tel", name, label?, placeholder?, required? }>,
submit?:
| { kind: "mailflix" }
| { kind: "external_link", url: string }
| { kind: "webhook", url: string }
| { kind: "none" },
success_message?: string,
trigger?: { type, ... },
}
}Render adapters can read creative.fields + creative.submit and
build the form dynamically. See the host-app example in
examples/ for a reference adapter that handles all four
submit kinds.
The full schema + examples live in
decisions/docs/VARIANT_CREATIVE_SHAPE.md.
Changelog
0.1.5 — Forward all form values downstream
The bundled default renderer's Mailflix POST previously dropped
every form field except email and name. For survey or
lead-capture variants with a textarea, the actual response
content (typed by the user) was sent to the platform's
conversion event payload but never reached Mailflix's metadata
— so the operator could see "someone converted" but not what
they actually said.
Now: every non-email/name form value gets nested into Mailflix's
metadata blob alongside decision_id + variant_id, so survey
responses are recoverable from Mailflix's subscriber UI. (Same
data also lands in the platform's events warehouse via
trackConversion's properties field — that's now visible on
the portal's decision drawer via a new "Response" card.)
0.1.4 — One-file embed
The SDK now ships a bundled vanilla-DOM renderer that handles every
variant format (modal, slide-in, inline, bottombar, topbar) and submit
kind (mailflix, external_link, webhook, none) without operator code.
renderAdapter is now optional on the client constructor; observe()
can be called with no arguments and auto-discovers slots from the DOM
(elements with data-decisioning-slot="<name>"), falling back to a
single floating slot when none exist.
Minimal embed becomes:
import { DecisioningClient } from '@bakaburg24/decisioning-js';
const client = new DecisioningClient({
apiKey: '...',
endpoint: 'https://decision-service-production.up.railway.app',
eventsEndpoint: 'https://event-ingest-production-5912.up.railway.app',
consent: { behavioral: true, cookies: true },
});
client.observe(); // no slots arg, no renderAdapter — just worksBrand tokens auto-extract from the host page's CSS. The SDK reads
--decisioning-primary (or --primary), --decisioning-surface,
--decisioning-text, --decisioning-radius, --decisioning-font
from :root, falls back to the host body's computed font + colors,
then to neutral defaults (indigo accent, system font, white surface).
Pass explicit brand: { primary, surface, text, radius, font } at
client init to override.
Conversion tracking decoupled from downstream POST: the bundled
renderer fires trackConversion the moment form submit / CTA click
fires — before hitting Mailflix or the operator's webhook. A
misconfigured downstream key or transient 5xx no longer silently
blinds the bandit. (Custom render adapters should follow the same
pattern.)
0.1.3
- Debug logging gate. Set
localStorage.dx_debug = '1'in the browser console and reload — the SDK then logs every lifecycle moment to the console with the[decisioning]prefix:observe()called,/v1/decideresolved (and what variant + trigger was picked), trigger armed, trigger fired, render adapter invoked, adapter returned. Useful for diagnosing "why isn't my variant rendering" on production sites without redeploying. Off by default — negligible runtime cost when the flag is unset (one localStorage check at SDK init, cached).
0.1.2
- Exit-intent trigger fix. The previous implementation listened for
mouseleaveondocument, which is unreliable across browsers (mouseleave doesn't bubble anddocument's leave-boundary is browser-dependent — variants configured withtrigger.type: 'exit-intent'never actually fired in production). Now usesmouseouton document withrelatedTarget=null+clientY<=0, the browser-portable signal for "cursor crossed the top edge."
0.1.1
DEFAULT_TIMEOUT_MSraised from 200 → 1000. The old default matched decision-service's server-side latency target but didn't account for browser→Railway network cost (TLS + RTT is 100–300ms from EU). Server responses that beat the old timeout still fired impressions (decision-service emits them async before responding), but the SDK aborted the body read and the variant copy never swapped in. Override viatimeoutMsfor same-region setups where 200ms is realistic.
0.1.0
- Initial release.
DecisioningClient,observe(), render-adapter contract, anonymous-id management, telemetry events.
