@arlotv/embed-core
v0.9.0
Published
Headless client SDK for Arlo embed rails, chat, and telemetry
Readme
@arlotv/embed-core
Headless client SDK for Arlo embed rails, chat, and telemetry. Zero runtime
dependencies, ships as ESM (dist/index.js + .d.ts) for bundler consumers
and as an ES5 IIFE (dist/arlo-embed.iife.js, ~3kB gzipped) for direct
<script> embedding on TV/legacy browser engines.
Install
npm install @arlotv/embed-coreimport { createArloEmbed } from '@arlotv/embed-core';
const arlo = createArloEmbed({
apiKey: 'YOUR_API_KEY',
customerId: 'customer-123',
// baseUrl defaults to https://api.arlotv.ai
});The signals rule: always {id, title}
Every signals array (likes, dislikes, favorites) is normalized before it
leaves the SDK. Always pass {id, title} pairs — bare content ids lose
the human-readable title the backend needs for cold-start/attribution
matching:
await arlo.rails({
likes: [
{ id: 'tt0111161', title: 'The Shawshank Redemption' },
{ id: 'tt0068646', title: 'The Godfather' },
],
dislikes: [{ id: 'tt0120655', title: 'Spawn' }],
});Bare strings (e.g. likes: ['tt0111161']) are still accepted for backward
compatibility and get coerced to { id: v, title: v }, but the title will
just be the id — do not rely on this in new integrations.
watched — viewing history
A fourth signals bucket carries viewing history (WatchedSig): { id, title?,
progress?, completed?, last_watched_at? }. Finished watches (completed: true
or progress >= ~0.95) act as implicit taste — the ranker naturally down-ranks
what the user already saw (never hard-suppresses). Capped at 50 server-side.
await arlo.rails({
watched: [
{ id: 'tt0111161', title: 'The Shawshank Redemption', completed: true },
{ id: 'tt0068646', title: 'The Godfather', progress: 0.4 },
],
});Server-side taste persistence (v2)
By default the server now persists signals per customer_id (opaque, no
PII; org-controllable, right-to-erasure via DELETE /v1/embed/users/:id). Two
consequences:
rails({})with an empty signals object is still personalized once the server has stored taste for that customer — you no longer need to assemble the full signal blob on every call.- Injected signals always win on conflict and are merged with stored state, so existing full-injection integrations behave exactly as before.
Prefer the delta emitters (reaction / favorite / watchProgress, below)
for keeping the store current as the user acts.
rails(signals, opts?)
Returns Promise<RailsResponse>. Fetches personalized rails for the given
signals.
const { rails, attribution } = await arlo.rails(
{ likes: [{ id: 'tt0111161', title: 'The Shawshank Redemption' }] },
{ max: 20, country: 'US' },
);opts.max?: number— cap the number of items returned.opts.country?: string— country-scope availability.- Rejects with
{ code: 'rails_http_<status>', message }on a non-2xx response — callers shouldawait/.catch()it.
The response is served stale-while-revalidate: the server answers instantly from its cache and recomputes personalization in the background. Two additive fields tell you what you got:
rails_meta?: { stale?: boolean; refreshing?: boolean }—stalemeans the rails predate the customer's newest signals;refreshingmeans a fresh personalized set is composing server-side (refetch shortly if you care).- each rail carries
personalized?: boolean—falsefor generic cold-start/editorial fill,truewhen shaped by this customer's own taste.
Each rail item also carries the display title and an images object, so you render a full
rail from this one call — no per-title hydration:
const { rails } = await arlo.rails(signals);
rails.forEach((rail) => {
renderRailHeader(rail.title);
rail.items.forEach(({ content_id, rec_id, title, images }) => {
renderCard({ id: content_id, rec_id, title, poster: images?.poster, backdrop: images?.backdrop });
});
});images carries poster, poster_large, backdrop, and title_card — the last is a 16:9
image with the title baked into the artwork (render it as-is; don't overlay your own title).
Any images field may be null (catalog art gaps are real) — supply your own placeholder.
content_id remains the stable id for telemetry and for anything not in the response.
railsWithCache(client, customerId, signals, opts?) — instant first paint
Optional client-side layer over the same endpoint: returns the last-known rails
from storage synchronously (cached), refetches (fresh), stores the result,
and calls opts.onUpdate(fresh). Storage is injectable (RN: AsyncStorage
adapter); defaults to localStorage.
import { railsWithCache } from '@arlotv/embed-core';
const { cached, fresh } = railsWithCache(arlo, customerId, {}, { onUpdate: render });
if (cached) render(cached); // paints in the same frame as the host appanonymousCustomerId(storage?) — logged-out visitors
Mints one UUID per browser and persists it under arlo:customer_id so signals
accrue to a stable identity across sessions (the blessed convention for
anonymous visitors). Pass a storage adapter on RN; falls back to a per-session
id when storage is unavailable.
similar(opts)
Returns Promise<SimilarResponse>. A content-to-content "More Like This" rail
for a single title — ideal for a PDP. The response carries only ids; the host
hydrates each card from its own catalog.
const { items, attribution } = await arlo.similar({
contentId: 'the-shawshank-redemption', // your catalog id / slug (required)
max: 12, // capped at 20 server-side; default 12
country: 'US',
});
// items: [{ content_id, rec_id, position }, ...]
items.forEach(({ content_id, rec_id }) => renderCard(myCatalog[content_id], rec_id));opts.contentId: string— the title to find neighbors for (required).opts.max?: number— cap the number of neighbors (server caps at 20).opts.country?: string— country-scope availability.opts.customerId?: string— attribution-only session id (not a login); defaults to the configuredcustomerId. Present ⇒ PDP clicks attribute via the rec_id ledger; absent ⇒ the rail still renders, clicks just aren't attributed.- Fail-silent: an unknown/unindexed title resolves to
{ items: [], reason }(never throws) so a PDP never breaks. Rejects with{ code: 'similar_http_<status>', message }only on a non-2xx response, and{ code: 'similar_bad_content_id' }ifcontentIdis missing.
chat(message, opts?)
import { DEFAULT_MOOD_CHIPS } from '@arlotv/embed-core';
// Cold start: render DEFAULT_MOOD_CHIPS; on tap call chat(chip).
const handle = arlo.chat('recommend something like Shawshank', {
signals: { likes: [{ id: 'tt0111161', title: 'The Shawshank Redemption' }] },
history: [{ role: 'user', content: 'hi' }],
onToken: (t) => appendToTranscript(t),
onPicks: (cards) => renderPicks(cards),
onChips: (chips) => renderChips(chips), // per-turn quick replies; clear on next user action
onAttribution: (a) => renderAttribution(a), // e.g. "Powered by Arlo" — render when a.required
onDone: (usage) => console.log('done', usage),
onError: (e) => console.error(e.code, e.message),
});
// later, e.g. on unmount or a new message starting
handle.cancel();chat() returns a plain { cancel(): void } handle synchronously — it is
not a Promise. All results arrive via the onToken / onPicks /
onChips / onAttribution / onDone / onError callbacks, which are safe
to call with no opts at all (arlo.chat('hi') does not throw).
DEFAULT_MOOD_CHIPS is a static, host-overridable string[] the host
renders before the first message (there's no prior turn to derive
per-turn chips from). Tapping one is an ordinary chat(chipText) call —
nothing special on the wire. Hosts are free to render their own list
instead; this is only a sensible default, not configuration.
onChips(chips) delivers per-turn quick-reply suggestions for the
next thing the user might say (distinct from DEFAULT_MOOD_CHIPS, which
only appears pre-first-message). Fires from the stream's chips frame (and
from the poll response's suggested_replies); omitted or empty when the
turn has none.
onAttribution(a) delivers the attribution payload the host must render
on the chat surface — { required, text, logo_url, link, placement_hint }.
When a.required is true, rendering it (e.g. "Powered by Arlo") is a
compliance requirement, same as the attribution object returned by
rails(). It fires from the stream's attribution frame (and from the poll
response's attribution).
Transport ladder
chat() auto-selects a transport and silently falls back if a rung isn't
available in the current runtime:
xhr(default) — progressive XHR againstPOST /v1/embed/chat, parsing newline-delimiteddata:frames out ofresponseTextas they arrive. Chosen by default wheneverXMLHttpRequestexists.sse— opt-in only (opts.transport = 'sse'), uses nativeEventSourceagainst a GET+query-encoded variant of the same endpoint. Not the default because the current/v1/embed/chatendpoint is POST-only; this rung is for a future GET variant. IfEventSourceisn't available, it downgrades toxhr(thenpoll).poll— one-shot JSONfetch(POST,stream:false), used when neitherxhrnorsseis viable, or as the final fallback of the ladder. Always available.
Errors are routed to onError with a transport-specific code
(sse_error | poll_error) — chat() never throws into the host page.
track(type, data?)
Fire-and-forget, synchronous — queues an event in an in-memory batch.
arlo.track('tile_tapped', { content_id: 'tt0111161' });
arlo.track('pick_tapped', { rec_id: 'rec_abc123' });type is one of: 'attribution_rendered' | 'rail_impression' |
'chat_opened' | 'pick_tapped' | 'tile_tapped' (engagement), the funnel types
'video_start' | 'ad_fill' | 'video_complete' (rec_id required), or the signal
deltas 'reaction_set' | 'favorite_set' | 'watch_progress'. data is optional:
{ rec_id?: string; content_id?: string; payload?: Record<string, unknown> }.
reaction(contentId, reaction, title?) / favorite(contentId, favorited, title?) / watchProgress(contentId, {progress, completed, title?})
Typed emitters for the server-side taste store — prefer these over raw
track() for taste updates; they shape payloads correctly. reaction(id, null)
clears a previous like/dislike; favorite(id, false) removes a favorite.
Batched with the rest of telemetry; delivered on flush().
arlo.reaction('tt0111161', 'like', 'The Shawshank Redemption');
arlo.watchProgress('tt0111161', { completed: true });
arlo.favorite('tt0068646', true, 'The Godfather');attributionRendered()
Shorthand for track('attribution_rendered') — call once when the
attribution/branding block is actually painted on screen.
arlo.attributionRendered();flush()
Synchronously sends the current queued-event batch via
fetch(url, { keepalive: true }) so it survives page unload, then clears the
buffer. Best-effort — a failed or dropped batch never throws. Call it
proactively (e.g. on visibilitychange/pagehide) if you don't want to wait
for the SDK's own batching.
window.addEventListener('pagehide', () => arlo.flush());captureRecId(url?)
Pure, synchronous helper — pulls the referral id out of ?src=arlo&aid=...
query params (defaults to window.location.href when no url is given).
Returns string | null.
const recId = arlo.captureRecId(); // or captureRecId(location.href)
if (recId) arlo.track('pick_tapped', { rec_id: recId });Promises and old TV engines
rails() is async and returns a real Promise. chat()'s poll
fallback and flush()'s telemetry send both use fetch(...).then(...)
internally even though their public surface is callback-based. Any TV or
legacy browser engine consuming this SDK needs global Promise and fetch
implementations (polyfill them before loading the SDK if the target engine
lacks either) — createArloEmbed() reads the global fetch at construction
unless you pass your own via { fetch }. Of the public API, only track,
attributionRendered, and captureRecId are Promise-free (they enqueue or
parse synchronously). flush() is fire-and-forget — it needs fetch +
Promise to send, but is best-effort and never throws, so on an engine
without them it silently drops the batch rather than erroring.
Browser <script> / IIFE usage
For engines without a bundler (Vizio/webOS/Tizen/FireTV WebViews, older Smart TV browsers), load the ES5 IIFE build directly:
<script src="https://unpkg.com/@arlotv/embed-core/dist/arlo-embed.iife.js"></script>
<script>
var arlo = ArloEmbed.createArloEmbed({
apiKey: 'YOUR_API_KEY',
customerId: 'customer-123',
});
arlo.rails({
likes: [{ id: 'tt0111161', title: 'The Shawshank Redemption' }],
}).then(function (res) {
renderRails(res.rails);
});
var chatHandle = arlo.chat('what should I watch tonight?', {
onToken: function (t) { appendToTranscript(t); },
onPicks: function (cards) { renderPicks(cards); },
onError: function (e) { console.error(e.code, e.message); },
});
// chatHandle.cancel() to abort
</script>The global is ArloEmbed, exposing the same named exports as the ESM
entrypoint (createArloEmbed, captureRecId, normalizeSignals, types are
erased at runtime). The bundle is built with target: 'es5' and has no
runtime dependencies, so it runs unmodified on old TV JS engines — provided
a Promise polyfill is present (see above).
