Skip to main content

Atomix, twice: a documentary agent and a shared shell for four apps

Share:XLinkedInHN
Cover for Atomix, twice: a documentary agent and a shared shell for four apps

Two folders, same word

I have a folder in Downloads called Atomix and a folder in OneDrive/Apps/Github called Atomix. Neither is a fork of the other. Both are mine. I did not plan the collision. It happened the way most naming collisions happen, one project got named late and I liked the word too much to rename it.

The one in Downloads is a Python pipeline that generates a biography documentary from a name and a prompt. The one in Github is a Vite plus React plus TypeScript monorepo with a FastAPI backend, and it has been slowly eating four other apps of mine into itself as native pages. This post is about both, because I am the person who has to keep them straight in my head, and writing them down side by side is the only way that has ever worked.

The documentary agent, four stages

The class that runs the show in the Downloads copy is called BiographyDocAgent. It walks a piece of text through four stages: Research, then Script, then Media Generation, then Assembly. Each stage writes artifacts to a working directory so the next stage can pick them up without holding state in memory. If Media Generation crashes at minute forty, I do not want to rerun Research.

Research is an LLM call that pulls facts about the subject and dumps them as structured JSON. Script takes that JSON and turns it into a narration script with scene breaks and cues. Media Generation is the interesting stage. It talks to a Kaggle notebook I keep running, which exposes three models behind an ngrok tunnel: Qwen3-TTS for the voiceover, Whisper for word-level timestamps back from the TTS output, and TextAnimator for the on-screen text with per-word timing. Assembly stitches audio, text overlays, and background footage in MoviePy and writes an MP4.

The project used to be called Space Documentary Agent. I built it originally to make short documentaries about planets and probes, and then I realized the pipeline did not care what the subject was, so I renamed the class, renamed the folder, and pointed the Research stage at Wikipedia biographies. The name Atomix is a nod to the fact that every stage produces atomic artifacts on disk.

The decommissioned model migration

The Groq calls used to hit qwen-2.5-32b. That model got decommissioned. I found out because the Research stage started returning HTTP 400s with a body that said the model was no longer available. I swapped in llama-3.3-70b-versatile and re-ran a full generation.

The manim generation module was on llama-3.1-8b-instant, which was still up, but it was the second-cheapest tier and its outputs had been fraying for a while. I moved it to the same llama-3.3-70b-versatile and the manim code it produced stopped needing three retries per scene.

Both swaps were one-line changes in the code, but they surfaced a second problem. The new model liked to think out loud. Its outputs came back wrapped in <think>...</think> blocks, sometimes with a JSON payload after the thinking, sometimes with a code fence around the JSON, sometimes with a preamble like "Here is the JSON you requested:" before the payload. Every parser downstream broke.

