DEV Community

Cover image for Too lazy to read my newsletters, so I built an AI that reads them to me
Victory Lucky
Victory Lucky

Posted on AI-assisted

Too lazy to read my newsletters, so I built an AI that reads them to me

Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady Submission 🦥

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady

My inbox has 124 unread newsletters. I subscribed to every one of them on purpose. Neo Kim's System Design Newsletter teaches me something every day, and Gregor Ojstersek's Engineering Leadership is the closest thing I have to a mentor. They're important. I'm just too lazy to read them.

I am not too lazy to listen, though. I have a 40-minute commute to the office and my ears are free the whole way. So I built Unread: It picks up your newsletters to a private address, and turns them into one short podcast episode, with every story heard once, every claim cited.

What I Built

Unread turns the newsletters you never get to read into a short, private podcast.

  1. Forward once. You get a private address like k7m2q9x4ab@in.getunread.site. Add one Gmail filter that forwards your newsletters to it. Gmail sends a confirmation code first, so Unread catches that email and shows the code in the app. After that you never touch it again.
  2. Unread does the reading. Every issue is cleaned (footers, unsubscribe blocks, tracking pixels and sponsor sections removed) and split into separate stories.
  3. You hear each story once. This is the part I care about most, and it's really two features:
    • The same story from several newsletters becomes one chapter that credits all of them. When two newsletters cover the same launch in the same week, you hear it once: "Engineering Leadership and The System Design Newsletter both covered this."
    • A story you already heard is skipped. If a newsletter brings it up again next week, Unread remembers and leaves it out.
  4. Nothing is made up. Every line of the script must cite the newsletter it came from. Any number or quote that isn't in that source is rewritten once, and cut if it still fails. Every chapter lists its sources, with links.
  5. It plays where you already listen. Each episode has chapters, a transcript that highlights the line being spoken, and the sources. Episodes go to a private RSS feed that works in any podcast app that accepts a feed URL (the Sources page has one-tap buttons for Apple Podcasts and Pocket Casts), and to a web player on phone and desktop.

Who gets to be lazier? Anyone with multiple newsletter subscriptions and a commute, a gym session or a pile of dishes. After two minutes of setup, the effort is zero. No app to open, no "read later" queue to feel guilty about, no reading the same funding round four times.

Slow and steady, on purpose. The Free plan makes one episode a week, voiced by an open-weight model on a CPU. A 10-minute episode takes the worker a few minutes to record, and nobody minds, because it's made on a schedule while you sleep. It only runs when there's something new: fewer than two new stories means no episode and no cost.

Demo

Live: getunread.site. Sign in, copy your forwarding address, and forward one newsletter by hand to try it.

The video's narrator and the episode's host are both ElevenLabs voices. The screens are rebuilt from the app's own components and strings.

My Gmail inbox: newsletter after newsletter, unread

Forwarding setup: the private address, and Gmail's confirmation code caught and shown in the app

Two newsletters, one story: Gemma 4 confirms the borderline pair

The merged chapter credits both newsletters

The fact check: a number that isn't in the cited source gets cut

Now playing: the line being spoken is highlighted, and every source is one tap away

Code

Unread

Forward your newsletters to Unread and get one short, deduplicated podcast episode with the parts that matter, in a voice you choose, delivered to the podcast app you already use.

How it works

newsletter ─► Cloudflare Email Routing ─► Email Worker ─signed─► /inbound/email
              (SPF/DKIM)                  (apps/inbound-worker)     verified sender? clean, split into stories
                                                                    │
Temporal schedule / "Make one now" ─► make-episode workflow (apps/worker)
   collect → EmbeddingGemma + pgvector clustering → Gemma plan → Gemma script → fact check
   → Kokoro (Free) / ElevenLabs (Plus) → ffmpeg −16 LUFS → seeded cover → publish
                                                                    │
private RSS feed (/feed/<token>.xml) + web player (Next.js, apps/web)

Repo

Path What
apps/web Next.js 16: onboarding, Home, Now playing, Sources, Settings, admin; inbound, feed, audio, billing routes
apps/worker Temporal worker (or poller), CLIs: brief, make-episode, dispatch, promo, warm-models
apps/inbound-worker Cloudflare
…
Path What's in it
apps/web Next.js 16 app: onboarding, Today, Now playing, Sources, Settings; the inbound-email, feed, audio and billing routes
apps/worker Temporal worker (or a polling fallback) that runs the episode pipeline, plus CLIs (pnpm brief, pnpm make-episode)
apps/inbound-worker Cloudflare Email Worker that relays forwarded mail, signed
packages/core All the logic: email parsing, clustering, planning, scripting, fact check, voices, audio, cover, RSS feed, plans, promo codes
packages/db Drizzle schema and migrations: Postgres with pgvector and a Timescale hypertable

The repo was started on October 23, inside the challenge window. Every third-party choice is written up as a decision record in docs/decisions/, and the product spec is in docs/prd-unread.md. Integration tests run against real EmbeddingGemma, real Kokoro, real ffmpeg and Temporal's time-skipping test server, not mocks.

How I Built It

The open-source AI at the centre

Model Licence What it does in Unread Where it runs
Gemma 4 31B (gemma-4-31b-it) Gemma open weights Every editorial decision: confirms borderline "same story?" pairs, ranks stories, writes each chapter as cited JSON, repairs lines that fail the fact check Served by Google's Gemini API, with OpenRouter (the same open model) as the fallback
EmbeddingGemma 300M (onnx-community/embeddinggemma-300m-ONNX, q8) Gemma open weights Turns every story into a 768-dimension vector, to find the same story across newsletters and stories you've already heard On our own worker's CPU, through transformers.js 3.8.1 (ONNX Runtime)
Kokoro-82M (onnx-community/Kokoro-82M-v1.0-ONNX, q8) Apache-2.0 Reads every Free episode aloud, in four curated voices On our own worker's CPU, through kokoro-js

Around them it's open source all the way down: Postgres with pgvector from Tiger Data, Temporal, ffmpeg, Mozilla's Readability, resvg for covers, and Next.js.

The pipeline

newsletter ─► Cloudflare Email Worker ─signed─► /inbound/email (Next.js on Render)
                (HMAC over the raw MIME)         verified sender? SPF/DKIM pass?
                                                 clean → split into stories → items
                                                        │
Temporal schedule (or "Make one now") ─► makeEpisodeWorkflow (worker on Render)
   collect → EmbeddingGemma + pgvector → Gemma confirms borderline pairs → skip heard
   → Gemma ranks → Gemma writes cited JSON → deterministic fact check (+1 repair)
   → Kokoro (Free) / ElevenLabs (Plus) → ffmpeg, −16 LUFS → seeded cover → publish
                                                        │
             private RSS feed /feed/<token>.xml + web player with chapters and transcript
Enter fullscreen mode Exit fullscreen mode

1. Getting mail in safely. Cloudflare Email Routing hands each forwarded message to a small Email Worker, which signs the raw MIME with an HMAC (X-Unread-Signature) and posts it to the app. The app accepts mail only from the user's verified addresses and only when SPF or DKIM passes. Everything else is dropped and logged before any model sees it. Gmail's forwarding-confirmation email is recognised by its subject, and its code is stored and shown in onboarding as soon as it arrives. Retries are idempotent on Message-ID.

2. Cleaning and splitting. HTML becomes text, with footers, unsubscribe blocks, tracking pixels and identifiable sponsor sections removed and links kept. Each issue is split into stories at its headings, where a short line that's all bold counts as a heading. The newsletter's name comes from the sender or List-Id, for citations.

3. Same story, heard once. EmbeddingGemma embeds every story on the worker. Measured on the dev container, the same story from two different outlets scores about 0.93 cosine similarity and unrelated stories about 0.30, so the thresholds are:

Cosine similarity What happens
≥ 0.83 Same story: merged into one chapter
0.72 to 0.83 Borderline: Gemma decides ("same event, announcement or finding, not just the same topic"), at most 30 pairs per episode
≥ 0.85 against anything you've heard Already heard: skipped

"Already heard" is a single pgvector query against the stories in your past episodes:

const [nearest] = await db.execute<{ similarity: number }>(sql`
  select 1 - (i.embedding <=> ${vector}::vector) as similarity
  from ${schema.heardItems} h
  join ${schema.items} i on i.id = h.item_id
  where h.user_id = ${userId} and i.embedding is not null and i.id <> ${c.id}
  order by i.embedding <=> ${vector}::vector
  limit 1`);
if (nearest && Number(nearest.similarity) >= HEARD) heardItemIds.push(c.id);
Enter fullscreen mode Exit fullscreen mode

4. Planning. Gemma ranks the stories within your priority tiers ("always first", "normal", "only if room"). It factors in how often you skip each source, read from a Timescale hypertable of listening events over the last 60 days, then freshness. It fits the result to a character budget for your plan and episode length (about 900 characters per spoken minute).

5. Writing, with a leash. Gemma writes each chapter as JSON, { title, lines: [{ text, sourceIds }] }, from the sources in that chapter only. Gemma on the Gemini API has no JSON mode, so the prompt states the contract. The reply is validated with Zod plus semantic checks (length budget, known source ids), and errors are fed back for up to three attempts. The intro and outro are templates, not model output, so the part that names your newsletters can't hallucinate: "This is Unread for Monday, 26 October. 4 stories today, from…"

6. The fact check is code, not vibes. Every line goes through a deterministic check:

export function checkLine(line: ScriptLine, allowed: Set<string>, sourceText: Map<string, string>): string[] {
  const problems: string[] = [];
  const cited = line.sourceIds.filter((id) => allowed.has(id));
  if (cited.length === 0) problems.push("cites no valid source");
  const words = line.text.trim().split(/\s+/).length;
  if (words > MAX_WORDS_PER_LINE) problems.push(`${words} words (max ${MAX_WORDS_PER_LINE})`);
  const haystack = normalizeText(cited.map((id) => sourceText.get(id) ?? "").join("\n"));
  const sourceNumbers = new Set(numbersIn(haystack));
  for (const n of numbersIn(line.text)) {
    if (!sourceNumbers.has(n)) problems.push(`number ${n} is not in the cited sources`);
  }
  for (const q of quotesIn(line.text)) {
    if (!haystack.includes(q)) problems.push(`quote "${q}" is not in the cited sources`);
  }
  return problems;
}
Enter fullscreen mode Exit fullscreen mode

A failing line goes back to Gemma once, along with the exact problems. If it still fails, it's dropped. A shorter episode beats a wrong one.

7. Voices. A VoiceProvider interface has two implementations, chosen by the user's plan. Voice code never checks plans itself.

  • Kokoro voices the Free plan on the worker's CPU. Measured: 8.6 s of speech in 6.6 s on one thread. A 10-minute episode is about 7.8 CPU-minutes, or about 4 minutes with two jobs in parallel.
  • ElevenLabs voices Plus. Standard voices use eleven_flash_v2_5; Studio voices use eleven_v4, with neighbouring lines passed as context so the delivery flows.

Every line is cached by sha256(provider | model | voice | text), so a retry never pays twice. ffmpeg joins the lines in one pass and normalises to −16 LUFS. Chapter and per-line start times come from exact sample counts, which is how the transcript highlights the line being spoken.

8. Cover and feed. Each episode gets a seeded SVG cover (your date and newsletters), rendered to PNG with resvg. The private RSS feed has iTunes and Podcasting 2.0 tags, chapter JSON per episode and itunes:block, behind a 192-bit token you can rotate.

9. Durable by default. makeEpisodeWorkflow runs the steps as Temporal activities: start → script → voice (with heartbeats) → cover → publish. Each step stores its output and checks for it before redoing work, so a retried activity never re-buys voice or re-asks Gemma. Two Temporal Schedules drive it: a dispatcher every 10 minutes (who is due, in their own timezone) and hourly maintenance (raw mail is deleted after 30 days, and trials expire). If Temporal is unavailable, the same worker falls back to a FOR UPDATE SKIP LOCKED poller that runs the same step functions. The workflow is tested with Temporal's time-skipping environment: steps run in order, the voice step is retried after a transient failure, and nothing is published when the request is skipped.

10. Hosting. Both halves run on Render. The web app is a Node service on getunread.site, which runs the migrations before each deploy. The worker is a Docker background worker with ffmpeg and the ONNX models baked into the image, so a cold start doesn't download the models first.

Plans, without surprises

Free Plus ($12/mo)
Voice Kokoro (open-weight, 4 voices) ElevenLabs Standard or Studio voices
Cadence Weekly, on your chosen day Daily, only when there's something new
Length Up to 10 minutes 5, 10 or 20 minutes

The effective plan comes from one function: paid Plus, or an unexpired promo trial, or Free. Promo codes give a Plus trial with no card. A trial is capped at 3 ElevenLabs episodes, redemption is atomic, and it needs a verified email. Payments go through Bachs, and plans change only from its signed, idempotent webhooks, never from a redirect URL.

What fought back

  • Two ONNX runtimes in one process crash. kokoro-js ships with transformers.js 3.x, so EmbeddingGemma had to run on the same pinned @huggingface/transformers@3.8.1, or Node fails with VERS_1.21.0 not found.
  • No JSON mode for Gemma on the Gemini API. Hence the contract-in-the-prompt, a Zod check and the three-attempt repair loop. Gemma follows a stated schema well once the error message tells it exactly what was wrong.
  • Gmail won't auto-forward until you confirm by email. That would have been the onboarding killer: users would have to go find a code in a mailbox they can't see. So Unread catches the confirmation email and puts the code on the setup screen.

Why Does Open Innovation Matter?

Open models are what make a free plan possible at all. Voice is the expensive part of a podcast. With ElevenLabs, a 10-minute episode is about 9,000 characters, roughly 36 cents at Flash prices or 72 cents for a Studio voice, every time. If every listener cost that much, Unread would have to be paid-only. Kokoro is Apache-2.0 and runs on our own worker, so a Free episode costs CPU time and nothing per character. ElevenLabs became the upgrade instead of the foundation.

The model that reads your newsletters runs where we choose. EmbeddingGemma reads every story you forward, and it runs on our worker's CPU. There's no per-call bill, no rate limit, and your newsletter text isn't sent to a separate embeddings service just to be compared.

No single vendor holds the off switch. Gemma is served by Google today, but it's open weights. That's why the fallback works: when the Gemini API returns 429s, the same model is called through OpenRouter, and the prompts and the fact check behave the same. With a closed model there's no second provider to fall back to. The model client already supports the OpenAI-compatible protocol, so pointing it at a self-hosted or fine-tuned Gemma is a small change, not a rewrite.

Trust is checkable. The part that protects listeners from made-up numbers is open code (checkLine above), not a promise in a model card. Anyone can read exactly what "every claim is cited" means.

My Agent Session

I couldn't get the first session due to Claude limit on that account, so here's the others

Landing page from Landing.dc.htm claude-opus-5-5
You

<ide_opened_file>The user opened the file [REDACTED]\hacks\unread\design\README.md in the IDE. This may or may not be related to the current task.</ide_opened_file>

“Build the landing page from docs/design/Landing.dc.html.”

Agent
Agent
Agent
You

[Request interrupted by user for tool use]

You

continue

Agent
Agent
Agent

The repo just changed under me (a monorepo scaffold landed). Re-orienting.

Agent
Agent
Agent

Current / is a minimal hero; I'll replace it with the full design. Checking a few details (onboarding redirect, price, topics, terms page).

Agent
Agent
Agent

