← Blog
Engineering9 min read

We gave our podcast agent verbs instead of a command line

By Kushal Sharma

The sixth agent in our 30 agents series is a Podcast Producer: a script goes in, a levelled and tagged MP3 comes out. It is the first agent that needed a dependency the platform did not have, and answering that turned out to be the interesting part.

Segments at four different loudnesses arriving at one published target. The quiet one is in colour because it is the one the measurement catches — which is why “normalised” here is a number rather than a claim.

The dependency problem, stated honestly

Editing audio means ffmpeg. Our agents run commands in a gVisor sandbox, and the filesystem that sandbox sees is the container image of the service that launched it. So there was an obvious place to put ffmpeg: the sandbox Dockerfile, right next to python3, pandas and matplotlib.

Those three are already there for exactly this reason — the CSV Analyst needed to draw a chart and there was nowhere else to put a charting library. The comment we wrote at the time says so, and does not pretend to be happy about it:

DOCKERFILE
# **Measured: 53MB of wheels**, which nearly triples this image's archive # pull (29.7MB before). That is a real cost and every sandbox pays it, # including agents that never touch Python — so this is the wrong place for # it, and it is here anyway because the right place does not exist yet.

Adding ffmpeg would have been the same decision a second time, and considerably worse. It pulls the full Debian codec set. Every sandbox on the platform would carry an audio toolchain so that one agent in thirty could use it, paid on every cold start by every agent that never touches audio — and once it is in the base image, the only way back out is a migration.

What made the alternative cheap is that the mechanism already existed and we had only ever pointed it outward. An agent definition can declare MCP servers it needs:

TypeScript
mcpServers: [ { id: "media", required: true, reason: "Measuring, trimming, mixing, normalising and encoding audio. " + "It runs on Mume and needs no account.", }, ],

We had used that for DeepWiki and for Linear — other people’s servers, reached with the user’s own credentials. Turning it inward changes the question from what does the base image need to what does this agent bind, and the second question has a per-agent answer. So ffmpeg lives in its own Cloud Run service, and the sandbox image is exactly what it was.

The tool we did not build

Having decided on a service, the fastest thing to expose from it is one tool that takes an ffmpeg command and runs it. It is about a third of the code we ended up writing, it handles every case anyone will ever ask for, and it is wrong in two independent ways.

It is remote code execution wearing a helpful hat

ffmpeg’s argument grammar is not a list of flags. It contains a general-purpose expression evaluator, protocol handlers that open URLs and pipes, and a concat demuxer that reads arbitrary paths out of a text file. A tool that accepts a command line accepts all of that, on a service that holds a mounted bucket and sits inside a cloud project.

And no amount of checking the string closes it. The attack surface is not a list of dangerous words you can filter — it is the whole of ffmpeg’s parser, which is a large and creative program that has been extended for twenty years.

A parameter with a type is a hole with a shape. Three requests fit and go through; the fourth is not blocked by a rule, it simply has nowhere to enter.

It also makes the model worse

This is the half we did not expect to care about as much as we do. A model handed a command line spends its turns recalling filter syntax, and it recalls it the way anyone recalls a thing they have read a lot and used occasionally — imperfectly, and confidently.

The failures are specific and they repeat:

  • -af where -filter_complex was needed. The simple filter flag cannot take two inputs, so anything involving a second file fails with a message about filter negotiation that does not mention the actual mistake.
  • Concatenating files that disagree. Different sample rates or channel layouts either refuse outright or — much worse — produce something that plays at the wrong speed.
  • Single-pass loudness normalisation. loudnorm without measured values is allowed to reach for compression to hit its target, and on speech that is audible. It still reports success.

Every one of those is a decision that should be made once, by someone reading the documentation, rather than every time, by a model remembering it. So the service exposes eight named operations — probe, detectSilence, trim, concat, mix, normalize, encode, importAudio — each a fixed argument array with typed holes in it. The caller chooses values. It never chooses flags.

