product

About That — by Emberkiln

Embeddable audio player for third-party sites: one script tag gives every page a player that speaks about that page. Task 316c8cfa (P0). v1 built 2026-07-16. The strategic bet: every embedded player is an Emberkiln-branded widget on someone else's site — the product IS its own distribution.

What it does

A site owner (creator-gated HiveJournal/Emberkiln account) creates an embed — allowed domains, voice (their ElevenLabs clone or a narrator), tone, enabled rendition kinds — and pastes:

<script async src="https://www.hivejournal.com/about-that.js"
        data-embed="EMBED-ID"></script>

Visitors get a compact player (iframe) offering renditions of the current page:

KindWhat it isVoices
summary150-250-word spoken summary1 (owner clone or narrator)
eli10Explain-like-I'm-101
pitch_panelFlagship. FOUNDER (owner's voice) pitches the page's idea; INVESTOR (stock voice, env ABOUT_THAT_INVESTOR_VOICE_ID) pushes back. Original show format — no real-TV references, prompt-enforced.2, stitched
tl_drRuthless 20-second compression (50-80 words) — the busy-exec TL;DR. Opt-in (migration 545).1
devils_advocateThe single strongest honest objection to the page (steelmanned) + the fair answer the page's own content supports; says so plainly when the page doesn't answer it. Converts on sales pages. Opt-in (migration 545).1
for_kidsELI10's younger sibling — explain it to a curious 6-year-old (tinier words, ~120-200w). Opt-in (migration 546).1
for_grandmaExplain it to a sharp but non-technical grandparent — no jargon, familiar comparisons, patient. Opt-in (migration 546).1
accessibility_companionStructure-first wayfinding for a listener who can't see the page: how many sections, what each offers, where the key action is. Plain, not a pitch. Opt-in (migration 546).1
author_noteThe author's own "why I wrote this" note behind a blog/Substack post — first person, grounded in the page, no invented biography. Opt-in (migration 547).1

tl_dr, devils_advocate, for_kids, for_grandma, accessibility_companion, author_note are single-voice registers added after v1. They are NOT in the enabled_kinds default ({summary,eli10,pitch_panel}), so an existing embed only renders them once the owner ticks them on. New kinds need a migration (the renditions kind CHECK, unlike presets which are code-validated); the single-voice render path + prompt map (SINGLE_VOICE_DIRECTIVES in about-that-core.ts) is exhaustive so a missing directive is a compile error.

Render is cached with two trigger modes (migration 496):

  • Eager (default): the player iframe's config load passes the page URL, and the backend pre-renders every enabled kind on page VISIT — the first listener gets instant audio. Two-switch throttle: the global about_that_eager_render toggle (CRON_REGISTRY/dashboard/admin/crons — not a cron, it rides the registry for the ~60s kill switch; env fallback ABOUT_THAT_EAGER_RENDER) AND per-embed eager_render (owner opt-out, since renders bill the owner's quota). Eager reuses getOrRenderRendition verbatim, so every lazy-path guard applies: own-domain gate, per-embed daily cap, cache, tombstones, plus the player IP throttle on the trigger.
  • Lazy (either switch off): first listener tap → SSRF-guarded page fetch → LLM script → TTS → stitched MP3 in storage; everyone after gets the cache.

Cache key = sha256(kind + voice + tone + preset + extracted text), so page edits re-render and unchanged pages never double-spend (eager revisits of an unchanged page are free cache hits).

Vertical presets (migration 495)