Plan: a features/landing/ folder (content, sections, a small client sample player), and / composes it. Let me check the existing player icons and env pattern to reuse them.

Agent
Agent

Writing the feature folder now.

Agent
Agent
Agent

Now the sections and the page.

Agent
Agent
Agent
Agent
Agent

Switching the header's #top link to a plain anchor (typed routes), then typecheck and lint.

Agent
Agent
Agent

Dependencies aren't installed locally; installing so I can typecheck, lint and look at the page.

Agent
Agent
Agent
Agent
Agent

I rebuilt / from docs/design/Landing.dc.html, but I haven't typechecked, linted or opened it in a browser. Your C: drive is full (97 MB free), so pnpm install stopped partway with "no space left on device". I only checked the code by reading it.

What's built
- page.tsx now shows the full page: header, hero, how it works, "Hear each story once", news topics, pricing, FAQ, the closing call to action and the footer. Signed-in visitors still go straight to /today.
- The page's pieces are in sections.tsx, and the copy is in content.ts.
- sample-player.tsx is a small player shared by the hero's sample card and the three chapter play buttons.
- I added one colour token, ground-2 (#121318), to globals.css for the alternating section backgrounds.
- Every sign-up button sends people to the Auth0 sign-up page and then to /today, which sends new users to onboarding.

Where I changed the design
- Sign in link: I added one to the header. The design has none, and without it returning users have no way back in.
- Mobile header: below about 768 px, the How it works, Pricing and FAQ links are hidden so the header fits on one row.
- FAQ arrow: each question has a chevron on the right instead of the browser's default marker.
- Terms link: I left it out of the footer because there is no Terms page yet.

Still missing before launch
1. Plus price: the page shows [PRICE] until you set PLUS_PRICE in content.ts.
2. Sample audio: there is no apps/web/public/sample-episode.mp3 yet. Until you add it, the play buttons are disabled and the card says "Sample episode coming soon". When you add it, update each chapter's startSeconds to match the recording.

I also updated item 6 in project-memory.md to list these gaps. Nothing is committed.

Next step: free some space on C:, then run pnpm install && pnpm typecheck && pnpm lint && pnpm dev. The failed install left a partial node_modules in the repo that is taking up space. Do you want me to delete it?

You

free my storage n run it

Agent

I'll only clear caches and temp files that rebuild themselves, starting with the partial install I created. First, a look at what's taking space.

Agent
Agent
Agent
Agent

Crash dumps are protected, so I'm skipping them and running the rest.

Agent
Agent

5.4 GB free now. I'll also clear the Yarn cache with Yarn's own command, then reinstall.

Agent
Agent
Agent

Installed. Now typecheck and lint the web app.

Agent
Switched to <synthetic>
Agent

API Error: Can't reach the API server — check your internet or DNS (ENOTFOUND)

You

continue

Switched to claude-opus-5-5
Agent

Typecheck and lint both pass with no errors. Next I'll start the dev server. First I'm checking whether there's a local env file (I won't open it), because the page calls Auth0.

Agent
Agent
Agent
Agent
Agent

The page returns 200. Next I'll take screenshots at desktop and phone widths to compare against the design.

Agent
Agent
Agent
Agent
Agent
You

[Image: original 1440x4821, displayed at 597x2000. Multiply coordinates by 2.41 to map to original image.]

Agent
You

[Image: original 390x7003, displayed at 111x2000. Multiply coordinates by 3.51 to map to original image.]

Agent
Agent
Agent
Agent

The machine is slow under this load. I'll wait for that check to finish.

Agent
Agent

I'm still waiting on the final re-check (typecheck, lint and screenshots after the sample-player fix). I'll report as soon as it finishes.

You

<task-notification>
<task-id>bcli0tf3u</task-id>
<tool-use-id>toolu_01UfHPr3zhCMqeMirLKCqdtk</tool-use-id>
<output-file>[REDACTED]\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\54d1a7fa-712e-4a54-8760-ad71ded39900\tasks\bcli0tf3u.output</output-file>
<status>completed</status>
<summary>Background command "Recheck types, lint, and rerun the page check" completed (exit code 0)</summary>
</task-notification>

Agent
Agent

I freed about 5 GB on C: and the landing page is now running at http://localhost:3000.