I added a _clean_llm_response() function that strips three things in sequence. First, anything between <think> and </think> tags. Second, code fences, both triple-backtick and the language-tagged variants like triple-backtick-json. Third, any wrapper prefix that looks like a JSON preface, matched with a regex that finds the first { or [ and slices from there. The function does not try to be clever. It just runs the three passes and returns what is left. Every LLM-consuming stage in the pipeline now goes through it before json.loads.

MoviePy 1.0.3 versus MoviePy 2.x

Kaggle notebooks ship an old MoviePy. Specifically, they ship 1.0.3, and 1.0.3 uses method names like set_duration, set_audio, set_position. My local dev environment on Windows has MoviePy 2.x, where those same methods are named with_duration, with_audio, with_position. Same behavior, different verbs.

I write Assembly locally, test it against the local MoviePy, push it up to Kaggle, and Kaggle throws AttributeError: 'VideoClip' object has no attribute 'with_duration'. Or I edit on Kaggle, pull the changes down, run locally, and get the mirror-image error about set_duration. Every session, at least once, I break the code by writing it for the version I happen to be looking at.

I tried a shim. The shim was a small module that inspected the installed MoviePy version at import time and monkey-patched the missing methods onto the clip classes so either name worked in either environment. It half-solved the problem. It did not solve the deeper thing, which is that the argument shapes also changed, along with the names, and the shim did not catch every case. I gave up on the shim and now I just remember which environment I am in and grep for the wrong verb before I run anything.

The cp1252 monkey-patch

This one is worth its own section because it burned me for a full evening.

The Kaggle notebook writes intermediate JSON and text artifacts to disk between stages. On my machine, open('out.json', 'w') writes UTF-8 by default. On Kaggle, open('out.json', 'w') writes cp1252, because the notebook kernel picks up a Windows-style locale from somewhere upstream even though the underlying container is Linux. I have never fully traced the reason. I only know the symptom.

The symptom is that any character outside the cp1252 range, which includes most of the em-dashes and curly quotes that the LLM likes to emit, and every non-Latin character in a biography subject's name, causes an encoding exception when the artifact is written. The exception happens in Stage 2 or Stage 3, long after Research has produced the offending characters, and the traceback points at whichever json.dump or open call happened to be first in the failure sequence.

The fix that I finally shipped was to monkey-patch builtins.open at the top of the Kaggle notebook, wrapping it so every call that does not explicitly pass an encoding argument gets encoding='utf-8' injected. The patch is six lines and it sits in the very first cell of the notebook. Every subsequent import inherits it. Every open call downstream, including the ones inside MoviePy and inside the requests library's cookie handling, gets UTF-8.

I do not love monkey-patching a builtin. I have not found a cleaner fix that survives across restarts of the Kaggle kernel, though. The alternative was to audit every stage for encoding= arguments and pass them explicitly, and there are dozens of such call sites once you count the transitive ones. The monkey-patch is the load-bearing hack that keeps the pipeline runnable on a machine I do not control.

The other Atomix, a shared shell

The Github copy is a different kind of project. It started life as a single Vite plus React plus TypeScript app with a FastAPI backend for LLM calls and a Node plus Express render sidecar that runs the actual video rendering. Three services, one repo.

Over the last several months, I have been pulling other apps of mine into it as native pages rather than iframes. Three of the absorbed apps used to be their own repos: yokoso, terraink, and animated-infographic-studio. A fourth, prompt-to-mograph, started life as an internal feature under frontend/src/features/motionGraphics/promptToMograph/ and never had its own repo. Each of the standalone three used to have its own auth flow, its own settings page, its own tailwind config, its own build pipeline. Separate deploys, separate mental models.

The shared-shell decision was that all four should live inside one Vite app, share the same auth, share the same settings, share the same design system, and each get a route prefix. The FastAPI backend serves all four. The render sidecar serves all four. When I add a feature to one, like a new export format or a new billing tier, it lands in the shared layer and every app inherits it.

The migration was mostly a matter of copying each app's React components into a subdirectory under src/apps/, replacing their auth calls with imports from the shared auth module, and rewriting their build outputs to route under a common domain. The hard part was the CSS.

The design purge

Every one of the four apps I pulled in had been built at a different time with a different mood. One of them had purple gradients everywhere. Another used violet accents on its primary buttons. A third had indigo section backgrounds. A fourth had fuchsia highlights in its charts. Together, dropped into the shell, they looked like a color wheel with a headache.

I did a purge. I grepped the entire monorepo for purple, violet, indigo, and fuchsia Tailwind class prefixes and I deleted every one of them. Where a color was load-bearing (a primary button, a chart accent), I mapped it to the shell's neutral plus one-accent palette. Where a color was decorative, I removed it entirely and let the neutral background do the work.

The purge took a full weekend. The diff was two thousand lines of class-name deletions across the four apps. When it was done, the shell looked like one product instead of four. That was the point.

What Atomix has become

The Downloads copy still generates biographies. I ran it last week to produce a five-minute documentary about a scientist I had never heard of, and it came out watchable, which is the honest bar for a pipeline that ships without human editing.

The Github copy is now the shell that hosts four apps and a growing pile of shared infrastructure. It is where I go when I want to add a feature that all my render tools should have. It is also, increasingly, the place I open when someone asks to see what I have been building, because it collects the answer in one place instead of four.

The two projects will keep sharing a name. I have looked at renaming one of them and every alternative I try feels worse. The word does the same work in both cases, which is to say that atoms combine into larger things, and both projects are doing exactly that with the pieces I already had.

See also

Cite as: Saravanan, K. (2026). Atomix, twice: a documentary agent and a shared shell for four apps. Kaushik Saravanan. https://www.kaushik.cv/blog/atomix-documentary-agent-to-shared-shell