An embed carries a preset that re-skins the pitch_panel persona pair (and its player label) — kinds never change, summary/eli10 are unaffected, and the try wall always uses founder. Config lives in PRESET_CONFIG (about-that-core.ts); validation is code-side against ABOUT_THAT_PRESETS (no DB CHECK — new verticals don't need constraint migrations).

PresetPersonasPlayer labelWho it's for
founder (default)FOUNDER / INVESTORThe Pitch PanelThe original — pitch the page's idea. Prompt is golden-locked byte-identical to pre-preset v1.
candidateCANDIDATE / RECRUITERThe Interview PanelJob seekers — their resume/portfolio page pitches them; the recruiter probes impact, gaps, "why you". Landing: /about-that/for-job-seekers.
agentAGENT / BUYERThe Open House PanelReal-estate agents — one snippet in the listing template; every listing renders its own episode (content-hash caching). The buyer probes price, condition, what the listing leaves out. Landing: /about-that/for-real-estate.
launchFOUNDER / HUNTERLaunch DayLaunch-day / Product Hunt landing pages — the maker reveals what shipped today; an early adopter probes what it does on day one, who it's for, what's still rough.
restaurantOWNER / GUESTThe Chef's TableRestaurants / venues — the owner or chef tells the story of the place; a first-time guest asks what to order and what makes it worth the trip. The owner's actual voice is the whole point here.
nonprofitADVOCATE / DONORThe Giving TableDonation / mission pages — the person behind the cause makes the case at the moment of the ask; a thoughtful donor probes impact-per-dollar, proof, overhead. High emotional leverage.
makerMAKER / SHOPPERThe Maker's BenchMakers / artists (Etsy, Gumroad, artist "about" pages) — the maker tells the story of the craft; a curious shopper asks what it's made of, why it costs what it does, who it's for.

Rules when adding a preset: persona pair needs distinct first letters (parsePanelScript coerces speakers by first-letter prefix); pitcher = the owner's voice, panelist = the stock voice (ABOUT_THAT_INVESTOR_VOICE_ID for all presets in v1); preset is a cache-key dimension; add a card to the landing's "Built for" section if it gets a /about-that/for-* page.

Known limitation (stated honestly on the real-estate page): extraction is plain fetch + readability — JS-only IDX listing widgets yield no text and the render 422s. Server-rendered listing pages work.

Languages (migration 548)

"Your site, pitched in your voice, in Spanish." The TTS model (eleven_multilingual_v2) already speaks every supported language, so a language is realized purely by writing the script in it — no new TTS model, no new render path. Supported set (ABOUT_THAT_LANGUAGES in about-that-core.ts, code-validated — no DB CHECK): English, Spanish, French, German, Portuguese, Italian, Japanese, Chinese (Mandarin), Hindi.

  • Per-embed enabled_languages (about_that_embeds, default {en}). en is always implicitly on and can't be disabled (normalizeLanguages guarantees it). Owner picks the set at create-time or via the per-embed 🌍 editor.
  • lang is a cache-key dimension folded into renditionCacheKey — but en folds in nothing, so existing English audio keeps its exact hash and never re-renders. Each non-English language caches independently (about_that_renditions.lang is a descriptive tag; correctness rides the content-hash).
  • Prompts append a "write in <language>" directive only for non-English (the English/founder prompts stay byte-identical and golden-locked); the pitch panel keeps its English speaker tokens and translates only the text.
  • Player shows a language <select> when the embed has >1 language; a stale or off-list ?lang= degrades to English rather than 403-ing. Deep-link a language with …/listen/<id>?url=<page>&lang=es.
  • Cost note: eager render pre-renders the DEFAULT (English) only; other languages render lazily on first listen (and count against the daily cap), so enabling 8 languages doesn't 8× every page visit's spend.

The "try it out" wall (landing-page growth loop)

/about-that lets anyone, no account point at one website → we render a Pitch Panel about it (house voice as FOUNDER — "in your voice" is the signup hook) and show it in a public gallery with the embed snippet. Hard cap: 25 non-error tries, ever (TRY_GALLERY_CAP) — bounded marketing spend that converts into a permanent demo wall; at 25 the form flips to a sign-up CTA. Gallery cards show the source page's own og:image with an arrow down into the player ("the page → the panel about it"; migration 497, favicon-tile fallback for sites without one). Migration 494_about_that_tries.sql; endpoints GET/POST /api/about-that/try + GET /try/:id (submit returns 202 fast, render runs in the background, the page polls). Guards: URL dedupe (unique url_normalized — resubmits return the existing render; error rows give their slot back and are retryable via upsert), 3 submissions/IP/day, OpenAI moderation on the extracted page text before anything is voiced (it's a public gallery), page read + moderation happen before a slot is taken, post-insert recount guards the cap race.

