There's a Slack channel at Mux where, very, very rarely, a robot tells us that a video is mostly of feet.
That channel is the moderation feed for stream.new, our OG demo app, and the robot in question is Mux Robots. Over the last year we've rebuilt how stream.new moderates and understands the videos people upload… twice! Since the whole history is sitting in the open source repo anyway, I thought it’d be fun to take a walk down memory lane, and remember how we went from primitive vision API calls in 2021 to the setup we run today, and why Mux Robots is the version we'd been trying to build all along.
We launched stream.new in 2020 as the simplest possible expression of Mux Video. Record or upload a video, get a shareable link — free, no account required. It's processed hundreds of thousands of videos over the years, and it's still one of our favourite reference implementations of Mux Video. It's also our dogfooding platform: when we ship something new to Mux Video, stream.new is usually one of the first places we try it.
It's also free, anonymous video hosting on the open internet, which means it attracts everything you'd expect: adult content, pirated movies, people filming themselves watching pirated movies, and, for reasons I've never quite got to the bottom of, a surprising amount of professional cycling footage...
As Dylan put it in 2021, you either die an MVP or live long enough to build content moderation.
Whelp, stream.new has lived long enough to build it three times.
One important note before we go wandering: Everything in this post is about the automation layer. stream.new has always respected DMCA takedowns, and every video has a manual reporting flow with humans making the final call. The automation exists to support those humans, and to make sure they see as little of the internet's worst content as possible.
How we moderated in 2021
The original system, which Dylan wrote about, worked the way most moderation systems did back then: extract sample thumbnails from across the video, send them to a vision API, and act on the scores that come back. At first we used two providers: Google Vision SafeSearch and Hive. Why? Moderating a UGC app was new ground to us, and we didn’t know how accurate each model would be, so we were worried about both false positives and negatives. We figured it was best to have a second opinion before deleting someone's video!
That approach worked pretty well for years, but there were downsides. We were maintaining two bespoke API clients with two different response formats (Hive returns float scores, with a lot of sub categories, and Google returns a five-point likelihood enum), plus the code to reconcile them into something comparable: around 250 lines, with tests. Not the end of the world, but a bit annoying. The orchestration was the bigger annoyance: everything ran synchronously in the asset.ready webhook handler as we didn’t want to build, or depend on any orchestration layer, so slow provider responses meant timeouts and retries.
The bigger limitation was that thumbnail classification can only tell you what a frame looks like, not what the video is. A frame from a pirated episode of a TV show looks completely innocent to a SafeSearch check. So while the automated layer caught the obvious stuff, a lot still came down to the humans in the Slack channel.
Some housekeeping first
Before we could modernise any of the AI plumbing, we had to modernise the app itself. At the end of last year, stream.new was running Node 16, React 17, and Next.js 12 on the Pages Router, and most current tooling simply won't run on a stack that old. Two PRs in January (#211 and #212) brought us up to Node 20, React 18, Next.js 13, and the App Router, and later migrations carried us the rest of the way to Next 16 and Node 22.
Not the most exciting part of the story, but if your app has been quietly running in a corner since 2020, it’s worth budgeting for this step. On the bright side, it was also much less painful than it would have been a few years ago: most of the upgrade work was heavily LLM assisted, with a human (me!) reviewing the changes.
Migration one: @mux/ai
In December 2025, we launched @mux/ai, an open source TypeScript toolkit that handles the glue between Mux assets and AI providers: extracting thumbnails at sensible intervals, fetching and cleaning transcripts, prompting consistently, leveraging storyboards, returning typed results, with comprehensive evals. In February, stream.new became a “customer” (#215) on @mux/ai.
The migration deleted both provider clients and all of the score-reconciliation code, and replaced them with calls like this, running inside a durable Vercel Workflow:
const [openaiResult, hiveResult] = await Promise.all([
getModerationScores(assetId, {
provider: 'openai',
thresholds: { sexual: 0.9, violence: 0.9 },
maxSamples: 5,
}),
getModerationScores(assetId, {
provider: 'hive',
thresholds: { sexual: 0.9, violence: 0.9 },
maxSamples: 5,
}),
]);We kept the same two-provider approach we'd always had, but @mux/ai now handled all of the plumbing. We also took this moment to try out OpenAI’s moderation API, as an alternative to Google’s SafeSearch.
Moderation by asking questions
The feature that gave us the opportunity to look a the bigger moderation picture was askQuestions.
Classifier scores are great at detecting NSFW or violent content, but stream.new's hardest moderation problems were never really about NSFW frames; they were whole categories of content that a simple frame classifier can't recognise. However, modern multimodal models can, because you can just ask them in a prompt, as long as they have enough context.
We built askQuestions in @mux/ai for exactly this. Our moderation policy became a list of plain English questions that runs against every upload:
[
{ question: "Is this a professionally produced full length movie or TV show, or a standalone segment from it?" },
{ question: "Is this professionally produced footage of a cycling race?" },
{ question: "Is this a watchalong-style video where a person or small group is actively watching and reacting to a full-length movie or TV episode as the main focus of the clip?" },
{ question: "Does this video use offensive language, and/or is likely to offend?" },
{ question: "Does this contain explicit slurs, dehumanization, or threats toward a protected group (not general insults or political opinions)?" },
{ question: "Is this video mostly of feet?" },
]That's the list (give or take) running in production today, and each question maps to a real category of content we've had to deal with. Not every question triggers an automatic deletion, but more on that in a moment.
The watchalong question is a fun example: if someone films themselves in the corner of the frame while some anime plays behind them, a simple NSFW check sees a person on a sofa and waves it through, but a multimodal model with wider context on the video can identify what's actually going on. The cycling question is there because pirated race coverage kept showing up, and it turned out to be more reliable to ask about it directly than a generic piracy question would have caught.
Fun side story on those cycling videos - we got so bored of them, we eventually reached out to the site we found linking to them, and asked them politely to stop… which they did… I suspect this is the first time in human history a pirate has stopped because someone asked nicely… 🏴☠️
The answers come back structured: a yes/no, a confidence score, and the model's reasoning. That makes enforcement straightforward. For the questions we treat as auto-delete rules, a "yes" with confidence above 0.8 pulls the video automatically and posts the reasoning to Slack (#226). Everything else just lands in the channel for a human to review.
Unsurprisingly, transcripts make the answers dramatically better, too, and that was unlocked by another Mux Video feature we shipped around the same time: automatic language detection for auto-generated captions. Before that, you had to tell us the language when you asked for captions, and stream.new has no idea what language an anonymous upload is going to be in. With auto-detection, we simply generate captions for every asset, and the workflow waits for the caption track to be ready before firing the summarisation and question jobs, so the analysis has a transcript to draw on, whatever language the video is in.
This did have one downside though: video summaries initially came back in the language of the video, with no option to override. While this is super cool, and impressively multilingual, it’s completely unhelpful for a predominantly English-speaking moderation channel. We added an outputLanguageCode option to @mux/ai for exactly this (#229).
We also learned that the questions themselves need iterating, the same way prompts do everywhere. The protected-group question above started life as a much vaguer Is this hate speech?, which couldn't reliably separate genuine slurs and threats from general insults and political rants. Spelling out exactly what we want to catch (and what we don't) made the answers far more consistent.
Migration two: Mux Robots
@mux/ai was always going to be a stepping stone for us. Shipping an open source package first as our first foray into AI meant we could work out which video AI jobs customers actually wanted (moderation, summarisation, Q&A) with real-world applications. But it's a BYO-LLM toolkit: it runs in your app, with your own OpenAI and Hive keys, and all the orchestration happens in your infrastructure.
Step two was to take that same engine and run it natively inside Mux, next to your video, as a first-party API. That's Mux Robots. You create a job for an asset, Mux runs the analysis, and a robots.job.* webhook tells you when it's done. stream.new migrated in April (#230), and the workflow code got noticeably simpler. Starting a moderation job now just looks like this:
const { id } = await mux.robotsPreview.jobs.moderate.create({
parameters: {
asset_id: assetId,
thresholds: { sexual: 0.85, violence: 0.85 },
sampling_interval: 10,
max_samples: 25,
},
});The interesting part is how the workflow waits for results. Mux Robots jobs are asynchronous, and we didn't want a serverless function sitting there polling, so we use Vercel Workflow's hook primitive: the workflow creates a hook keyed by asset ID and job type, fires the job, and suspends. When the completion webhook arrives, it resumes the hook and hands the webhook payload, outputs and all, straight to the workflow:
const hook = moderationHook.create({ token: `robots-moderate:${assetId}` });
await startModerationJob(assetId);
// The hook resolves with the robots.job.* webhook payload
const job = await Promise.race([
sleep(ROBOTS_JOB_TIMEOUT_MS),
hook,
]);Two details worth copying if you build something similar: First, create the hook before firing the job, so a fast webhook can't slip through the gap. Second, use the payload the webhook delivers rather than making another API call to fetch the job you've just been told about. The only time we go back to the Mux Robots job API is when the timeout wins the race and we need to check what state the job ended up in.
And of course the big payoff was when we removed the OPENAI_API_KEY and HIVE_API_KEY from the app entirely; Mux Robots just runs with the Mux API credentials stream.new already had, and automatically picks the best provider for each workflow we run.
Mux Robots was still in preview while we migrated, so there were some rough edges: webhook payload shapes were still in flux, and it took a few attempts to get timeout and workflow dependency handling right.
Tuning stream.new’s moderation
Since the migration, every change to stream.new's moderation behaviour has been a small config change to tune our use of a Mux Robots workflow. We've adjusted our NSFW moderation strategy a few times to improve consistency (#237), and we promoted the full-length-movie question into the auto-delete list alongside watchalongs (#236). Each of those was a one- or two-line diff to review, which is exactly what moderation policy changes should be.
So how did we pick those numbers? Honestly: we started cautious and dialled it in in production. While auto-deleting someone's video is a bad place for a false positive, on a free UGC platform where no-one is paying, it’s better than the alternative. Every moderation result is logged to Slack, so whenever we make a change to the moderation thresholds, we pay more attention to the content that’s being moderated, and what’s being reported. Watching that channel is what gave us the confidence to settle on 0.85 with a sample every 10 seconds. If you're setting up moderation for your own content, we've written up how to choose a sampling interval and thresholds in the Mux Robots moderation guide.
What’s next?
Looking back across the repo, and channeling my secret inner Swiftie, the three eras are pretty clear:
- 2021: we wrote the vision API clients and score reconciliation ourselves, using tools and models that only looked at individual frames, and only cared about NSFW and violent content
- 2025: @mux/ai handled the LLM / video glue, and multimodal models meant our deeper moderation policies could be written in English, but we were still holding provider keys and running the orchestration inside stream.new
- 2026: Robots comes along 🎉. One API call to the infrastructure that already has the video, with the @mux/ai engine still doing the work under the hood, just inside Mux Robots this time
Each step deleted bespoke code integrating specific providers, and the current pipeline is both the smallest and the most capable it's ever been. That's the direction we want to keep pushing: video AI belongs next to the video.
But what about the future? Well, we recently introduced Mux Robots Directives, a way to orchestrate complex workflows that include multiple steps. So keep an eye out for more improvements in stream.new in the next few months, as we continue to iterate on Directives, and integrate it.
If you want to trace any of this in more detail, the stream.new repo has the full history, and Mux Robots is available to try on your own assets today. As always, we'd love to hear your feedback, and to see what you build.
And if you upload a video of your feet to stream.new: yes, the robots (and Phil) will know.