normalize is the clearest case. It takes a target in LUFS and a true-peak ceiling, and internally it always runs the measurement pass first and feeds those numbers into the second one. There is no way to call it and skip that, because the skip was never a parameter.

Which workspace? Not the one in the arguments

The media service has the workspace bucket mounted, and that bucket holds every user’s files. So the question that decides whether any of this is safe is: when a tool call says raw/host.wav, whose raw/host.wav is that?

The answer is that no argument names a user. The gateway mints a short-lived signed grant naming one account and one session, from its own authenticated context, and sends it as the bearer token. The media service resolves every path against the root that token implies and refuses anything landing outside it. A caller can ask for a file; it cannot ask for someone else’s file, because the part of the path that would say so is not in its gift.

That token is deliberately not a JWT. A JWT carries the algorithm to verify it with in a header the verifier is expected to read, and the entire alg: none family of bugs comes from verifiers that believe it. There is one issuer and one audience here, so the format has no algorithm field to lie about.

Two things we only found by looking

The protocol was not the one we thought

We built the server against the 2026-07-28 MCP spec — stateless, no handshake, every request self-contained. Our gateway uses the client from the same SDK family. The first integration test passed, and the server logged this on every single request:

JSON
{"severity":"INFO","message":"[media] serving","era":"legacy"}

The client speaks the 2025 protocol by default. Version negotiation is opt-in per connection — adopting v2 of a package is not the same as adopting the spec it shipped for. Everything worked, nothing warned, and the only reason we know is that the server logs which era answered.

It is now opt-in per server: on for the one server we wrote, off for DeepWiki and Linear, which are 2025-era servers that gain nothing from the change and would risk a probe timeout on connect. Two tests assert both eras, because the failure mode here is silence.

Two credentials, one header

The service is private, so Cloud Run checks IAM by reading the Authorization header. That is also where an MCP client puts its credential, and where our own workspace grant needed to be. The first deploy hit it immediately.

The easy way out is to make the service public and let the grant be the only control — defensible, since the grant is a real signature check that fails closed, but it throws away a layer to avoid a half-hour’s work. Cloud Run has a header for exactly this collision: the platform consumes X-Serverless-Authorization for its IAM decision and does not forward it, so the two credentials stop competing.

The seed is a script, and that was an accident worth keeping

Every agent we build is seeded with deliberately imperfect sample data, for a reason we have written about before: a clean input lets an agent look competent while proving nothing.

For a podcast producer the obvious seed is audio. We cannot do it — seeded workspace files are a map of path to string, so a .wav cannot be put in one. That is a real gap and it hit the Invoice Extractor too.

Here it pointed at something better. Seeded with a script, the agent’s first move is to speak it — which produces genuine audio, in the workspace, that it then has to measure and edit. The whole pipeline runs from text with nothing uploaded and nothing faked. An agent seeded with finished audio would have had the interesting part done for it.

The script carries five planted problems:

  • A music bed that does not exist. The production notes ask for music under the intro. There is no music file, and nothing here makes one. The only honest answer is to produce the episode and say so.
  • An ad read sold as thirty seconds that is about seventy words — a little over twenty at podcast pace. Checkable with probe once it is spoken.
  • Two segments with the same title, from an obvious copy-paste.
  • An outro promising a guest the script never names.
  • A loudness target in the notes. So there is a spec to hit and a measurement to report against, rather than a vague instruction to make it sound good.

The first four are things a producer would catch and mention. The fifth is the one that separates models: normalize hands back what the file measured before and after, and an agent that writes “normalised to −16 LUFS” without printing those numbers has not checked anything.

What it does not do

It cannot make music. generateSpeech speaks; there is no instrument on the platform. Asked for a bed, it says there is not one rather than producing something spoken and calling it music.

It has no shell, which is the design and not an oversight. Every other file-touching agent we have built has bash. This one gets named verbs instead, which is precisely why it cannot invent a filter chain — and equally why it cannot do something the verbs do not cover. That is the trade, and it is the right way round for a tool whose output someone publishes under their own name.

Try it— the script is already loaded, and the interesting instruction is “produce the episode, and tell me what you could not do”.