Cost + abuse guards

  • Embed can only narrate pages on its own allowed domains (dot-boundary suffix match — evil-example.com never matches example.com); a leaked embed id can't be farmed as free TTS.
  • Per-embed daily_render_cap (default 20, owner-settable 1–500) counts every render row including failures.
  • Per-IP daily throttle on the public endpoint (namespaced bucket in rehearsal_rate_limits, no migration — the LinkedIn-outreach precedent).
  • TTS quota attributed to the embed owner (ttsSegment context.user_id).
  • Text clamps: 8k extracted chars in, ~3.8k script chars out (≈3-4 min audio); panel ≤24 lines × 600 chars.
  • Error rows are tombstones per content-hash — the same failing input is never retried on spend; a page edit changes the hash and unblocks.
  • Concurrent-render claim via the unique index (embed_id, content_hash, kind) — first inserter wins, others get 202 and poll; stale claims (>10 min) self-clear.

Asking questions (faq_answer register)

Opt-in per embed (faq_enabled). A visitor types a question; we answer it only from the page's own content — never inventing hours, prices, or policies. When the page doesn't hold the answer we say so and point them to the page/contact (the model emits a NOT_ON_PAGE sentinel → a graceful decline). Answer is text-first with best-effort audio in the embed's voice. Guards: the visitor question is moderated (public input), the target URL must be on the embed's domains, answers are content-hash cached (an identical repeat question is free), and spend is capped per-embed/day + throttled per-IP. Because the input needs height, FAQ shows on the hosted /listen page and via a data-faq="1" loader opt-in (a taller iframe); the compact 148px embed stays audio-only. This is the stepping stone to the Site Concierge — same grounding + billing model, single-question instead of multi-turn.

Changing voices

The dashboard voice picker previews every narrator voice (ElevenLabs CDN preview clips via the voice-bank endpoint) and the owner's consented clone before choosing. Because the rendition cache key includes the resolved voice, saving a new voice makes every future render fresh automatically — and "Re-render existing pages now" replays the embed's known pages through the normal pipeline immediately (bounded by the daily render cap; old audio files remain untouched orphans).

Distribution beyond the snippet

Not every owner can edit HTML. Two per-rendition affordances on the dashboard close that gap:

  • Listen link/about-that/listen/<embedId>?url=<page>: a hosted, indexable standalone player page (link-in-bio, QR on a yard sign or conference badge, email signatures). Same public endpoints + domain gate as the iframe player; distribution packaging, not a new render path.
  • Download MP3 — the rendition's cached audio as a file (podcast pre-roll, trailer, ad-read reuse).
  • GitHub README badge — a shields-style flat SVG ("▶ hear this page | About That") a dev founder drops in their repo README; the badge links to the hosted listen page. Public URL is /api/about-that/embed/<embedId>/badge.svg served by the backend and proxied to the brand domain via a next.config rewrite (same brand-correctness reason as the podcast RSS rewrite — a README must not show the Railway host). The SVG builder aboutThatBadgeSvg lives in about-that-core.ts (pure, golden-tested); the route ALWAYS returns 200 with an SVG (a paused/unknown embed renders a muted "unavailable" variant so a stale README link degrades gracefully instead of 404-ing the image). Cached hard (s-maxage=3600, stale-while-revalidate) for GitHub's camo proxy. Dashboard shows a live preview + copy-the-Markdown button; the copied listen link defaults its page URL to the embed's first allowed domain.