Space freed: I only removed things that rebuild themselves: the npm cache and packages in the pnpm store that no project uses. C: went from 97 MB free to about 5 GB free after reinstalling. I didn't delete crash dumps because Windows protects that folder. I also didn't touch Downloads (2.6 GB), Docker (35 GB) or the Yarn cache (4.7 GB, Yarn isn't installed). If you need more room, Docker is the big one, and pruning unused images or volumes is a decision for you.

Checks:
- pnpm install completed, and typecheck and lint pass with no errors.
- I took screenshots at 1440 px and 390 px. They match the design, with no sideways scrolling on phones.
- I found and fixed one bug. When the sample mp3 is missing, the card was supposed to say "Sample episode coming soon", but it didn't. It does now.
- The only browser console error is the 404 for that missing sample-episode.mp3.

Placeholder Auth0 values: you have no apps/web/.env.local, so the dev server is running with fake Auth0 settings passed on the command line. The page displays fine, but "Start free" and "Sign in" won't work until you add real Auth0 values to apps/web/.env.local (see .env.example).

Still to fill in:
- Plus price: the pricing card shows [PRICE]. Set PLUS_PRICE in content.ts.
- Sample audio: add apps/web/public/sample-episode.mp3, then update each chapter's start time in content.ts to match it.
- Terms page: there isn't one yet, so the footer has no Terms link.

The changes aren't committed yet. Do you want me to commit and push them to your working branch?

You

what's a reasonable price to set?

also list out the required envs

Agent
Agent

What to charge for Plus: $12 a month, or $99 a year.

The PRD suggests $8–10 a month. At ElevenLabs list prices, that loses money on anyone who listens every day:

Plus listener ElevenLabs characters per month Cost at Flash ($0.04 per 1K) Cost at Studio ($0.08 per 1K)
5 min, daily ~135K ~$5 ~$11
10 min, daily (default) ~270K ~$11 ~$22
Hits the 350K cap 350K $14 $28

These assume about 900 characters per minute of speech, from PRD §8. Gemma, storage and the payment fee add a little on top. Two things bring the real cost down:
- Plus only makes an episode on days with new content, and most people won't hit the cap.
- ElevenLabs subscription tiers cost less per character than these list prices.

$12 covers a typical daily listener on Flash with some margin. Studio voices are what the pricing card advertises for Plus, and they cost twice as much. If you price under $15, I'd count Studio characters double against the 350K cap; otherwise a heavy Studio user costs you about $28 a month. Bachs can also charge in local currency, such as NGN, so you might set a lower regional price. Check current ElevenLabs prices before launch; the PRD's numbers are marked "verify".

Fix before shipping: you set PLUS_PRICE = "" in content.ts. Right now the Plus card shows only " / month" with no number. Set it to "$12" or whatever price you pick.

Environment variables

All of these go in apps/web/.env.local, and the worker needs them exported too. They come from .env.example.

Needed just to run the site and sign in
| Variable | Value |
|---|---|
| APP_BASE_URL | http://localhost:3000 locally; your real URL in production |
| AUTH0_DOMAIN | Your Auth0 tenant, e.g. your-tenant.us.auth0.com |
| AUTH0_CLIENT_ID | From your Auth0 application |
| AUTH0_CLIENT_SECRET | From your Auth0 application |
| AUTH0_SECRET | Make one with openssl rand -hex 32 |
| PG_URL | Postgres connection string; Tiger Cloud with ?sslmode=require in production. DATABASE_URL is only a fallback. |

Needed for the app to actually work
| Variable | What it's for |
|---|---|
| INBOUND_EMAIL_DOMAIN | The domain users forward newsletters to (in.getunread.site) |
| INBOUND_WEBHOOK_SECRET | Must match the secret set on the Cloudflare Email Worker |
| GEMINI_API_KEY or OPENROUTER_API_KEY | Writing episode scripts; you need at least one |
| STORAGE_DRIVER | local for development, S3/R2 in production |
| S3_BUCKET, S3_ENDPOINT, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY | Production only, when STORAGE_DRIVER is S3/R2 |
| ELEVENLABS_API_KEY | Plus and trial voices, Studio previews, sound effects |
| BACHS_API_KEY, BACHS_WEBHOOK_SECRET, BACHS_PLUS_MONTHLY_PRODUCT_ID | Plus billing |
| RESEND_API_KEY | Sending email |

Optional, or the defaults are fine
| Variable | Default or effect |
|---|---|
| SERPAPI_API_KEY | Only needed for the optional news topics |
| BACHS_PLUS_YEARLY_PRODUCT_ID | Only if you offer a yearly price |
| BACHS_API_URL | Sandbox by default; use https://api.bachs.io for live payments |
| TEMPORAL_ADDRESS, TEMPORAL_API_KEY | Empty means the worker checks for work on a loop instead of using Temporal |
| TEMPORAL_NAMESPACE, TEMPORAL_TASK_QUEUE | Sensible defaults are already set |
| ADMIN_EMAILS | Comma-separated emails that can open the admin page |
| GEMMA_MODEL, GEMINI_API_STYLE, OPENROUTER_MODEL | Model settings, already set |
| KOKORO_CONCURRENCY, MODEL_CACHE_DIR, FFMPEG_PATH | Voice and audio settings, already set |
| INBOUND_TRUST_UPSTREAM_AUTH | false; leave it |
| MAIL_FROM | Unread <[REDACTED]> |
| S3_REGION | auto |

To preview just the landing page locally, the five Auth0 variables plus APP_BASE_URL are enough. docs/deploy.md explains where to get each one.

You

can you get any of that for me?

Agent

I'll check what's already on this machine. That means environment variable names only (never values) and any CLIs that are already logged in.

Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent

Docker is up. Next I'll start Postgres 16 with pgvector on port 5432, using the same credentials as .env.example, then run the migrations.

Agent
Agent
Agent

The container is running (the first attempt failed while the engine was still warming up). Creating the test database and running migrations:

Agent
Agent
Agent

No, I can't get keys from the providers' websites. Each one needs you to log in, accept their terms and sometimes add a card. I don't have a browser session in your accounts, and making accounts for you isn't something I should do.

I set up everything that can live on your machine:

  • apps/web/.env.local: created from .env.example and git-ignored. I generated AUTH0_SECRET and INBOUND_WEBHOOK_SECRET, and pointed the storage and model folders at %LOCALAPPDATA%\unread. The secrets were written straight to the file and never shown.
  • Database: Postgres 16 with pgvector runs in Docker as unread-postgres. Both unread and unread_test are migrated (20 tables). The PG_URL in .env.local already points at it. Docker Desktop needs to be running for it to work.
  • Project memory: I added these local setup details to docs/project-memory.md.

What you need to get, in order of importance:

  1. Auth0, needed to sign in: go to manage.auth0.com, then Applications, then Create, and choose "Regular Web Application". Set:
    • Allowed Callback URLs: http://localhost:3000/auth/callback
    • Allowed Logout URLs: http://localhost:3000

Copy the Domain, Client ID and Client Secret into AUTH0_DOMAIN, AUTH0_CLIENT_ID and AUTH0_CLIENT_SECRET.
2. Gemini, needed to make episodes: create a key at aistudio.google.com/apikey and put it in GEMINI_API_KEY. It's free to start. OpenRouter (openrouter.ai/keys) is the backup option.
3. ElevenLabs, for Plus voices: your project notes say you already have a key, ending in a478, from your cloud environment. Paste it into ELEVENLABS_API_KEY.
4. Bachs, for Plus billing: get a sandbox key and webhook secret from your Bachs dashboard, then create a monthly Plus product there.
5. Resend (resend.com/api-keys) for email, and SerpApi (serpapi.com) for the news topics. You can leave both until later.

Cloudflare (inbound email), R2 storage and Temporal only matter when you deploy; docs/deploy.md covers them.

The dev server on :3000 is still running with the placeholder Auth0 values. Once you've added the Auth0 values, tell me and I'll restart it with your real settings and test signing in.

You

how will the inbound email be set up?

Agent
Agent

Incoming email goes through Cloudflare. Cloudflare receives mail for in.getunread.site, a small Cloudflare Worker (already in apps/inbound-worker) signs it, and the worker posts it to your web app. The code is written; what's left is account and DNS setup. ADR 0004 and docs/deploy.md §6 cover the details.

How a newsletter gets in

newsletter → Cloudflare MX for in.getunread.site
             (Cloudflare rejects mail that fails SPF/DKIM)
           → Email Worker "unread-inbound"
             (rejects mail over 2 MB and invalid addresses, signs the raw message with HMAC)
           → POST https://getunread.site/inbound/email
             (checks the signature, finds the user by alias, checks the sender is verified,
              parses the email, stores it, splits it into stories)

Each user gets an address like <alias>@in.getunread.site. Mail is only accepted from senders the user has verified; their sign-in email counts automatically. Gmail's forwarding-confirmation emails are recognised, so the confirmation code shows up in onboarding for the user to approve. Anything from an unverified sender is dropped before a model ever sees it.

Setup steps (about 15 minutes)

  1. Put getunread.site on Cloudflare. Add the domain to Cloudflare and change the nameservers at your registrar.
  2. Turn on Email Routing for the in subdomain. Go to Email → Email Routing. Cloudflare adds the MX and SPF records itself.
  3. Deploy the worker. sh cd apps/inbound-worker npx wrangler login npx wrangler secret put INBOUND_WEBHOOK_URL # https://getunread.site/inbound/email npx wrangler secret put INBOUND_WEBHOOK_SECRET # the same value as in apps/web/.env.local npx wrangler deploy
  4. Point all addresses at the worker. In Email Routing, go to Routing rules → Catch-all → "Send to a Worker" → unread-inbound.
  5. Test it. Forward a newsletter from your sign-in address to your alias. It should appear under Sources within a minute.

Things to know

  • The web app has to be online first. Cloudflare can't reach localhost. To test inbound before deploying to Render, run a tunnel such as cloudflared tunnel --url http://localhost:3000 and use that URL as INBOUND_WEBHOOK_URL. Switch it to the real domain later.
  • The secret must match on both sides. INBOUND_WEBHOOK_SECRET must be the same in the worker and the web app, or every message is rejected. I generated the web app's copy in .env.local, so copy that value into the worker.
  • Untested against real mail. The relay and parsing have tests, but no real email has gone through yet. ADR 0004 notes that the format of the authentication-results header needs checking on the first real message.

What I can do

Steps 1 and 2 happen in your Cloudflare dashboard and registrar, so those are yours. Once you've run wrangler login, I can do steps 3 and 4: set the secrets (including copying INBOUND_WEBHOOK_SECRET from .env.local without showing it) and deploy the worker. Deploying is an outward-facing change, so I'll wait for your go-ahead. I can also set up the tunnel so you can test inbound email before Render is live.

You

why is there a build error?
find n fix

<pasted_content id="f1a2">

unread@ db:migrate /opt/render/project/src
pnpm --filter @unread/db migrate

@unread/db@0.0.0 migrate /opt/render/project/src/packages/db
drizzle-kit migrate

No config path provided, using default 'drizzle.config.ts'
Reading config file '/opt/render/project/src/packages/db/drizzle.config.ts'
Using 'postgres' driver for database querying
[⣷] applying migrations...[⣯] applying migrations...[⣟] applying migrations...[⡿] applying migrations...[⢿] applying migrations...[⣻] applying migrations...[⣽] applying migrations...[⣷] applying migrations...[⣷] applying migrations.../opt/render/project/src/packages/db:
 ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL  @unread/db@0.0.0 migrate: drizzle-kit migrate
Exit status 1
 ELIFECYCLE  Command failed with exit code 1.
==> Pre-deploy has failed
==> Exited with status 1
==> Common ways to troubleshoot your deploy: https://render.com/docs/troubleshooting-deploys
</pasted_content id="f1a2">

Agent

Render runs migrations against Tiger Cloud right before deploying, and one of them fails. drizzle-kit hides the actual database error, so I'll work it out from the migrations. They applied cleanly on my local Postgres, which has pgvector but not TimescaleDB. Tiger has TimescaleDB, so the conditional hypertable migration (0002) is my first suspect.

Agent
Agent
Agent

No primary key on listening_events, so that common hypertable failure is out. Checking its indexes and the rest of the SQL for anything Tiger might reject:

Agent
Agent
Agent

No TypeScript runner is installed, so I'll write the script as plain ESM JavaScript. It runs on Render's Node 22 with nothing extra.

Agent
Agent
Agent
Agent
Agent

I couldn't reproduce the failure. All nine migrations apply cleanly on plain Postgres with pgvector, and also on Postgres with pgvector plus TimescaleDB. On the TimescaleDB one, listening_events became a hypertable correctly. So the SQL is fine.

The actual problem is that drizzle-kit migrate hides the database's error. Your log shows a spinner and "Exit status 1" and nothing else, so neither of us can see what Tiger rejected.

Fix: pnpm db:migrate now runs packages/db/scripts/migrate.mjs, which uses Drizzle's own migration runner and prints:
- which host it's connecting to (without the password);
- the Postgres version, and whether pgvector and TimescaleDB are available;
- the real error if it fails, including which stage failed (connecting or applying).

I tested both outcomes locally. A bad password now prints Migration failed while connecting: password authentication failed for user "unread", where before you'd have seen a silent exit 1.

Most likely causes, given the SQL is fine:
1. Missing or wrong PG_URL in Render. It needs to be the Tiger connection string with ?sslmode=require. Also, if PG_URL is empty, the script falls back to DATABASE_URL, which might point somewhere else.
2. Tiger's IP allow list blocks Render. Check the service's network settings in the Tiger console.
3. The Tiger database isn't empty, for example from an earlier manual attempt, so a CREATE TABLE fails with "already exists".

Next step: push this change and redeploy. The pre-deploy log will then name the exact error, and if it's one of the three above it's a settings change, not a code change. Changes aren't committed yet; you're on main, but your project notes say the working branch is claude/gifted-albattani-gk5mjz. Which branch does Render deploy from? Tell me and I'll commit and push the migration fix together with the landing page.

You

it failed again,

btw, is there password in this "[REDACTED]"

Agent
Agent
Agent

No, that URL has no password in it. That's exactly the error the new script reports: "SASL authentication failed" means Tiger asked for a password and none was sent.

A complete URL puts the password after the username:

postgres://tsdbadmin:<PASSWORD>@<host>:<port>/<db>?sslmode=require

Tiger's console never shows the password inside the copied URL. It shows it only once, when the service is created. If you don't have it, reset it from the service's settings in the Tiger console.

Please also use the direct connection rather than the pooler. The database name tsdb_transaction means this URL goes through Tiger's transaction-mode connection pooler. That's a problem for this app:
- Migrations are safer over a direct connection.
- The web app and worker use prepared statements, which a transaction-mode pooler breaks. The code supports turning them off (createDb({ prepare: false })), but nothing does that today. With this URL you'd swap the migration error for random query errors at runtime.

In the Tiger console, copy the service's direct connection string instead: database tsdb and the service's own port, not the pooler's.

What to do:
1. In Render's unread-shared env group, set PG_URL to the direct connection string with the password filled in. If the password contains characters like @ : / ? #, URL-encode them.
2. Redeploy. If something else is wrong, the pre-deploy log will now show the exact error.

Put the password only in Render. Don't paste it here.

You

how will those sample episaodes be generated? what's holding it?

Agent

I'll check what the episode pipeline needs and what's missing on this machine before answering.

Agent
Agent

Progress since last time: .env.local now has your OpenRouter and ElevenLabs keys (I checked names only, not values), and ffmpeg is installed. Checking what the make-episode command expects:

Agent
Agent

The sample episode would be made the same way a real user's is, by running the pipeline (pnpm make-episode --user <id>). Nothing has run it yet. Three things are missing, and one decision is yours.

How it would be made

  1. Content: put about six newsletter issues into the local database for a sample user. They should overlap on the same stories so the "Hear each story once" merging actually happens.
  2. pnpm make-episode --user <id>: this groups the stories, has Gemma pick and script the chapters (with a fact check), voices them, runs ffmpeg and saves the audio and chapter list to local storage.
  3. Landing page: copy the mp3 to apps/web/public/sample-episode.mp3. Then update the chapter titles, lengths and start times in content.ts from the real episode, so the landing page matches what people hear.

What's holding it up

Blocker Status
No content. Inbound email isn't deployed, and there's no seed script to load newsletters directly. I can build this: a script that feeds sample emails through the same parsing code used for real mail.
Rights. A public sample shouldn't read out real newsletters' text without permission. I'd write about six short fictional newsletters, with overlapping stories on AI pricing, a funding round and a design essay, matching the landing page's chapters.
No sample user. Users are normally created on first Auth0 sign-in, and Auth0 isn't set up. The seed script can create the user directly.
Gemma has never run for real. Your OpenRouter key is now in .env.local, so it can. This would be the first real run, and the prompts may need tuning.
Voice Kokoro runs free on this machine: it downloads about 300 MB of models (fine with 5 GB free) and is slowish on CPU. ElevenLabs sounds better but spends credits.
ffmpeg, database Both ready.

Your call: which voice should the sample use? Kokoro is free and is what Free users hear. ElevenLabs is what the Plus card advertises, and it costs about $0.35–0.70 of credits for a 9-minute episode at list prices. Your project rules say to ask before spending provider credits.

Say which voice and I'll do it end to end: write the sample newsletters, build the seed script, generate the episode, put it on the landing page and update the chapters.

You

before that,

I created an account n tried forwarding a newsletter to a generated email "[REDACTED]" but gmail said address not found?

why did happend

Agent

Gmail says "address not found" when the receiving side rejects the address or has no mail server at all. I'll check the domain's DNS and the worker's alias rules.

Agent
Agent
Agent

DNS is fine and your address is valid, so the problem is almost certainly in Cloudflare's routing: the catch-all rule isn't sending mail to the worker yet.

What I checked:
- DNS is correct. in.getunread.site has Cloudflare's three MX records and its SPF record, so Email Routing is on for the subdomain (steps 1 and 2 are done).
- Your address is valid. 5ggdfsvk83 passes the worker's alias check: 10 characters, none of the easily confused ones (i l o 0 1).

Why Gmail says "address not found": Cloudflare received the email, found no routing rule for 5ggdfsvk83@, and rejected it with "550 5.1.1 Address does not exist". That's what Gmail shows as "Address not found". Aliases are random, so the only rule that can match them is the catch-all. Either the catch-all is off, or it isn't pointed at a worker, or the unread-inbound worker was never deployed. Those are steps 3 and 4 of the setup.

If the worker had rejected it, the bounce would contain one of our own messages instead: "Unread could not accept this message" or "Temporary failure at Unread". Check the bounce text to be sure.

To fix it in the Cloudflare dashboard:
1. Workers & Pages: check whether unread-inbound exists. If not, it needs deploying (step 3 from earlier: set the two secrets, then npx wrangler deploy).
2. Email → Email Routing → Routing rules → Catch-all address: set the action to "Send to a Worker", choose unread-inbound, and make sure it's enabled.
3. Email Routing → Activity log: shows each message and what happened to it. Use it to confirm the next attempt.

I can't see your Cloudflare setup from here because wrangler isn't logged in on this machine. If you run npx wrangler login in apps/inbound-worker, I can check whether the worker exists, and with your go-ahead deploy it with the right secrets. The catch-all rule is a toggle in the dashboard, so that one's quickest done by you.

Also check INBOUND_WEBHOOK_URL. It must point at the live web app (https://getunread.site/inbound/email) and the web app must be deployed. Render's pre-deploy was failing on the database earlier, so if the site isn't up yet, the next bounce will say "Temporary failure at Unread" instead. Fix PG_URL first so the deploy goes through.

You

it works now, but I haven't being able to get a newsletter yet,

also, check if the /today page has samples datas

Agent

Two things to check: why the forwarded newsletter isn't showing up, and whether /today has hardcoded sample data. Starting with the code for both pages:

Agent
Agent

/today loads everything from the database (loadHome). Checking the empty-state components, since that's what you're seeing now:

Agent
Agent
Agent
Agent
Agent

I found a real bug that very likely explains this. Reading the full user-loading code before fixing it:

Agent
Agent
Agent
Agent
Agent
Agent
Agent

Running typecheck, lint and the web tests:

Agent
Agent

All clean. I'll also check the two SQL statements against the local database: a pending sender with different capitalisation should get verified, and the insert shouldn't create a duplicate.

Agent
Agent

/today: there's no sample data. Everything it shows comes from your account in the database. Until your first episode exists, you'll only see the "No episode yet" card, your forwarding address and a short note on how each morning's run works.

Why your newsletter isn't arriving: I found a bug that very likely explains it. Your sign-in email was added to your allowed senders only at the moment your account was first created, and only if Auth0 already said the email was verified. If you signed up with email and password, you hadn't clicked Auth0's verification link yet at that point. The app never checked again afterwards. So your address never became an allowed sender, and each forwarded newsletter was quietly dropped as "unverified sender".

The fix is in current-user.ts. The check now runs on every signed-in visit: as soon as Auth0 says your email is verified, it becomes an allowed sender. If the same address was already waiting for a code in Sources, that gets verified too. Typecheck, lint and the web tests pass, and I tested the two database statements on the local test database: a pending address with different capitalisation got verified, with no duplicate row.

For you to do:
1. Deploy the fix. It isn't committed yet; tell me which branch Render deploys from and I'll commit and push it with the landing page and migration changes.
2. Make sure you've clicked Auth0's verification email.
3. Sign out and sign back in. Your current session still carries the old "not verified" status.
4. Forward the newsletter again from your sign-in address. Earlier forwards were dropped, not queued, so they won't come back by themselves.

If it still doesn't show up, look at Sources. It displays the reason for the latest rejected message:
- "An address that isn't on your list": you forwarded from a different address than the one you sign in with. Add that address in Sources.
- "We couldn't verify (SPF/DKIM failed)": this would be a second problem. The check that the email really came from your address hasn't been tested against real Cloudflare mail yet. Tell me if you see this one and I'll look at how Cloudflare formats those authentication headers.

You

when does a podcast get generated?

Agent

I'll check the scheduling code rather than go from the PRD.

Agent
Agent
Agent
Agent

An episode gets made in one of three ways. In every case, the worker on Render has to be running.

1. On your schedule (automatic). Every minute the worker checks for users whose delivery time is coming up. It starts making the episode 30 minutes before your delivery time, in your timezone, so it's ready on time.
- Free plan: weekly, on the day you picked. Plus plan: every day.
- The default delivery time is 7:00. It's changed in Settings, and /today shows the current schedule ("every Monday at 7:00").

2. "Make my first episode" at the end of onboarding. This starts one straight away.

3. "Make one now" on /today. This button appears once you have at least 2 unused newsletters or links waiting. The number of extra episodes per day is limited by plan.

When nothing gets made: if there isn't enough new content when the run starts (for example, nothing forwarded since your last episode), the run is skipped and costs nothing. /today then says "Not enough new content yet. Forward a couple of newsletters and we'll make the next one."

In your case: your first forward was probably dropped by the sender-verification bug, so the episode from onboarding would have found nothing and been skipped. After the fix is deployed and you forward again, either wait for your next scheduled time or use "Make one now" once two items are waiting.

Check that the worker is deployed. In Render, the unread-worker service (Docker) must exist and be running. Without it, requests pile up and nothing is ever generated. It also needs the shared env group, including PG_URL and your OpenRouter or Gemini key.

You

what's the promo code to get plus?

Agent
Agent
Agent

There's no promo code yet. None ships with the app or the migrations; you create your own. Two ways:

Option A: the admin page (easiest)
1. In Render, set ADMIN_EMAILS to your sign-in email and redeploy. The address must be verified in Auth0.
2. Open https://getunread.site/admin, go to the "Promo codes" section and create one. For example: code HACK2026, 14 days, 3 episodes.

Option B: the command line, pointed at the production database:
sh
PG_URL="<your Tiger URL>" pnpm promo create --code HACK2026 --days 14 --episodes 3 --max 200 --expires 2026-12-31
PG_URL="<your Tiger URL>" pnpm promo list

To redeem a code: go to Settings, then "Have a promo code?". It gives Plus for the number of days you set, with no card.

Promo trials work a little differently from paid Plus:
- Episode limit: the number of Studio episodes is capped by --episodes (3 by default).
- Voice: trials use ElevenLabs' Standard voice. The higher-quality Studio voice is only for paid Plus.
- Verified email: redeeming requires a verified email. With the sign-in fix above, that means signing out and back in after you click Auth0's verification link.

I can't create a code from here because I don't have your production database URL, and that would be a write to your live database. Option A keeps the password out of your terminal history, so I'd use that.

You

'PG_URL' is not recognized as an internal or external command,
operable program or batch file.

Agent

That PG_URL=... command syntax only works in Mac/Linux shells. You're in Windows Command Prompt, which needs a different form. From the repo root ([REDACTED]\hacks\unread):

Command Prompt (cmd):
bat
set "PG_URL=[REDACTED]"
pnpm promo create --code HACK2026 --days 14 --episodes 3 --max 200 --expires 2026-12-31
pnpm promo list

PowerShell:
powershell
$env:PG_URL = "[REDACTED]"
pnpm promo create --code HACK2026 --days 14 --episodes 3 --max 200 --expires 2026-12-31
pnpm promo list

A few things to watch:
- Keep the quotes exactly as shown. In cmd they go around the whole PG_URL=... part, which stops the & or ? in the URL from being misread.
- Use the direct connection URL (database tsdb), not the tsdb_transaction pooler, and include the password.
- Special characters in the password: if it contains @ : / ? # or %, URL-encode them. For example, @ becomes %40.
- The setting lasts only for that terminal window. Close it afterwards so the password doesn't stay around.

If you'd rather not handle the database URL at all, Option A from before is simpler: set ADMIN_EMAILS in Render and create the code at /admin.

App UX and audio robustness claude-opus-5-5
You

how can this app's UX be improved? how is the audio implementation (rendering, playback, etc)? are they robust enough?
is the application robust enough with smooth UX?

Agent

I'll look through the web UI and the audio pipeline before giving you a verdict.

Agent
Agent
Agent
Agent
Agent
Agent
Agent

Audio core is clear. Now the player UI and main screens.

Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent

I've finished reading the code. Here's my review.

Short answer

The structure is sound: one global <audio> element, chapter and line timings taken from real clip lengths, a voice cache, −16 LUFS loudness, and Media Session support. But it isn't robust yet. Three problems can leave a user stuck or confused with no way out, and the player has no loading or error states. I don't think any of these is big, but I'd fix the top group before the Oct 26 deadline.

Must fix (a user gets stuck or misled)

  1. Requests can stay "running" forever in poller mode. In poller.ts, if the worker crashes or redeploys mid-episode (Render sends SIGKILL after its grace period), or if startEpisode throws (it runs outside the try in make-episode.ts:14 and nothing in the poller loop catches it), the request stays running and nothing ever resets it. After that:
    • Today shows "Making your episode…" forever, and AutoRefresh keeps reloading the page every 5 s.
    • requestEpisodeNow keeps returning that dead request (requests.ts:71), so the user can never make another episode.

Fix: have the hourly maintenance job mark requests that have been running for more than about 45 minutes as failed. Wrap the poller's loop body in try/catch. Add a "taking longer than usual" message after about 15 minutes.

  1. Poller mode has no retries. One brief Gemma, ElevenLabs or DB error fails the whole episode. Temporal mode retries each step, the poller doesn't. The voice cache would make a retry cheap, so retrying voiceEpisode and prepareScript 2–3 times with backoff costs little.

  2. "Not enough content" never tells the user anything. prepareScript marks the request skipped, but loadHome only reports failed (queries.ts:63). So the "No new episode this time" branch in failure-banner.tsx never shows. The user taps "Make one now", sees progress, and then the card goes back to how it was with no explanation. The same happens when a plan or budget block skips a request.

  3. Free users can't use "Try again". Free users get 1 manual request a day, and the failed request counts against it (requests.ts:60). Clicking Try again after a failure returns "You can make 1 extra episode a day". Failed requests shouldn't count.

Audio playback (web player)

  1. No error, loading or buffering state. player-context.tsx doesn't listen for error, waiting, stalled or canplay, and play() failures are silently ignored. If the audio 404s, the network drops, or the 6-hour signed R2 URL expires, the play button just doesn't respond. Add a status: "loading" | "ready" | "error" value, a spinner on the play button, and a "Couldn't load audio. Retry" message. On an error, retry once by setting audio.src again; that also gets a fresh signed URL.

  2. Playback speed resets on a new episode. Changing src resets playbackRate to defaultPlaybackRate. The button still shows 1.5× while the audio plays at 1×. Set audio.defaultPlaybackRate along with playbackRate, and save the rate in localStorage.

  3. Resume doesn't work reliably. Setting currentTime right after src (line 100) is ignored by Safari/iOS. Apply the start position in a one-time loadedmetadata handler instead. The position is also only saved every 5 s, and not on pause or visibilitychange.

  4. Lock-screen controls. The play and pause handlers both call toggle(), so if the state gets out of sync, pressing pause on the lock screen starts playback. Call audio.play() and audio.pause() directly. There's also no setPositionState, so the lock-screen scrubber and time are wrong.

  5. The app re-renders 4 times a second. currentTime is in the context value, so every usePlayer() consumer (episode card, chapter list, transcript, mini player, both bars) re-renders on every timeupdate. Move the time into a separate context or a useSyncExternalStore subscription.

  6. The transcript auto-scroll fights the reader. scrollIntoView({behavior:"smooth"}) runs on every line change (transcript-view.tsx:17). Pause auto-scroll when the user scrolls by hand, and show a "Jump to current line" pill, as Spotify and Apple do.

Audio rendering (worker)

  1. Kokoro may cut off long lines. Script lines can be up to 400 characters (types.ts:41). Kokoro's limit is about 510 phoneme tokens, and numbers, URLs and acronyms expand a lot when spoken. I believe kokoro-js generate() truncates silently rather than splitting, but I haven't confirmed it. Either split lines at sentence boundaries before synthesis, or test with a 400-character line full of numbers.

  2. The MP3 header may be wrong. ffmpeg writes the MP3 to a pipe, so it can't go back and finish the Xing/Info header. Constant-bitrate 96k means most players estimate duration correctly, but some podcast apps and Safari can show a wrong length or seek imprecisely. Encode to a temp file and read it back.

  3. Loudness. Single-pass loudnorm adjusts volume as it goes and can "pump" between clips. A two-pass run (measure first, then apply with linear=true) gives steadier speech, and it's cheap at these lengths.

  4. Smaller points:

    • When one clip fails in mapLimit, the other workers keep synthesising (and spending ElevenLabs credits) after the step has already failed. Add an abort flag.
    • Chapter titles are spoken as ${title}., so a title ending in "?" becomes "?.".
    • Newsletter clutter reaches the titles: the screenshot shows "…passes a milestone (6 minute read)". Strip patterns like (N minute read) and Sponsored.

What already works well: line and chapter timings come from real clip lengths, so the transcript highlight stays in sync. The cache key covers provider, model, voice and text. The S3/R2 redirect supports Range requests, which podcast apps need, and the local fallback handles Range itself.

UX improvements (beyond fixes)

  • No episode history in the web app. Today shows only the latest episode, and recentEpisodes() exists but nothing uses it. Add a "Previous episodes" list under the chapter list, or a Library tab.
  • Show progress and resume on the episode card. An episode that isn't loaded always shows 0:00, even when a saved position exists. Show "Resume from 4:12" and a "Played" mark.
  • Better in-progress feedback. The voice step already sends {done, total} but the UI doesn't show it. "Voicing 23 of 61 lines" plus an estimated time remaining would beat a static step list. Also stop polling while the tab is hidden.
  • Now Playing accessibility. It has role="dialog" but doesn't move focus into the dialog, trap it there, or return it on close. The intro shows as one block of text and the outro never appears in any transcript.
  • Desktop chapter clicks. Clicking a chapter on desktop only shows its transcript. A small play-from-here button on each row would help, since right now you have to click a transcript line to jump.
  • Keyboard shortcuts are global and only skipped inside form fields. Watch for clashes with the range-input seek bar when it has focus: arrow keys there move by 1 s and also skip 15 s.

Suggested order before the deadline

Items 1, 3, 4, 5 and 6 (each is small, and together they remove every dead end), then 2 and 7. Items 11–13 only matter after real Gemma and SFX runs. I couldn't test any of this end to end here: there's no Gemma key and no deployed worker, so these findings come from reading the code, not from running the app.

Do you want me to implement the must-fix group and the player fixes on a branch?

You

fix them all

Agent

I'll work on a new branch. First, the remaining files I need.

Agent
Agent
Agent

Now checking whether Kokoro actually truncates long text.

Agent
Agent

Confirmed: kokoro-js calls the tokenizer with truncation: true, so long lines are silently cut. Let me check the existing tests and the script prompt before editing.

Agent
Agent

Kokoro truncation is real, so splitting is needed. Reading the last few web files I'll touch.

Agent
Agent

I have everything I need. Starting with the worker side: text splitting for Kokoro.

Agent
Agent
Agent
Agent

Now the assembler: two-pass loudness and a file-based MP3 encode so the Xing header is correct.

Agent
Agent
Agent
Agent

I'll rewrite the ffmpeg helper and the encode step of assemble.ts.

Agent
Agent
Agent
Agent

Next: step-tracker progress reporting, then the voice step.

Agent
Agent
Agent
Agent
Agent

Now strip newsletter clutter from chapter titles in the script writer.

Agent
Agent
Agent
Agent
Agent
Agent

The backslashes got eaten by the template literal. Fixing that block directly.

Agent
Agent

Now request handling: don't count failed requests against the daily limit, and add stale-request recovery.

Agent
Agent
Agent
Agent

Making startEpisode reuse an existing episode for a requeued request, then adding retries to makeEpisode.

Agent
Agent
Agent
Agent

Per the project rule (3+ params → options object), I'll put retry settings on EpisodeDeps instead of adding a third parameter.

Agent
Agent

Deps edit didn't apply (CRLF likely); using Edit.

Agent
Agent

Now the poller and maintenance.

Agent
Agent

Core changes done. Typechecking before moving to the web side.

Agent
Agent

Now the web side. First loadHome: skipped notices, start time, voice progress, and history.

Agent
Agent
Agent
Agent

Now the notice banner, in-progress card, and auto-refresh.

Agent
Agent
Agent
Agent
Agent
Agent
Agent

Two fixes: retry must not depend on currentTime (that would defeat the context split), and the event effect shouldn't re-subscribe on every play/pause.

Agent
Agent

Now the consumers. First, icons and the play button with loading/error states.

Agent
Agent
Agent

Now progress bar, mini player, and player bar.

Agent
Agent

Now the transcript with follow/unfollow auto-scroll, shared by chapters and the intro/outro.

Agent
Agent
Agent

Now Now Playing: focus moves in, Tab stays inside, focus returns on close, and the intro and outro get their own transcripts.

Agent
Agent

Now the episode card (resume position, played state) and chapter list (desktop play-from-here).

Agent
Agent

Now an episode history list for Today.

Agent
Agent
Agent

Now wiring it all into the Today page.

Agent
Agent

When an episode is in progress, the latest card is hidden, so history should include it.

Agent
Agent
Agent

Lint and typecheck pass. Now tests: unit tests for the new pure helpers, and DB tests for the request recovery. Checking the test helpers and what's available locally.

Agent
Agent

Writing tests. ffmpeg and the Docker Postgres are both available locally, so I can run the integration tests too.

Agent
Agent
Agent
Agent
Agent

All 15 new tests pass against real ffmpeg and Postgres. Now the full suite, including the end-to-end Kokoro + ffmpeg episode test, since I changed both.

Agent
Agent

No cached models here, so the end-to-end test will download EmbeddingGemma and Kokoro (a few hundred MB; you have 7 GB free). Running the full suite in the background.

Agent
Agent

While that runs, I'll check whether the repo has docs that describe these behaviours and need updating.

Agent
Agent
Agent
Agent
Agent
Agent

I'd like to check the UI in a real browser too. Checking whether Playwright is available on this machine.

Agent
Agent

Playwright's browsers are installed. Checking the local dev DB for the smoke user so I can drive the real UI.

Agent
Agent

The dev DB is empty, so I'll seed a smoke user with two real episodes (tone audio built by the new assembler), plus a skipped request to show the new notice.

Agent
Agent
Agent
Agent
Agent

Seeded. Starting the dev server against the local DB.

Agent
Agent
Agent

No Playwright package installed, only its browser. I'll put playwright-core in the scratchpad and point it at that Chromium.

Agent
Agent
Agent
Agent
Agent

Three end-to-end tests failed. Looking at why before going further.

Agent
Agent
Agent
Agent
Agent
Agent

Now the browser check script: phone and desktop screenshots, playback, Now Playing focus, rate persistence, resume after reload, and a forced audio failure.

Agent
Agent
Agent

Mostly good, but my resume check clicked the wrong button: /Play .* episode$/ also matches "Play today's episode". Tightening that selector and re-running to see the full output.

Agent
Agent

Everything checks out in the browser, including resume (21 s, then 23.9 s after 2 s at 1.5×). Now the screenshots.

Agent
You

[Image: original 780x2446, displayed at 638x2000. Multiply coordinates by 1.22 to map to original image.]

Agent
Agent
Agent
Agent
Agent

Committing to the branch while the model-dependent tests re-run.

Agent
Agent
Agent

Pushed. The re-run of the two model-dependent test files is still going. I'll report its result when it finishes.

Agent
Agent

I've fixed everything from the review and pushed it to the fix/robustness-ux branch on GitHub. Typecheck, lint and next build all pass. Of the 58 core tests, 55 pass. The 3 failures are the end-to-end tests that need real models, and they failed because the Hugging Face model downloads timed out on your machine, not because of the code. I'm re-running those 3 now and will report back.

Checked in a real browser: I added a test user and two episodes with generated tone audio to your local Docker database, then drove the app in headless Chromium on port 3100 (phone and desktop sizes). It ran against the local database only, never Tiger. Your dev server on port 3000 was left alone. Things that worked:
- The "No new episode this time" notice and the new "Earlier episodes" list show.
- Now playing puts focus on the close button, keeps Tab inside the player, and gives focus back on close.
- Speed 1.5× is kept when switching to another episode.
- An episode stopped at 0:21 shows "Resume from 0:21" after a reload and resumes there.
- When every audio request fails, the player retries once, then shows "Couldn't load the audio" with a retry button. Retrying after the network came back worked.
- On desktop, the play button on a chapter starts it at the right time.

Stuck or confusing states
- Stuck episodes: when the worker starts, any request it was in the middle of goes back in the queue and continues from where it stopped. The hourly maintenance job marks a request as failed after 2 hours running or 6 hours waiting.
- Retries without Temporal: each step is retried 3 times with increasing waits, except when the Studio voice isn't set up.
- "Make one now": a failed attempt no longer uses up the daily allowance, so "Try again" works for Free users.
- Today page:
- It now says why no episode was made (not enough new content, or a plan limit).
- It shows "Waiting to start…" before the run begins, and "23 of 61 lines" while recording the voice.
- After 15 minutes it says the run is taking longer than usual.
- It stops refreshing while the tab is hidden.

Audio rendering
- Kokoro cutting off lines: I confirmed in the kokoro-js source that it silently cuts long text. Each line is now split into chunks of up to 220 characters and joined with a short pause.
- Volume: loudness is now measured first and then applied evenly across the episode. Pure silence still encodes.
- MP3 header: the MP3 is now written to a temporary file, so its duration and seeking information is correct. A test checks this.
- Smaller fixes:
- No more clips are started after one fails.
- Titles like "(6 minute read)" and "Sponsored" are removed.
- A title ending in "?" no longer gets an extra full stop.

Player
- Loading and errors: the play button shows a spinner while loading or buffering, and a retry icon after a failure.
- Speed and resume: speed is remembered between episodes and visits, and resuming at the saved position now works in Safari.
- Saving position: your place is saved when you pause or switch away.
- Lock screen: play and pause are separate actions, and the lock screen shows the right time and scrubber.
- Performance: the time display updates on its own, so the whole app no longer re-renders 4 times a second.
- Transcript: it stops auto-scrolling when you scroll yourself and shows "Back to the current line". The intro and outro now have transcripts too.
- Today page: older episodes are listed with resume and played state, and the episode card shows a resume label.

New tests cover each of these: line splitting, title cleanup, the retry helper, encoding with real ffmpeg, and the stuck-request handling against Postgres. I also updated the voices decision record (0006) and docs/project-memory.md.

Things you should know:
- Rotate the Tiger database password. I tried to hide it when printing apps/web/.env.local, but the hiding didn't work on the quoted PG_URL, so the Tiger Cloud password appeared in my tool output. It went nowhere else.
- That file points at Tiger, not your local database. A dev server started without overriding PG_URL talks to Tiger.
- The local dev database now has a test user (auth0|u0-smoke) with two test episodes. I left it there for future UI checks.
- Not tested against a deployed worker or a real iPhone. Safari resume and the lock-screen controls only have desktop-Chromium coverage so far.

You

<task-notification>
<task-id>b29b3j16v</task-id>
<tool-use-id>toolu_01T81DujTNHR7egSNMPLPftv</tool-use-id>
<output-file>[REDACTED]\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\b29b3j16v.output</output-file>
<status>completed</status>
<summary>Background command "Re-run model-dependent e2e tests" completed (exit code 0)</summary>
</task-notification>

Agent
Agent
Agent
Agent

The 3 end-to-end tests still fail on the re-run. The cause is a broken model download, not the code. The first download timed out partway, leaving a truncated EmbeddingGemma file (96 MB of a much larger file), and later runs tried to load it.

I've deleted the partial file. In the background I'm downloading the models again with the repo's warm-models command, then re-running those 3 tests. I'll post the result when it finishes. If your network times out on Hugging Face again, they'll fail the same way.

Nothing else has changed: the 55 other tests pass, along with typecheck, lint, next build and the browser check. The code is pushed on fix/robustness-ux.

You

<task-notification>
<task-id>b6odq4zhz</task-id>
<tool-use-id>toolu_019Yn1qdBXqJY3HXfv51NUCV</tool-use-id>
<output-file>[REDACTED]\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\b6odq4zhz.output</output-file>
<status>completed</status>
<summary>Background command "Clear partial model, warm models, rerun e2e" completed (exit code 0)</summary>
</task-notification>

Switched to <synthetic>
Agent

API Error: Connection dropped (ECONNRESET)

You

contnue

Switched to claude-opus-5-5
Agent
Agent
Agent

Files needed: EmbeddingGemma's .onnx + .onnx_data (309 MB), and Kokoro's configs + model_quantized.onnx (92 MB). Downloading with resume-on-failure in the background.

Agent
Agent

The model files are downloading in the background with curl, which picks up where it left off when the connection drops. EmbeddingGemma is about 309 MB and Kokoro about 92 MB. When that finishes I'll check the sizes, then re-run the 3 end-to-end tests. Everything else is unchanged: the code is pushed on fix/robustness-ux and all the other checks pass.

You

<task-notification>
<task-id>b6ls0irs5</task-id>
<tool-use-id>toolu_013k9WivsfXrs6Ygb2enGJRA</tool-use-id>
<output-file>[REDACTED]\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\b6ls0irs5.output</output-file>
<status>completed</status>
<summary>Background command "Download model files with resumable curl" completed (exit code 0)</summary>
</task-notification>

Agent
Agent

All sizes match. The small Kokoro configs look right, but I'll check they aren't error pages, then run the tests.

Agent
Agent
Agent

Both configs are valid. The tokenizer's model_max_length: 512 also confirms the Kokoro truncation limit that the line splitting works around. The models are now fully cached, and the end-to-end tests are running real Kokoro voice generation on your CPU. They passed the 10-minute mark and are still going, so I'll report when they finish.

You

<task-notification>
<task-id>bqmjfpdyi</task-id>
<tool-use-id>toolu_01EwL9x6Yhh6BjWt9rYEXXhx</tool-use-id>
<output-file>[REDACTED]\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\bqmjfpdyi.output</output-file>
<status>completed</status>
<summary>Background command "Verify configs and rerun e2e tests" completed (exit code 0)</summary>
</task-notification>

Agent
Agent

All 3 end-to-end tests now pass with the real models. I'll add the download workaround to the project notes so the next session doesn't hit the same problem.

Agent
Agent
Agent

All 58 core tests now pass, including the 3 end-to-end tests that make complete episodes with real EmbeddingGemma, Kokoro and ffmpeg. Those episodes go through the new line splitting, two-pass loudness and file-based MP3 encoding.

The earlier failures were caused by your network, not the code. Hugging Face kept dropping the connection mid-download, which left a truncated model file. I downloaded the model files with curl, resuming each time the connection dropped, into the same cache folder the app uses.

Both commits are pushed to fix/robustness-ux: the fixes, plus a short note in docs/project-memory.md on how to recover from a broken model download.

Still open:
- Database password: the Tiger Cloud database password showed up in my tool output earlier, so I'd still rotate it.
- Not yet tested on real devices: resume in Safari and the lock-screen controls on an iPhone, and the stuck-request recovery against a deployed worker.
- Merging: the branch isn't merged into main. Say if you want me to open a pull request or merge it.

You

can our project work offline? and/or run on a laptop?

Agent

I'll check a few specifics in the code before answering.

Agent
Agent
Agent

Yes, it runs on a laptop. That's how it runs now on your Windows machine: Postgres in Docker, files stored on disk, the web app via pnpm dev, and the worker without Temporal. The end-to-end tests we just ran made complete episodes there.

Fully offline: not today. Three things still need the internet, and one of them is the core feature.

Works offline (once the models are downloaded)

Part Why
Postgres, local storage Docker and the local disk
EmbeddingGemma (merging stories that several newsletters covered) Local ONNX model, about 400 MB cached in %TEMP%/unread-models
Kokoro voice Local ONNX model; the voice files ship inside the npm package
ffmpeg assembly, covers Local
Web player, transcript, chapters, feed served from localhost Local
Worker without Temporal No external service needed

Needs the internet

Part Service Can it be avoided?
Writing the script Gemma 4 31B via the Gemini API or OpenRouter. The two web addresses are hard-coded in model/index.ts. This is the real blocker. Without it no episode can be made. It's fixable, see below.
Signing in Auth0's hosted login page Only with a local dev sign-in
Receiving newsletters Cloudflare Email Routing → a public /inbound/email address Offline you'd need another way to add them, such as importing .eml files
Saved links, news Fetching web pages, SerpApi Optional features
Studio voices, billing, email ElevenLabs, Bachs, Resend Optional; Free uses Kokoro

On a laptop with internet

It works fully. Two extras are needed:
- A public address for incoming email. Cloudflare has to reach /inbound/email, so you'd need a tunnel such as cloudflared tunnel.
- The same tunnel for podcast apps, so they can fetch the feed. The web player works on localhost without it.

Laptop resources:
- Memory: about 1 GB for EmbeddingGemma.
- Disk: about 400 MB of models, plus Docker.
- Time: voice generation on the CPU takes a few minutes for a 5–10 minute episode.

Making it work offline

These are small changes:
1. Local model option. The model client already speaks the standard OpenAI chat API, so a new LOCAL_MODEL_BASE_URL setting could point it at Ollama or llama.cpp running Gemma on the laptop. The catch is that the 31B model is too big for most laptops. A quantized Gemma 12B (or 4B) would run, but scripts would be weaker and more of them would fail the JSON format or the fact check. The fact check would drop bad lines rather than let them through, so episodes stay honest but may come out shorter.
2. Dev sign-in. A local-only login that bypasses Auth0. It would be switched off in production.
3. Import newsletters. A CLI or upload that feeds .eml files through the same processing as forwarded email.

Do you want me to build any of these? For the hackathon demo, only #1 matters if you want to show it offline. #2 and #3 only matter for using it with no network at all.

You

btw, add an MIT Lincense

Agent
Agent

No license anywhere yet. Checking the README and root package.json so I can reference it in both.

Agent
Agent
Agent
Agent
Agent

The worker's Docker image bakes the models in, so "not included here" is only true for the repo. Making that precise, then committing.

Agent
Agent

I added the MIT license and pushed it:

  • LICENSE: the standard MIT text, "Copyright (c) 2026 Lucky Victory".
  • package.json: "license": "MIT" in the root file.
  • README.md: a new License section. It notes that the two voice and text models keep their own licenses: Kokoro-82M is Apache-2.0 and EmbeddingGemma uses Google's Gemma terms. Neither model is stored in the repo.

It's committed on fix/robustness-ux, the branch we've been working on, so it reaches main when that branch is merged. If you want the license on main right away instead, I can cherry-pick it.

You

merge to main n push

Agent
Agent

Clean tree, and main hasn't moved since I branched, so this is a fast-forward.

Agent
Agent

I merged fix/robustness-ux into main and pushed it to GitHub. No one else had pushed to main in the meantime, so it went straight on with no merge commit or conflicts.

main now has the three new commits:
- 73fa6a7: the robustness and UX fixes.
- b3a3494: the note on recovering from broken model downloads.
- 5d5dd58: the MIT license.

I left the fix/robustness-ux branch in place, locally and on GitHub. Say if you want it deleted.

You

btw, let's talk about this,
since emails are often unique, can we use the users' email starting (before the @) to generate the forwarding email for them? while also keeping the option to regenerate

Agent

I'll look at how aliases are made and rotated now before weighing in.

Agent
Agent
Agent

Yes, this works well. The setup we already have makes it safe, but I wouldn't use the email prefix on its own. Add a short random suffix, for example [REDACTED] instead of [REDACTED].

Why not the bare prefix

  1. Prefixes aren't unique. [REDACTED] and [REDACTED] both become john. The fallback would be john2, john3…, which tells people how many users you have and is easy to guess.
  2. It would reveal who uses Unread. If I know your email, I know your Unread address. Our inbound gate (verified sender plus SPF/DKIM) stops a stranger from injecting content. But if the Email Worker treats known and unknown addresses differently, an attacker can check whether someone is a user. They can also flood that address with junk, and every rejected message still gets logged.
  3. Some prefixes are bad slugs. Examples:
    • Apple's Hide My Email addresses look like [REDACTED].
    • Some prefixes are very long (firstname.middlename.lastname.work).
    • Some are role or reserved words (admin, postmaster, abuse, support).
    • Some contain characters we don't allow.

A 4-character suffix fixes all three. It gives about 920k combinations for each name, so collisions are rare and the address can't be guessed. It stays easy to read out and recognise in Gmail's forwarding settings.

What I'd build

  • Format: <slug>.<4 chars>. The slug comes from the part of the sign-in email before the @:
    • lowercase it;
    • drop any +tag;
    • keep only a-z0-9;
    • cap it at about 16 characters.

The suffix uses the existing alphabet, which leaves out look-alike characters such as l/1 and o/0.
- Fall back to fully random (today's style) when the slug is under 2 characters, is a reserved word, or looks machine-made (Apple's private relay domain, a long hex string).
- Regenerate offers two choices:
- "New address": same name, new suffix (lucky.p3wd).
- "Use a random address": for people who don't want their name in it.
- Collisions: retry with a new suffix if the database's uniqueness rule rejects it.
- Compatibility: the inbound parser currently accepts only the 10-character random format, so it has to accept both formats. Existing users keep their current addresses. Plus-addressing (lucky.k7mq+news@) keeps working.
- Tests for the slug rules, the fallbacks and parsing both formats.

When someone regenerates, the old address dies at once. Their Gmail auto-forward then fails silently: Gmail keeps sending to an address we reject. Two ways to deal with that:
- Grace period: accept the old address for about 7 days.
- Clear warning: keep immediate rotation, but tell the user to update Gmail forwarding and confirm it again.

What I need from you

  1. Shall I build it as described?
  2. For rotation: grace period or clear warning? I'd lean towards the warning. A grace period means storing old aliases, and the main reason to rotate is spam, where you want the old address dead right away.
Video and Write Up Session claude-opus-5-5
You

h

Agent

Your message was just "h". I'm guessing it went out by accident.

What would you like to work on? From the project memory, these are still open:

  1. Database: check whether Tiger Data (PG_URL) is reachable from this fresh session, then run pnpm db:migrate.
  2. Gemma: once a GEMINI_API_KEY or OPENROUTER_API_KEY is set, run pnpm brief on real newsletters.
  3. Landing page placeholders: the Plus price is still a placeholder, there's no sample episode audio yet, and there's no Terms page.
  4. Accounts and deploy: Auth0, Cloudflare Email Routing, R2, Render, Bachs, and the rest. I'd ask before anything outward-facing.

The submission deadline is Mon Oct 26, 07:59 WAT.

You

Pull main

Agent
Agent
Agent

I pulled main into claude/nifty-galileo-tinqrd. It was a fast-forward with no conflicts, so the branch now sits at 183aca6.

It brought in three new commits:
- Migrations: drizzle-kit migrate is replaced by a custom script, packages/db/scripts/migrate.mjs.
- Landing page: the Plus plan now has a default price.
- Sign-in: user loading and the email trust logic are combined in apps/web/src/features[REDACTED].

Nothing is pushed yet; your project notes say to push after each milestone rather than straight away.

You

Stop hook feedback:
[~/.claude/stop-hook-git-check.sh]: There are 3 unpushed commit(s) on branch 'claude/nifty-galileo-tinqrd'. Please push these changes to the remote repository.

Agent
Agent

I pushed claude/nifty-galileo-tinqrd to the remote as a new branch. It's the same as main, so it doesn't add any changes yet. I didn't open a pull request: there's no difference from main to review, and your notes say no PRs unless you ask for one.

You

[image omitted from the upload]

[image omitted from the upload]

Now I need you to make a video, but this isn't going to be just a demo video,
It has to tell a story (see image), starting with the story behind the idea, the tools used, where they were used, and/or how they were used,
You will create screens, flows and actions in the video demonstrating all the flow (from forwarding the email to Unread, to Unread processing it, to playing it (use elevenlabs voice), )

For the voiceovers use elevenlabs voice for it too.

The story has to be good, n so good that it captures attention n show not just tell, research how to tell a winning story for a hackathon, research winning projects n what separates them from the others.

But first write the script for review before anything else

Agent
You

Base directory for this skill: /root/.claude/plugins/synced/97e0a069-3e3f-4425-8a96-b5a176c0aff6_e1329c19-dde0-494c-813c-7df64ccc4b2b/product-videos/skills/product-videos

Product videos

Videos of a software product, made in code with Remotion: every frame is
React, rendered to MP4. The product's screens are rebuilt as components from the real app's
code and screenshots, so numbers can count up, text can be typed, and the camera can zoom without
blur. A starter project with all the machinery is in assets/starter/.

Three things make these videos good, and most of this skill serves them:

  1. Everything shown is real. Every screen, label, status, button and behaviour exists in the product. Motion is free (zooms, a cursor, numbers counting up to their real value); invention is not (a button the app doesn't have, a toast it never shows, a live update it doesn't do). A video that demos fake UI misleads the people it is meant to convince. Read references/accuracy.md before building any screen.
  2. The words carry it. A clear problem, one promise, short headlines, narration that says what the screen shows. Read references/scriptwriting.md before drafting.
  3. Sound finishes it. Voice timed to the action, music ducked under it, a click on every click, one loudness for every file. Read references/audio.md before adding sound.

You cannot watch or listen to what you make. Check pictures as still frames (stills.sh) and sound
as numbers (scripts/audio-report.mjs), then tell the user plainly what they still need to judge
by eye and ear — usually pacing and music.

Start: ask, then plan

Before building, settle these with the user. Ask only what the conversation hasn't answered, in one
batch, and offer a recommended default for each:

Question Why it matters Default if they don't care
What product, and where is it? (repo path, running URL, demo logins) Screens are rebuilt from the real app — must know
Who is it for, and what should they do after? Sets the style, script and call to action Prospective users; "try it"
Which kind of video? (references/styles.md) Structure, length, pacing Launch for a new product, tutorial for a feature
Which visual style? Studio, Editorial, Graphic, Cinematic or Showcase How it looks and moves The recommended look for the type (below); always say why
Length and shape: 16:9, 9:16, 1:1? Layout and camera 30–60 s, 16:9, plus a 9:16 cutdown on request
Script: will they write it, or should you? If they bring one, check every claim against the product You draft, they approve
Product name and brand: logo, colours, font Title cards and the app frame The app's own tokens; ask before inventing a name
Voiceover? Which voice? Timing is driven by the narration Yes, local Kokoro af_heart; ElevenLabs if they have a paid plan
Music: a licensed track, AI-generated, or the kit's generated bed? Licensing The kit's generated bed, flagged as a placeholder
Where should the output go? Delivery out/ in the video project, outside their repo

If they say "you choose", choose, say what you chose in one line each, and go.

The visual style is always offered, with a recommendation and a reason. Each video type has a
default look (references/visual-styles.md has the table): Editorial for most launches, feature drops
and explainers; Showcase for AI, agent and automation products whose demo is the pitch; Graphic for
API and infrastructure products with little UI; Studio for tutorials, onboarding and anything where
the whole real screen matters; Cinematic for premium, quiet brands.
Adjust for the product's brand and where it will be watched (muted feeds favour the text-led
Editorial and Graphic). Tell the user which you recommend and why, in terms of their video, list
the other four in one line each, and go with the recommendation if they don't choose. If they point
at a reference video, name the closest look and what you will borrow from it, and offer to measure
its motion (references/motion-data.md) so the new video moves the way the reference does.

Then show a storyboard before building: the look at the top, then scene by scene what is on
screen, the headline, the narration line (or on-screen words, for music-led looks), the transition
into it, and roughly how long. It is cheap to change words and order now and expensive later.
references/styles.md has proven structures to start from.

Build

  1. Learn the product from its code and the running app. Routes, page components, the exact button labels, status names and their colours, what happens after each action (a message? a redirect? nothing?), empty states, defaults. Grep for the strings you plan to show.
  2. Capture references. Screenshot every screen and state the video will show from the running app (assets/starter/reference/capture.mjs, read-only: open dialogs, never confirm). These are the source of truth while rebuilding. Store them with the video project, not in the user's repo.
  3. Set up the project by copying assets/starter/ (next to this file) to the output location (outside the product's repo unless asked), then npm install. Copy the product's theme into src/theme.ts and src/kit/tokens.ts value for value, and set the name, tagline and mark in src/brand.ts.
  4. Write the cut list and narration per video: src/videos/<id>/timeline.ts (the look, scenes, base lengths, each scene's in transition) and narration.ts (lines, the frame each aims for, respellings). Register the video in src/Root.tsx.
  5. Build scenes (scenes.tsx) in the look's vocabulary: src/videos/editorial/ and src/videos/graphic/ are working examples of the two text-led looks, src/videos/showcase/ of Showcase, src/videos/example/ of Studio. Time new motion with the measured presets in src/kit/measured (MOTION.screenPan, MOTION.settle …) rather than inventing easings. Rebuild each screen from its reference screenshot with the kit's controls and an app frame shaped like the product's. Wrap anything the cursor or camera must reach in <Anchor name="…"> and aim at names, never at hand-measured coordinates. references/kit.md explains the camera, pointer, anchors, scene reuse and the vertical layout.
  6. Generate sound: npm run voice (scenes grow to fit their narration), npm run music and npm run sfx (generated tracks and effects, no licence needed; references/audio.md).
  7. Check stills at the moments that matter — every click, every typed value, every zoom, the last frame of each scene — and fix framing, overlaps and clipped text before rendering. Most problems are camera framing; widen the zoom or aim at a different anchor.
  8. Render with ./render-all.sh: MP4s, loudness at -16 LUFS, captions, thumbnails (as cover art, and as the opening frame, which is what X and most feeds show as the preview). Pull 2–3 frames from the finished files and run the audio report.
  9. Deliver. Before re-rendering anything the user has already seen, copy the old files aside (<name>-v2.mp4); they may want to compare. Report what was made and how long each is, what is placeholder (music, data, hostnames, the product name), and anything you could not verify.

Rules that save hours

  • Aim at anchors, not pixels. Coordinates drift whenever layout changes; anchors follow.
  • Narration drives timing. Write lines with an at frame next to the action they describe; the voice script places them and lengthens scenes that need room. Don't hand-time audio.
  • One name, one place. Product name, tagline and mark live in src/brand.ts, so a rename is a one-line change and a re-render.
  • Reuse scenes across videos (a launch video's product moments can be the tutorials' scenes) with StepCopy to change their headline. One verified screen, many videos.
  • Placeholder data is fine; placeholder features are not. Example names, amounts and a payouts.example.com host are data. Say which data is illustrative when you deliver.
  • Use measured timing. The kit's MOTION presets are curves and durations measured from a professional launch and verified by re-rendering. Prefer them to hand-picked easings; measure a new reference with assets/motion-lab when the user wants a specific feel.
  • Stills before renders. A full render takes minutes; a still takes seconds.
  • Don't trust a pipeline you haven't checked. After the first render of a new setup, read the audio report and look at frames from the actual MP4, not only the stills.

references/pitfalls.md lists the specific bugs this process has hit, with fixes. Skim it when
something renders blank, off-centre, silent or wrong only in the final file.

References

Read the one you need, when you need it:

File Read it when
references/styles.md Choosing a video type; you need a structure, length and pacing
references/visual-styles.md Choosing and recommending a look; building Editorial, Graphic, Cinematic or Showcase scenes; matching a reference video
references/motion-data.md Timing motion with measured presets; what professional launch motion measures as; measuring a reference video and scoring a render against it
references/scriptwriting.md Writing or reviewing the script, headlines, narration, captions, or working from a user's script
references/audio.md Voiceover, music, sound effects, levels, licensing, ElevenLabs or other AI audio tools
references/accuracy.md Before rebuilding any screen; deciding what may be shown
references/kit.md Working in the starter project: looks, transitions, motion blocks, camera, cursor, anchors, scenes, vertical, thumbnails
references/social.md 9:16 or 1:1 cutdowns, burned-in captions, platform specs, thumbnails
references/pitfalls.md Something renders wrong, blank, silent or slowly

Delivering

End with a short report the user can act on:

  • Each file: path, length, aspect, the look, and what it contains (video, captions .vtt, thumbnail).
  • What is real and what is placeholder: illustrative data, a placeholder name or hostname, the music source, the voice (and that it is AI-generated if it is).
  • What you checked (stills, frames from the MP4, audio report) and what they must judge themselves: watching it in motion and listening to it.
  • How to change it: edit narration and re-run voice; edit brand.ts; ./render-all.sh.
  • Licences: Remotion is free for individuals and companies of up to three people; larger companies need a company licence. Music and voice licences per references/audio.md.

ARGUMENTS: Story-driven hackathon video for Unread (newsletters → podcast). First deliverable: the script for review only, no rendering yet.

Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent

I'm looking up the judging criteria on the challenge page, then I'll write the script.

Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent

The first draft of the script is ready for you to review. It runs about 2:20 at 16:9, in three acts. It has two ElevenLabs voices: you telling the story, and the host voice of the episode itself, which you only hear once someone presses Play.

What the research said

  • Writing quality carries the most weight in this challenge. The other criteria are fit with the "rewards laziness" theme, creativity, technical execution and use of partner technology. The video supports the post, so it needs one idea that sticks, not a list of features.
  • Recent DEV winners were concrete and personal. The overall winner of the last weekend challenge was "Phone calls are the boss fight of living in Japan". The winning entries were built for one real person and kept the open model at the centre.
  • Hackathon advice: open with a frustration the viewer has felt, show a moment the viewer will remember, and say what the screen can't show (the pipeline, why each model was chosen, how output is checked) instead of describing clicks.
  • Many entries this week use a sloth name (SlothOps, Slothbox, SlothBrief…). Unread stands out by keeping the laziness honest rather than leaning on a mascot.

The script (on-screen action in short, then the voiceover)

ACT 1 · The problem
1. Cold open. A mail folder named "Unread" counts up to [1,247]. "This is my inbox. [1,247] unread newsletters. … I subscribed to every one of them on purpose."
2. The overlap. Four newsletters arrive in one hour, and the same phrase lights up in all four subject lines. "And on a good morning, four of them tell me the same story. Same launch, same numbers, four times. … I'm not going to read them. I'm lazy."
3. The turn. The phone goes into a pocket; earbuds go in. "But I do have a [forty]-minute commute, and my ears are free. So I made the reading someone else's job."
4. Title. The "Unread" folder label lifts out of the inbox and becomes the logo. "This is Unread."

ACT 2 · How it works, shown (a step rail along the bottom uses the app's real progress labels)

  1. Forward once. The real onboarding screen: copy the address, then the Gmail steps. "Waiting for your first newsletter…" changes to the confirmation code card, then "Got Morning Brew ✓". "Setup is one Gmail filter… Gmail sends a confirmation code; Unread catches it and shows it right here. After that, I never touch it again."
  2. The email arrives. An envelope goes Gmail → Cloudflare Email Worker (signed) → Unread, and a stranger's email bounces off. "…Mail that isn't from me is dropped before any model sees it."
  3. Same story, heard once: the key moment. Stories become dots; four dots from four newsletters drift together; "Gemma 4: same story?" → "Yes"; the four cards fold into one chapter, "Morning Brew · also in TLDR, The Hustle". Then a story from yesterday comes back, meets "Heard Thursday" and fades out. "EmbeddingGemma turns each story into a vector, and pgvector on Tiger Data finds the ones that are really the same story. When it's close, Gemma 4 makes the call. Four newsletters become one chapter that credits all four. … And if one brings it up again next week, Unread remembers I've heard it, and skips it."
  4. Fact check. A script line with "$3B" is checked against the source, struck and rewritten. "Every line has to cite its source. If a number isn't in the newsletter, the line gets rewritten, or cut."

ACT 3 · The payoff

  1. Voice. "Free episodes are read by Kokoro, an open-weight voice on our own worker. Studio voices come from ElevenLabs. Temporal runs it all on schedule, before my commute."
  2. Press play. The real Today and Now playing screens: tap Play, the host starts ("Good morning. Today's episode comes from four newsletters…"), and "Transcript and sources" slides up. Then you: "[Nine] minutes. Every story once. Every claim linked to where it came from. And it plays in my own podcast app."
  3. Why open. A wall of the tools, each labelled with where it's used. "Gemma does the thinking, Kokoro does the free voices, both open. That's what makes a free plan possible at all."
  4. Close. Back to the opening inbox, still at [1,247]. "My inbox still says [1,247] unread. … I'm caught up anyway." End card: "Stay lazy. Stay informed."

The full script, with screen directions and a check of every claim against the code, is in /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video-script.md.

Claims that aren't true yet

Some parts of the flow aren't live, and the video shouldn't show them as working:
- Nothing is deployed. "Render runs it" and the getunread.site address stay out unless they're live before we record.
- Tiger Data hasn't been migrated, and Gemma has never run against real newsletters. The host lines in step 10 and the merged chapter should come from a real run, not made-up text. That needs a GEMINI_API_KEY or OPENROUTER_API_KEY. Without one, I'll label the episode content "example".
- "Every morning" would only be true on Plus, since Free is weekly. I wrote "on schedule" instead.

Questions before I build

  1. Your real numbers: how many unread newsletters you have, and how long your commute is. The story only works with real figures.
  2. Newsletter names: should I use the ones you actually forward (Morning Brew, TLDR…), or made-up names?
  3. Voices: which ElevenLabs voice for you and which for the host? My default would be two clearly different premade voices. Generating them uses about 2,500 characters of credits, and I'll ask before spending them. If your plan is the free tier, ElevenLabs doesn't allow commercial use and requires attribution.
  4. Look: I recommend Cinematic. Unread's near-black, single-accent brand matches it, and it suits a narrated story. The other options:
    • Editorial: text-led, works with the sound off.
    • Showcase: light and gradient, "watch the AI work".
    • Studio: plain, full real screens.
    • Graphic: bold type and colour slabs.
  5. Extras: do you want a 9:16 cutdown for X/LinkedIn, and can you get a Gemma key so the episode in the video is real?

Sources:
- DEV Week 3 challenge page
- Build for a Friend winners
- Reskilll demo tips
- AngelHack demo tips
- Lessons from winning five hackathons
- RevenueCat on pitching
- Deepgram hackathon recap
- Week 3 entries

You

@"/root/.claude/uploads/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/c13f96bc-Deis-Launch-Video-SnapYT.App.mp4" also check this winner video, identify what made theirs good

Agent

I'll pull frames, scene cuts and the audio from the video and measure them.

Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent

The video has a voiceover under a music bed, with the words appearing on screen as they're spoken. I'll transcribe it to get the exact script.

Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent

Now I'll write a tighter draft 2 that applies what Deis did.

Agent
Agent
Agent

The Deis video works because it's short and every sentence earns its place. It's 61 seconds and about 110 words, with no sentence longer than seven words. I've rewritten our script along the same lines as draft 2, about 95 seconds (the first draft ran about 2:20). Both files are above.

What made Deis good

  1. The problem is shown as objects. Job cards get stamped TOO SENIOR, WRONG LANGUAGE and NO SPONSORSHIP, and a rejection email pops up. No statistics, no app screens.
  2. There's exactly one pause. "Until… now." is the only near-silence in the whole video, and the music lifts right after the reveal.
  3. A familiar comparison in four words: "Tinder, but for jobs."
  4. Short, matching lines: "Swipe left to skip. Swipe right to apply." They're easy to remember.
  5. It answers the worry about AI head-on: "Every line links back to a fact you gave. What it can't prove, it cuts. Deis never applies for you." Unread can say the same thing, because its citations and fact check are real.
  6. The open model is part of a benefit, not a logo wall: "Gemma writes your CV…" and "Runs on your laptop. Works offline. Free to run."
  7. The ending echoes the opening: "jobs that were never meant for them" becomes "Jobs you can actually get."
  8. One visual system: a near-black background, one accent colour with a soft glow, real screens in a phone frame, and every spoken word appearing on screen as it's said, so it works with the sound off. It has only two hard cuts; everything else is continuous motion.

A transcription tool caught the speech in the last 20 seconds only. For the first 40, I took the wording from the on-screen text, which matches the voiceover wherever I could check it.

How draft 2 uses this

  • A "SAME STORY" stamp lands on three of the four identical newsletters: our version of their rejection stamps.
  • One silence before the reveal: "So… I stopped reading." Then "Unread. Your newsletters, as a podcast."
  • Matching lines: "Four newsletters. One chapter. Every source credited." and "Heard it last week? It's skipped."
  • The trust line: "A number it can't find in the source, it cuts."
  • No tool wall. Each tool is named at the moment it does its job: Cloudflare (signing the email), EmbeddingGemma and pgvector (finding the same story), Gemma 4 (close calls and writing the script), Kokoro and ElevenLabs (voices), Temporal (schedule). That also covers your "where and how each tool was used".
  • The ending echoes the opening: "My inbox still says 1,247 unread. I'm caught up anyway." The end card says "Hear each story once.", which is your landing page's own headline.
  • "I" for the story, "you" for the product, so the viewer becomes the hero in the middle, like Deis.

Visual look: Deis uses the dark style I recommended (Cinematic). Ours would use Unread's own blue (--brand #5b9bff) for the glow and accent.

Still needed from you before I build

  1. Your real unread count and commute length.
  2. Real newsletter names, or made-up ones.
  3. Which ElevenLabs voices to use for you and for the host. Both together come to about 1,500 characters, and I'll ask before spending them.
  4. A Gemma key (GEMINI_API_KEY or OPENROUTER_API_KEY) if you want the episode in the video to be real output. Without it, I'll label the episode content as an example.
  5. Whether a site link goes on the end card. getunread.site isn't live, so otherwise I'll use the repo link.
You

on this "Cold open. A mail folder named “Unread” counts up to [1,247]. “This is my inbox. [1,247] unread newsletters. … I subscribed to every one of them on purpose.”" i would like to add "they're important, but I'm too lazy to read them,

Agent
Agent

I've added your line, and the opening now reads:

"This is my inbox. [1,247] unread newsletters. I subscribed to every one of them on purpose. They're important, but I'm too lazy to read them."

It works well. "On purpose" followed by "important, but too lazy" names the theme in your own voice, right in the opening seconds.

I made two knock-on changes so nothing repeats:
- Draft 2: the later line "I'm never going to read them" now says the same thing, so I cut it. That scene is now just "But I do have a [forty]-minute commute." The opening runs about 9 seconds instead of 6; the total is still about 95 seconds.
- Draft 1: I removed "I'm not going to read them. I'm lazy." from scene 2 for the same reason.

Both script files are updated in the scratchpad. I'm still waiting on your real unread count, commute length, newsletter names and the two voices before I build anything.

You

"The turn. The phone goes into a pocket; earbuds go in. “But I do have a [forty]-minute commute, and my ears are free. So I made the reading someone else’s job.”" -> "...a 40 minute commute to the office"

Newsletter names "Engineering Leadership" by Gregor, "System Design" Neo Kim (you can confirm them)

"Claims that aren’t true yet
Some parts of the flow aren’t live, and the video shouldn’t show them as working:

  • Nothing is deployed. “Render runs it” and the getunread.site address stay out unless they’re live before we record.
  • Tiger Data hasn’t been migrated, and Gemma has never run against real newsletters. The host lines in step 10 and the merged chapter should come from a real run, not made-up text. That needs a GEMINI_API_KEY or OPENROUTER_API_KEY. Without one, I’ll label the episode content “example”.
  • “Every morning” would only be true on Plus, since Free is weekly. I wrote “on schedule” instead." - the site is already live n works

Questions:

  • real numbers: 124
  • names - mentioned above
  • I'm on pro
  • go
  • no
Agent

I'll check the live site and confirm the two newsletters.

Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent

Draft 3 of the script is attached, with all your changes in.

Your changes:
- The opening count is 124. The scene is now a wall of 124 tiles, one per unopened issue.
- Scene 3 now says "But I do have a forty-minute commute to the office."
- I dropped the "four newsletters" framing to stay accurate, since you named two real newsletters. The overlap is now "Same topic. Same points. Twice.", and the merged chapter reads "Engineering Leadership · also in The System Design Newsletter". The line became "Two newsletters. One chapter. Both credited."
- Claims: the site is live, and it's served by Render, so the end card shows getunread.site. Render now appears next to Kokoro, ElevenLabs and Temporal in scene 11. Since you've said the live site works, I've stopped flagging the forwarding and pipeline claims.
- Your ElevenLabs Pro plan allows commercial use, so the AI voices are fine to publish.

I read "go" as going with the dark Cinematic look, and "no" as no 9:16 cutdown and no Gemma key.

Both newsletters are real:
- Engineering Leadership by Gregor Ojstersek.
- The System Design Newsletter by Neo Kim.

The app takes a newsletter's name from the sender name on the email. Substack may send these as something like "Gregor Ojstersek from Engineering Leadership". If your Sources page on the live site shows different names, tell me and I'll match them on screen.

One thing left that isn't real: the shared topic in scene 2, the chapter title, and the host's line in scene 12. If you forward one issue from each newsletter to the live site and send me the episode's chapter title and first few lines, the video will show real output. If not, I'll write lines that fit, and they'll be illustrative content shown under real newsletter names.

Before I build: which ElevenLabs voice should narrate as you, and which should be the host? If you don't mind, I'll pick two clearly different premade voices from your account. Otherwise I'll start building once you've confirmed the script.

You

you pick the voices, go ahead and build

maybe also add "Blogging Wizard" newsletter (look it up n confirm)

Agent
Agent
Agent

Blogging Wizard is confirmed: it's Adam Connell's free newsletter on blogging and content marketing. I'm setting up the video project now.

Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent

I'm reading the kit's components so I can build the scenes on them.

Agent
Agent
Agent

Next I'm writing the narration and switching the kit's voice script from Kokoro to ElevenLabs.

Agent
Agent
Agent

Now the narration, the timeline and the ElevenLabs voice script.

Agent
Agent
Agent
Agent

Voiceover generated: 1,159 characters in total. Here are the line timings the visuals will follow:

Agent
Agent
Agent
Agent

Now the theme, the fonts and the music drop-out:

Agent
Agent

Next, the rebuilt Unread screens (phone frame, onboarding, episode progress, Today, Now playing), copied from the app's code:

Agent
Agent
Agent
Agent
Agent
Agent

Now the text component and the 13 scenes:

Agent
Agent
Agent
Agent
Agent
Agent
Agent

The scenes compile. I'm checking still frames of each scene before rendering.

Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent

Most frames look right. I'm fixing four things: a fingertip that lingers on screen, the merge-scene layout, the phone placement, and zooms so the phone text is readable.

Agent
Agent
Agent
Agent
Agent

Stills check out. Starting the full render in the background:

Agent
Agent
Agent
Agent

The render is running: 3,296 frames, about 12 minutes on this one-core machine. When it finishes, I'll check frames from the actual MP4 and the audio levels, then send you the video.

You

<task-notification>
<task-id>bt592sze9</task-id>
<tool-use-id>toolu_01CvmChm39W12Hd9iCnWt6JT</tool-use-id>
<output-file>/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/tasks/bt592sze9.output</output-file>
<status>completed</status>
<summary>Background command "cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && nproc && ./render-all.sh Unread > render.log 2>&1; echo "exit $?" >> render.log; tail -5 render.log" completed (exit code 0)</summary>
</task-notification>

Agent
Agent

The render finished: 110 s, normalised to −16 LUFS. The music drops out for the "So… I stopped reading" pause at about 25 s. Next I'm checking frames from the actual MP4.

Agent
Agent
Agent
Agent
Agent

The final render is running (about 12 minutes). I've kept the first version as out/unread-v1.mp4 so you can compare the two.

You

<task-notification>
<task-id>b7ucskf1c</task-id>
<tool-use-id>toolu_01FBtHst7pBYBf92SYzvctFR</tool-use-id>
<output-file>/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/tasks/b7ucskf1c.output</output-file>
<status>completed</status>
<summary>Background command "cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && ./render-all.sh Unread > render.log 2>&1; echo "exit $?" >> render.log; tail -4 render.log" completed (exit code 0)</summary>
</task-notification>

Agent
Agent
Agent
Agent

The video is done and attached above: 1:50 at 16:9, with its thumbnail and a captions file.

What's in it: the dark Cinematic look in Unread's own blue, following the script. Every spoken word also appears on screen as it's said, so it works with the sound off. It opens on the thumbnail for under a second, because X and similar feeds use the first frame as the preview. The music cuts out for "So… I stopped reading." and comes back with the logo.

The files:
- unread.mp4, the video. The first render is kept as out/unread-v1.mp4 if you want to compare.
- unread-thumbnail.png, also embedded in the MP4 as its cover.
- unread.vtt, captions you can upload with the video.

All three are in /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/out/.

What's real and what isn't:
- Real: the screens are rebuilt from the app's code, with its colours, fonts and exact labels: the onboarding step, the Gmail confirmation code card, "Making your episode…" with its nine steps, Your morning brief, Now playing with "Source" and "Also covered by", and the "Newsletter · also in …" chapter label. The host's first two lines use the app's real intro template.
- Made up: every subject line and chapter title ("Write the design doc first", "Why design docs save weeks", and the rest), the "40% faster" line that gets cut, the "Caching" story, the episode times, the address alias and the confirmation code. Those titles are shown under the real newsletters' names. If you send me real subject lines from your inbox, I'll swap them in; a re-render takes about 12 minutes.
- Voices: ElevenLabs eleven_v4, with River as you and Alice as the host. Both are AI-generated. It used about 1,160 characters. The key in this environment reports a Starter plan rather than Pro, which still allows commercial use.
- Music: generated by the video kit itself, so there's no licence to worry about, but it's a placeholder-quality bed. ElevenLabs Music could make a better one, but I didn't spend your credits on it.

What I checked: still frames of every scene, frames taken from the finished MP4, and the audio levels. The loudness is −16 LUFS and nothing clips. The voice and music alternate as planned, with silence where the pause is. Things I couldn't check:
- How it feels in motion.
- How the voices and music sound.
- Whether the "SAME STORY" stamp lands on the beat.
- Whether the phone screens are readable at the size you'll embed. I added zooms where the phone text is small.

The length: it came out about 15 s longer than the planned 95 s, because River speaks in a relaxed way and the episode itself gets about 14 s. If you want it shorter, the quickest cut is dropping the host's long "4 stories today, from…" line, which saves about 6 s.

To change wording, edit src/videos/unread/narration.ts, then run npm run voice and ./render-all.sh Unread. Only changed lines use new ElevenLabs characters. Remotion, which renders the video, is free for individuals and small teams.

You

[image omitted from the upload]

[image omitted from the upload]

Show an actual Gmail inbox design (see image) for that this is my inbox

Agent

I'm rebuilding the opening and closing inbox to look like your Gmail screenshots (light Material design, cards, avatars, unread dots, Compose button, the 99+ badge), using your real rows.

Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
You

[Image: original 2160x617, displayed at 2000x571. Multiply coordinates by 1.08 to map to original image.]

Agent

I found AntennaPod's official screenshots, including its "Add podcast → Add podcast by RSS address" screen, which is exactly this flow. I'm looking at them up close:

Agent
Agent
Agent

I'm pulling AntennaPod's exact UI strings from its source, and checking what Unread's feed actually publishes (title, author, cover):

Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent

Both push-ins frame well. Rendering the new version (the previous one is kept as unread-v2.mp4):

Agent
Agent

While it renders, one thing I noticed in your inbox screenshots. Unread names a newsletter after the sender name on the email, which in your inbox is "Neo Kim" and "Gregor Ojstersek from Engineering Leadership". So the real app would probably label that chapter "Gregor Ojstersek from Engineering Leadership · also in Neo Kim", not the cleaner names the video shows. I can fix that in the app after the render; there's more detail in my final report.

You

No wait, ignore that podcast app link posting part, ignore 8t

Agent

Okay, I'll drop the podcast app scene. I'm stopping the render that includes it and restoring the previous ending, where the narrator says "Every story once. In my own podcast app." over the private-feed card. The new Gmail inbox stays.

Agent
Agent
Agent

The render is stopped. I'm applying the revert now:

Agent
Agent

The revert compiled, and the narration came from cache, so no new ElevenLabs characters were used. Checking one frame from the restored ending, then rendering:

Agent
Agent
Agent

The restored ending looks the same as before. Rendering the version with the Gmail inbox now:

Agent
Agent

I've dropped the podcast app scene, and the code for it is deleted. The video goes back to ending with "Every story once. In my own podcast app." and the private-feed card. The one new change is the Gmail inbox in the opening and the closing scene.

The final render is running, about 12 minutes. When it's done, I'll check frames from the MP4 and send it over. The previous version is kept as out/unread-v2.mp4.

You

Yeah, instead add something like "so you can listen to them on the go"

Agent

I'll add it to the title reveal, so it reads "Unread. Your newsletters, as a podcast. So you can listen to them on the go." That links the promise back to the commute. Stopping the current render first:

Agent
Agent
Agent
Agent
Agent

Understood: no "on the go" line anywhere. I'm removing it, keeping the Gmail inbox you asked for, and rendering that.

Agent
Agent

I've removed the "on the go" line, so the script is back exactly as it was. The only change from the version you've seen is the Gmail-style inbox at the start and end. It's rendering now, about 12 minutes, and I'll send it when it's done.

If you meant to keep the previous video with no changes at all, it's still there as out/unread-v2.mp4.

You

<task-notification>
<task-id>bo0offkgc</task-id>
<tool-use-id>toolu_012GwZcW8jWUns49YTrjr5V8</tool-use-id>
<output-file>/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/tasks/bo0offkgc.output</output-file>
<status>completed</status>
<summary>Background command "cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && python3 - <<'EOF'
p='src/videos/unread/narration.ts'; s=open(p).read()
old=''' { at: 0, text: "Every story once. In my own podcast app." },
{ at: 0, text: "So I can listen to them on the go." },
],'''
new=''' { at: 0, text: "Every story once. In my own podcast app." },
],'''
assert old in s; s=s.replace(old,new); open(p,'w').write(s)
p='src/videos/unread/scenes.tsx'; s=open(p).read()
old=''' <SaidStack
scene="play"
size={64}
lines={[
{ n: 3, text: "Every story once. In my own podcast app." },
{ n: 4, text: "So I can listen to them on the go." },
]}
/>'''
new=''' <Said scene="play" n={3} size={68} text="Every story once. In my own podcast app." />'''
assert old in s; s=s.replace(old,new); open(p,'w').write(s)
EOF
npx tsc && npm run voice 2>&1 | grep -E "generated|reveal|play"; ./render-all.sh Unread > render.log 2>&1; echo "exit $?" >> render.log; tail -4 render.log" completed (exit code 0)</summary>
</task-notification>

Agent
Agent
Agent
Agent

The updated video is attached: 1:50, loudness at −16 LUFS. "This is my inbox" now opens on a phone showing your Gmail inbox, scrolling through your real threads: Neo Kim, Gregor Ojstersek from Engineering Leadership, and Simon Hoiberg, with the 99+ badge and the Compose button. The closing line ("My inbox still says 124 unread") comes back to the same inbox. Everything else is the same as before, and I checked frames from the finished file.

The video is at /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/out/unread.mp4. The earlier versions are next to it as unread-v1.mp4 and unread-v2.mp4.

Two things to decide on:

  • Real and made-up content are now side by side. The inbox shows your real subject lines, but the next scenes still use my made-up "design doc" stories under the same newsletter names. Your inbox does have a natural real overlap: on Oct 20, Neo Kim sent "64 AI Engineering Concepts, Clearly Explained" and Gregor sent "How to Use AI to Become a More E…". I didn't switch to that pair because stamping "SAME STORY" on two real articles would be a claim about their content that I can't check. If you know they really cover the same ground, tell me and I'll swap them in. That would cost a re-render, about 12 minutes.
  • The app may label these newsletters differently from the video. Unread names a newsletter after the email's sender name, which in your inbox is "Neo Kim" and "Gregor Ojstersek from Engineering Leadership". The real app would show those names, not the "The System Design Newsletter" and "Engineering Leadership" in the video. A small fix in packages/core/src/inbound/sender.ts would turn Substack's "Name from Publication" into just the publication. If you want it, I can make that change on the branch after the video.
You

next is the article, remember it is heavily weighted n most judged by the judges, so it has be relevant n cotains as much necessary details as possible

submission template:
"This is a submission for the Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady

What I Built

<!-- What does it do, and what effort does it save? Who gets to be a little lazier because of it? -->

Demo

<!-- Share a deployed link or a video demo. -->

Code

<!-- Show us the code! You can embed a GitHub repo directly into your post. -->

How I Built It

<!-- Which open-source AI did you use (open-weight models, agent harnesses, frameworks, local inference), and how is your project built around it? -->

Why Does Open Innovation Matter?

<!-- Why does open innovation matter for what you built? What did it make possible that a closed API wouldn't? -->

My Agent Session

<!-- Optional, but judges love it. Save your session with DevRelay and embed it with the agent_session tag (see the challenge page), or link to it. -->

The Lazy Payoff

<!-- Optional, but this is where you can make your post stand out. How much time or effort does it actually save? Did you (or someone else) use it this week? If not, remove this section. -->

Prize Categories

<!-- Which partner categories are you entering? List every one that applies, or remove this section. -->

<!-- Team Submissions: Please pick one member to publish the submission and credit teammates by listing their DEV usernames directly in the body of the post. -->

<!-- Thanks for participating! -->"

Agent

I'll start with the rest of the challenge page (the full list of prize categories and any submission rules), then pull exact details from the codebase so every claim in the article is accurate.

Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent

The repo is public, so I can embed it. I'm also exporting a few full-resolution frames from the video to use as images in the post.

Agent
Agent
Agent
Agent
Agent
Agent

Now I'm writing the article, with every technical claim taken from the code and the design-decision records (ADRs):

Agent
Agent
Agent

Before handing it over I'm checking a few claims against the code: the model client's endpoints, model download sizes, and trial voices.

Agent
Agent
Agent

The article is written. I'm committing it and the images to the branch, which also makes the image links in the post resolve:

Agent
Agent
Agent

The article is attached as docs/submission/dev-post.md, ready to paste into DEV. I committed it and pushed it to claude/nifty-galileo-tinqrd, along with six frames from the video for the post, plus a cover image. I checked that the image links load.

It follows your template exactly, with a short opening story before "What I Built" (124 unread, "important but too lazy", the 40-minute commute). It's long, about 2,900 words, because the judges weigh writing and detail:

  • What I Built: the five steps, with the overlap split into the two features we agreed (same story merged, already heard skipped). It ties the Free plan's weekly, CPU-voiced episode to the theme, "Slow and Steady".
  • How I Built It:
    • A table of the three open models and what each one does: Gemma 4 31B, EmbeddingGemma on your own CPU, and Kokoro.
    • The pipeline diagram, with measured numbers: same-story similarity 0.93 against 0.30 for unrelated stories, the 0.83 / 0.72 / 0.85 thresholds, and Kokoro at 0.77× real time.
    • Two real code excerpts: the "already heard" database query and the fact-check function.
    • A short "What fought back" section on three problems I hit.
  • Why Does Open Innovation Matter?: the cost argument (36 or 72 cents of ElevenLabs per 10-minute episode against $0 on Kokoro), the falling back to a second provider that only an open model allows, and a fact check anyone can read.
  • Prize categories: Gemma, Render, ElevenLabs (it also voices the demo's narration), Tiger Data, Temporal, SerpApi and Auth0, each with what it does in Unread.

Before you publish:
1. Fill in the ✏️ placeholders: the YouTube link for the video, your DevRelay session, and the Lazy Payoff numbers (minutes of reading last week, and what your real episode came out as).
2. Confirm what production actually runs. I verified that the site is served by Render. The post also says production uses Tiger Data for the database, Temporal Cloud, a Gemma key and the SerpApi news option. If any of those isn't live, delete that line and its prize category.
3. Add a licence. The repo has no LICENSE file, which matters for an open-source challenge. I can add MIT in one commit.
4. Optional: create a promo code with pnpm promo create and give it to judges in the Demo section, so they can try the Studio voices.

Title: the front matter uses "I had 124 unread newsletters. Now they read themselves to me on my commute". Alternatives:
- "Too lazy to read 124 newsletters, so I made them a podcast that never repeats a story"
- "Unread: forward your newsletters once, hear each story once"

The deadline is tonight at 11:59 PM PDT (07:59 WAT). Download the video now if you haven't: it's only in this session's temporary scratchpad, which is deleted when the session ends.

You

In the video, it should be "...124 and still counting" (or similar) which indicates that the newsletters are still increasing, then talk about "set up auto-forwarding once, and Unread picks up every new newsletters for you as they arrive",
can you figure out a way to not mention EmbeddingGemma and Pgvector but keep what they do? (tell me how you will do this)

Agent

Here's how I'd handle all three changes. I'll wait for your go before touching anything, so they all land in a single 12-minute render.

1. "124 and still counting" (opening scene)
- New line: "A hundred and twenty-four unread newsletters, and still counting." On screen it reads "124 unread newsletters, and still counting."
- On screen: as "still counting" is said, two new emails slide in at the top of the Gmail list, the way new mail arrives in Gmail. One is from Neo Kim and one from Gregor, dated today, so the pile visibly keeps growing.
- Knock-on change in the closing scene: "My inbox still says 124 unread" no longer fits a count that keeps rising. I'd change it to "My inbox is still counting. I'm caught up anyway." The Gmail inbox stays on screen behind it.

2. Auto-forwarding (setup scene)
- New line 1: "Set up auto-forwarding once, and Unread picks up every new newsletter as it arrives."
- Line 2 becomes: "It even catches Gmail's confirmation code for you." I'd keep it, because it's a real detail that shows care.
- On screen: the app's real live status card does exactly this. After "Waiting for your first newsletter…", the "Got …" line grows as issues arrive: "Got Engineering Leadership", then "…, The System Design Newsletter", then "…, Blogging Wizard". That's the app's actual behaviour (it checks for new mail every 4 seconds), so it shows "picks up every new newsletter" without inventing anything.

3. Same-story matching without naming EmbeddingGemma or pgvector
- New line 1: "Unread compares what every story means, not just its words, and finds the ones that are really the same." That's what they do, said plainly: EmbeddingGemma turns meaning into a vector, and pgvector finds the close matches.
- Line 2 stays: "When it's a close call, Gemma 4 decides." Gemma is still the named open model.
- Line 3 stays: "Two newsletters. One chapter. Both credited."
- On screen: I'd drop the two labelled chips. Instead, each story card gets a small "meaning fingerprint", a strip of bars drawn from its own data. The two design-doc stories have nearly identical fingerprints despite different headlines. As they drift together, their bars line up and glow, with a small "match" reading. The other stories' fingerprints look different and stay apart. The "already heard" scene doesn't name them either: last week's story's fingerprint matches the new one, so the new one is skipped.
- The names stay in two places: the article, which explains them in full for the judges, and, if you want, the small credits line on the end card (Gemma 4 · EmbeddingGemma · Kokoro · ElevenLabs · Tiger Data · …). I'd keep them in the credits so the Tiger Data category is still visible in the video.

Cost: about 300 new ElevenLabs characters. The rest of the narration is reused from cache.

Two choices are yours:
1. Is the closing line "My inbox is still counting. I'm caught up anyway." okay?
2. Should EmbeddingGemma and Tiger Data stay in the end-card credits? I recommend yes.

You
  1. yes
  2. yes,

but hold on,

also i would like to relegate the Kokoro mention, let's make elevenlabs the only mention there, something around "Unread uses natural voices from eleven labs, so you feel it" and a list of voices UI then say something like " Choose from a variety of voices, so you can listen how you want it"?

fix those suggestions

Agent

All of it is going in. For the new voice scene, I'm finding the app's real voice picker to rebuild:

Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent

Now the screen components: incoming Gmail rows, the meaning fingerprint, and the Settings voice picker.

Agent
Agent

Now the scene edits:

Agent
Agent
Agent

Typecheck passes. Checking stills of every changed scene:

Agent
Agent
Agent
Agent

Nearly everything looks right. One fix: the matching pair overlaps and hides one fingerprint, so I'm spacing them apart before rendering.

Agent
Agent
Agent

The pair now sits cleanly, with matching fingerprints, the "close call · 0.79 match" reading, then "Gemma 4: same story?". Rendering:

Agent
Agent

The render is running (about 13 minutes; the voice scene made it a bit longer). Once it's done, I'll re-export the article's images from the new cut, so they match the video, and push them.

You

<task-notification>
<task-id>b6cpu4tg4</task-id>
<tool-use-id>toolu_01Xk5kerVnMaSDtxV6HwcsDX</tool-use-id>
<output-file>/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/tasks/b6cpu4tg4.output</output-file>
<status>completed</status>
<summary>Background command "cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && ./render-all.sh Unread > render.log 2>&1; echo "exit $?" >> render.log; tail -4 render.log" completed (exit code 0)</summary>
</task-notification>

Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent

The new cut is attached: 1:58, at the same loudness as before. I checked frames from the finished file. All the changes you asked for are in:

  • "Still counting": you say "124 unread newsletters, and still counting". The Gmail list jumps back to the top and two new emails slide in. The ending is now "My inbox is still counting. I'm caught up anyway." over the same inbox.
  • Auto-forwarding: "Set up auto-forwarding once, and Unread picks up every new newsletter as it arrives." Gmail's confirmation code appears first, as it does in reality. Then the app's live status lists each newsletter as it lands: Engineering Leadership, then The System Design Newsletter, then Blogging Wizard. On "It even catches Gmail's confirmation code for you", the code card lights up.
  • No EmbeddingGemma or pgvector in the narration: "Unread compares what every story means, not just its words, and finds the ones that are really the same." Each story card now shows a "meaning fingerprint". The two design-doc stories have nearly identical ones, show "close call · 0.79 match", and then "When it's a close call, Gemma 4 decides." The "already heard" story uses the same fingerprint idea. Both names are still in the end-card credits, as you agreed.
  • Voices: Kokoro is no longer mentioned in the narration. "Unread uses natural voices from ElevenLabs, so it sounds like a real show." The screen is the app's real Settings → Voice picker, filled with voices from your ElevenLabs account. Tapping ▶ on George plays his actual ElevenLabs preview clip, and tapping Lily cuts him off and plays hers, just as the app does. Then "Choose from a variety of voices, so you can listen the way you want." as Alice gets selected. Kokoro now appears only in the small end-card credits.

Made up: the two emails that slide in ("New on the blog this week" from Blogging Wizard, "This week's article" from Gregor) use generic subjects, because I don't have real ones for today.

Article: I re-exported its images from this cut, added a picture of the voice picker, and pushed both. I left Kokoro in the article on purpose: it's the strongest open-source argument for the judges (the free plan costs $0 per character). Say if you want it toned down there too.

The video is at /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/out/unread.mp4, and the previous cut is kept next to it as unread-v3.mp4. Download it before this session ends, because the folder is temporary. The deadline is 07:59 WAT this morning.

You

give me title n description for yt

Agent

Title (pick one):

  1. I had 124 unread newsletters, so I turned them into a podcast | Unread
  2. Too lazy to read my newsletters, so I built an AI that reads them to me
  3. Unread: forward your newsletters once, hear each story once

Description:

My inbox has 124 unread newsletters, and still counting. They're important, but I'm too lazy to read them. So I built Unread: set up auto-forwarding once, and every new newsletter becomes part of one short, private podcast episode that I listen to on my commute.

What makes it different:
• Hear each story once. When two newsletters cover the same story, Unread merges them into one chapter that credits both. If you heard a story last week, it's skipped.
• Nothing made up. Every line cites the newsletter it came from, and any number that isn't in the source gets cut.
• Natural voices from ElevenLabs, or a free voice that runs on open models.
• A private feed for your own podcast app, plus a web player with chapters, a transcript and sources.

Try it: https://getunread.site
Code: https://github.com/codewithveek/unread
Write-up: [DEV post link]

Built for the DEV Hacktoberfest Open-Source AI Challenge, Week 3: Slow and Steady.
Gemma 4 · EmbeddingGemma · Kokoro · ElevenLabs · Tiger Data (pgvector) · Temporal · Render · Cloudflare Email Workers · Auth0

Chapters
0:00 124 unread, and still counting
0:28 Meet Unread: set up auto-forwarding once
0:42 Same story, heard once
1:00 Repeats skipped, every fact checked
1:12 Natural voices from ElevenLabs
1:27 Press play
1:47 Still counting, caught up anyway

The narrator and the episode's host are AI voices from ElevenLabs. The app screens are rebuilt from Unread's own code. Newsletter names come from my own inbox; the story titles in the episode are examples.

#buildinpublic #opensource #ai #podcast #newsletters

Tags: unread, newsletter to podcast, ai podcast, gemma, elevenlabs, open source ai, hacktoberfest, dev challenge, text to speech, productivity

When you upload:
- Upload the captions file (unread.vtt) as English subtitles.
- Use docs/submission/cover.jpg as the custom thumbnail. It's on the branch in the repo; a copy is unread-thumbnail-1280.jpg in the video's out/ folder.
- Paste the YouTube link into the ✏️ spot in the DEV post, and the DEV post link into this description.

YouTube only turns the timestamps into chapters if the first one is 0:00 and each runs at least 10 seconds. These do, which is why some scenes are grouped together.

You

merge to main

Agent
Agent
Agent
Agent

The branch is merged into main and pushed; main is now at d11a583.

  • Merged in: the DEV write-up (docs/submission/dev-post.md), its 10 images and the cover. It merged cleanly on top of your MIT licence and the two app fixes.
  • Image links: the post now loads its images from main instead of the branch, so they keep working if the branch goes away. I checked that they load from main.
  • Licence: the Code section now says Unread is MIT-licensed, since your licence commit landed. That closes the licence item from my earlier checklist.
  • Branch: claude/nifty-galileo-tinqrd has been fast-forwarded to match main.

When I pushed, GitHub reported 7 Dependabot vulnerabilities on main (4 high, 3 moderate). I haven't looked into them. They don't block the submission, but I can review and update the affected dependencies after the deadline if you want.

You

give me your session that i can embed

Agent
Agent
Agent

DEV accepts Claude Code transcripts directly. I'm finding this session's transcript and making a copy with personal and secret data removed, so you can upload it.

Agent
Agent

I built Unread with an AI coding agent, spec first: a product spec (docs/prd-unread.md) with a build order and a cut line, a CLAUDE.md with the code standards, and a decision record for every third-party choice. The agent built milestone by milestone, U0 to U5: repo and auth, inbound email, the script pipeline, voice and feed, UI, then plans, Temporal and deploy. It checked each one against real models and real ffmpeg before moving on.

The Lazy Payoff

  • Setup: one Gmail filter, once. About two minutes.
  • Last week in my inbox: 6 new issues from two newsletters between Oct 19 and Oct 23. Four came from Neo Kim, on networking and AI engineering, and two from Gregor, on using AI at work and communicating your impact. At their own read times, that's ✏️ 55 minutes of reading I was never going to do.
  • What Unread made from it: ✏️ one 10-minute episode, 6 chapters, heard on the way to the office.
  • Effort after setup: zero. The filter forwards, the schedule makes the episode, and my podcast app downloads it.
  • What it costs to make: $0 in voice on Free (CPU time only); about 36 cents for a 10-minute episode on ElevenLabs Flash.

My inbox still says 124 unread. I'm caught up anyway.

Prize Categories

  • Best Use of Gemma. Gemma 4 31B makes every editorial call: borderline same-story pairs, ranking, every cited chapter script, and repairing lines that fail the fact check. EmbeddingGemma runs locally on CPU for clustering and "already heard".
  • Best Use of Render. The web app (Node, getunread.site, migrations as a pre-deploy step) and the Docker background worker that runs EmbeddingGemma, Kokoro and ffmpeg.
  • Best Use of ElevenLabs. Studio (eleven_v4) and Standard (eleven_flash_v2_5) voices for Plus, with request stitching and per-line caching. The demo video's narration and the episode host are ElevenLabs too.
  • Best Use of Tiger Data. Postgres with pgvector (vector(768), cosine HNSW index) for same-story clustering and "already heard", and a TimescaleDB hypertable of listening events whose skip rates feed the ranking.
  • Best Use of Temporal. makeEpisodeWorkflow with idempotent activities, heartbeats on voice, a 10-minute dispatch Schedule and an hourly maintenance Schedule, tested with time skipping.
  • Best Use of SerpApi. Opt-in news: pick topics and your country's edition, and Unread adds a news segment (always, or only on quiet days) from Google News results from the last 36 hours.
  • Best Use of Auth0 for AI Agents. Sign-in, with requireUser() checked in every page and route handler; the sign-in email becomes the first verified sender.

Top comments (0)