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:
| Kind | What it is | Voices |
|---|---|---|
summary | 150-250-word spoken summary | 1 (owner clone or narrator) |
eli10 | Explain-like-I'm-10 | 1 |
pitch_panel | Flagship. 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_dr | Ruthless 20-second compression (50-80 words) — the busy-exec TL;DR. Opt-in (migration 545). | 1 |
devils_advocate | The 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_kids | ELI10's younger sibling — explain it to a curious 6-year-old (tinier words, ~120-200w). Opt-in (migration 546). | 1 |
for_grandma | Explain it to a sharp but non-technical grandparent — no jargon, familiar comparisons, patient. Opt-in (migration 546). | 1 |
accessibility_companion | Structure-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_note | The 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_rendertoggle (CRON_REGISTRY→/dashboard/admin/crons— not a cron, it rides the registry for the ~60s kill switch; env fallbackABOUT_THAT_EAGER_RENDER) AND per-embedeager_render(owner opt-out, since renders bill the owner's quota). Eager reusesgetOrRenderRenditionverbatim, 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).
| Preset | Personas | Player label | Who it's for |
|---|---|---|---|
founder (default) | FOUNDER / INVESTOR | The Pitch Panel | The original — pitch the page's idea. Prompt is golden-locked byte-identical to pre-preset v1. |
candidate | CANDIDATE / RECRUITER | The Interview Panel | Job seekers — their resume/portfolio page pitches them; the recruiter probes impact, gaps, "why you". Landing: /about-that/for-job-seekers. |
agent | AGENT / BUYER | The Open House Panel | Real-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. |
launch | FOUNDER / HUNTER | Launch Day | Launch-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. |
restaurant | OWNER / GUEST | The Chef's Table | Restaurants / 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. |
nonprofit | ADVOCATE / DONOR | The Giving Table | Donation / 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. |
maker | MAKER / SHOPPER | The Maker's Bench | Makers / 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}).enis always implicitly on and can't be disabled (normalizeLanguagesguarantees it). Owner picks the set at create-time or via the per-embed 🌍 editor. langis a cache-key dimension folded intorenditionCacheKey— butenfolds in nothing, so existing English audio keeps its exact hash and never re-renders. Each non-English language caches independently (about_that_renditions.langis 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
speakertokens and translates only thetext. - 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.comnever matchesexample.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 (
ttsSegmentcontext.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.svgserved 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 builderaboutThatBadgeSvglives 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.currentScriptand inserts its own iframe (usedata-urlper 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.sql—about_that_embedsabout_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 →getActiveVoicew/ consent ≥ 2; narrator → explicit id orgetHouseVoiceId()), IP throttle. - Routes:
apps/backend/src/routes/about-that.tsat/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, optionaldata-url/data-host/data-width). - Player:
/about-that/player/[embedId](iframe-safe, noindex,useSearchParamsin 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/uploadAudio → season-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.