Integrating the loader (notes for embedders)

The loader is one script tag; each tag replaces itself with one iframe:

<script async src="https://www.hivejournal.com/about-that.js"
        data-embed="EMBED-ID"></script>
  • Multiple embeds per page work — each tag runs against its own document.currentScript and inserts its own iframe (use data-url per tag to narrate different URLs, e.g. one player per listing card).
  • React integrators (contributed by the quicksites team, 2026-07-16): JSX-rendered <script> tags don't reliably execute across hydration paths. Create the tag imperatively inside an effect, inside a container you own, and clear the container on cleanup — the loader inserts its iframe as the script's next sibling, i.e. inside your container, so clearing the container removes both and the pattern is idempotent under StrictMode double-mounts:
useEffect(() => {
  const el = containerRef.current
  if (!el) return
  const s = document.createElement('script')
  s.async = true
  s.src = 'https://www.hivejournal.com/about-that.js'
  s.setAttribute('data-embed', embedId)
  el.appendChild(s)
  return () => { el.innerHTML = '' } // removes script AND the injected iframe
}, [embedId])
  • Domain gate reminder: renders only work when the narrated URL's host is on the embed's allowed_domains (dot-boundary match — a root domain covers its subdomains). Editor/preview surfaces on a different domain will 403 renders unless that domain is on the list; see the provisioning API task for the longer-term preview story.

Where everything lives

  • Migration: supabase/migrations/493_about_that.sqlabout_that_embeds
    • about_that_renditions, RLS on, backend-only.
  • Pure core (golden-tested 15/15): apps/backend/src/services/about-that-core.ts — domain gating, cache keys, clamps, prompt builders, script parsers.
  • Service: apps/backend/src/services/about-that.ts — embed CRUD, getOrRenderRendition (the lazy pipeline), voice resolution (own_clone → getActiveVoice w/ consent ≥ 2; narrator → explicit id or getHouseVoiceId()), IP throttle.
  • Routes: apps/backend/src/routes/about-that.ts at /api/about-that — owner endpoints creator-gated (requireCreator, same posture as podcast-discussion); public: GET /embed/:id/config, POST /embed/:id/rendition (200 ready / 202 rendering / 4xx guards).
  • Loader: apps/frontend/public/about-that.js (static; data-embed, optional data-url/data-host/data-width).
  • Player: /about-that/player/[embedId] (iframe-safe, noindex, useSearchParams in a Suspense child; polls every 5s ≤ 3 min).
  • Dashboard: /dashboard/about-that — create/manage embeds, copy snippet, renditions log.
  • Landing: /about-that — also the viral-loop destination (every player's footer links here).

Reused infra (why v1 was one session)

podcast-discussion.ts (script→TTS→stitch pattern), season-audio.ts (ttsSegment/concatMp3Buffers/uploadAudioseason-assets/about-that/), link-preview.ts (fetchReadableText, SSRF-guarded), narration.ts (the lazy + content-hash cache model), llm.ts (complete; feature_keys about_that.script.* / about_that.render.* — visible in /dashboard/admin/llm-spend), lovio-voice.ts (clone + consent).

v1 limits / next

  • Models: gpt-4o-mini (summary/eli10), claude-sonnet-4-6 (pitch panel).
  • No verbatim read-aloud kind yet (long-page TTS cost; add with per-chars pricing or credits).
  • Investor is a single fixed persona; N-investor panel + recurring named personas = v2 (persona/critic system is the substrate).
  • WordPress plugin: researched 2026-07-16 — not now (category leader has 7k installs; BeyondWords, VC-backed, has 800; ElevenLabs themselves chose snippet-via-WPCode over a plugin). Do the WPCode docs play; revisit triggers in docs/marketing/2026-07-16-about-that-wordpress-channel.md.
  • Billing: creator-gated + caps for now; per-render credits when opened up.
ABOUT THAT — Docs | HiveJournal