Releases: LatentNoise/content
Release list
Content v0.8.0
Content can now be run for more than one person. Every row and every file has
an owner, a sign-in door exists, programs carry their own keys, and mail leaves
through a service of its own. If you self-host, nothing changes: your
instance still asks for no credential, and that is a promise this release was
built around rather than an exception it tolerates.
Everything has an owner, and self-hosting does not notice
The hard part of adding accounts is usually that the code grows a second
personality — one path for "logged in", one for "not". This release does not
have one.
A self-hosted instance is simply an instance with one user, called local.
It is a real owner id that never had to sign in, so every query filters on an
owner and every path contains one, in both modes
(ADR 0030).
There is no "no user" state anywhere, and no code outside identity resolution
asks which mode it is in.
- The store takes an owner first, on all 22 methods, and the filesystem
files work underjobs/<owner>/andtmp/<owner>/. - An existing database upgrades instantly. It already holds exactly one
person's data, so it is backfilled tolocalwith no data pass and no
guesswork. - A test enumerates every route and fails on one that resolves no owner.
It also walks routers mounted withinclude_router, which the first version
did not — every route added that way would have been silently exempt. job_stepsandjob_eventscarry no owner column: isolation is a join
onjobs, so there is one source of truth rather than three that can
disagree.- The resource-fact cache stays shared on purpose. A video's title and
duration belong to nobody, and two people analysing the same URL should pay
for it once. What you submitted is yours; what the internet says about a
public page is not.
Signing in: a link in a mail
CONTENT_AUTH_MODE=token turns on the hosted contract, and a browser gets in
through a link rather than a password
(ADR 0033).
The engine serves the door itself, at POST /api/v1/auth/link and
/api/v1/auth/callback, plus two plain HTML pages. It is not a product UI —
it is the one place allowed to turn a request into an identity, and putting it
anywhere else would create a second such place.
What the details are actually defending against, since that is what a sign-in
door is made of:
- Asking for a link always answers the same thing, whether or not the
address is known, whether or not the rate limit fired, whether or not the
mail service refused. A different reply would let anyone ask, one address at
a time, who has an account. - The token is stored as a fingerprint, so a leaked backup is a list of
useless hashes rather than a working keyring. - Following the link checks and burns in one statement, inside one
transaction. A read followed by an update leaves a window where a double
click, a mail client prefetching the URL, or a replay yields two sessions
from one link. nextis checked against an allowlist of whole origins, at the request
and again at the callback. An open redirect on a sign-in endpoint is how a
phishing page borrows your domain.- The session is a row, not a signed token, because "sign me out
everywhere" is the answer to a lost laptop. Its cookie isHttpOnly,
Secure,SameSite=Lax, and can be set on a parent domain so several
surfaces share one sign-in.
Named API keys, for the programs
A browser holds a cookie; the CLI, the MCP server and your scripts hold a key.
POST /api/v1/auth/keys mints one from the Console, shown exactly once —
only its fingerprint is kept, so nobody can read it back afterwards, the
operator included.
Keys are named and dated because the point of naming them is revoking one
without touching the others. The readable ck_live_ prefix makes a key
recognisable in a log and detectable by the secret scanners that watch public
repositories. A key is looked up by its fingerprint rather than compared
against a list, so a wrong key costs exactly what a right one costs.
The SDK carries them: ContentClient(api_key=…), falling back to
CONTENT_API_KEY. The same script therefore reaches a self-hosted and a hosted
engine by changing its environment, not its imports. A client you supply
yourself is never mutated — the credential goes on the requests, not onto your
object.
none mode can mint keys too, deliberately. They belong to local and
authenticate nothing that was not already open, but they let you prepare keys
before switching an instance to token, instead of being locked out at the
moment you flip it.
Mail leaves through one door
The sign-in link has to arrive somewhere, so this release adds a small
outbound-email service
(ADR 0031),
with its own chart under deploy/charts/mailer. A product posts a message and
gets an id; it never speaks SMTP and never holds the provider password.
Acceptance and delivery are separate: the API answers as soon as the message is
durably queued, and a worker delivers it. A provider outage therefore delays
mail instead of failing a sign-in. Failures are classified rather than counted
— a 5xx refusal is final, everything else backs off exponentially, and an
authentication failure is the deliberate exception, because an operator can fix
it and the message should wait rather than be thrown away.
With no mailer configured the link is written to the log. That is not a
degraded mode for a single-user instance, and it is how an operator recovers
when the mail service is down.
The API stops waiting behind a transcode
CONTENT_WORKER_ENABLED splits the API from the worker
(ADR 0032).
Same image, same code, one variable: one deployment answers requests, another
does the heavy work.
No second image was needed, because the queue was already built for it —
claiming a job takes the oldest queued row under BEGIN IMMEDIATE and marks it
running in the same transaction, so several processes draw from it without ever
claiming the same job. In the chart, worker.enabled renders the second
deployment.
What it fixes is latency, not throughput. Every pod on a node shares the
same cores; how much runs at once is still CONTENT_MAX_CONCURRENT_JOBS. And
every pod must stay on one node: the data volume is node-bound, and a SQLite
file reached over a network share corrupts.
Unset means enabled, so every existing deployment behaves exactly as before.
The delivery library learns who it belongs to
CONTENT_DELIVERY_SCOPE takes shared (the default and today's behaviour),
per_owner (everything under <owner_id>/) or off (no server-side library;
artifacts are downloaded instead).
It is a policy, never a question about the deployment mode — nothing in the
code asks "am I hosted", it asks what the delivery policy is. And the default
stays shared on purpose: /output is a library a human organises and a media
server reads, so inserting an owner level would break paths that work today
(ADR 0018,
revisited).
off refuses an asked-for delivery with the stable code
delivery_not_supported rather than silently dropping it: a caller who names a
folder and receives nothing has been lied to. GET /api/v1/folders now lists
the caller's own subtree.
Chart and housekeeping
- One surface can answer on several hostnames (
ingress.extraHosts), so a
LAN name and a public one can coexist without rewriting theHostheader in
a proxy — a rewrite makes the applications build links for a name nobody
typed. - The chart is in English, its defaults are
example.complaceholders
rather than any real deployment, and a new key no longer breaks
helm upgrade --reuse-valueson a release installed before it existed. make versionnow checks the chart'sappVersion. It is the image tag a
default install picks, so a stale one silently deploys the previous release.
Upgrading
Nothing to do. The database migrates itself, CONTENT_AUTH_MODE defaults to
none, CONTENT_WORKER_ENABLED defaults to on, and CONTENT_DELIVERY_SCOPE
defaults to shared.
CONTENT_AUTH_MODE=token before a mailer is reachable.
Without one, nobody receives a sign-in link and nobody can get in.
Content v0.7.1
One field, added because a true answer was still a misleading one.
A derivable capability names the path it takes
v0.7.0 made pdf.render and markdown.export derivable on a video — and
reported them as derivable ← subtitles, which reads as "a PDF from the
subtitles". The real path is subtitles → transcript → summary → pdf: the
default deliberately produces a summary document, not a transcript dump
(ADR 0028), and nothing in the feed said so.
Each resolved capability now carries derivation — the whole material
chain of its selected variant, source first, artifact last:
{
"id": "pdf.render",
"status": "derivable",
"selected_variant": "pdf.render.via_summary",
"derived_from": ["subtitles"],
"derivation": ["subtitles", "transcript", "summary", "pdf"]
}It is computed from the transformation registry's own declarations — one
source of truth, so any future variant answers its chain for free — and the
field is additive: derived_from is unchanged and older clients simply
ignore it.
Content Admin renders it: a derivable row now reads
pdf.render derivable · subtitles → transcript → summary → pdf, and falls
back to the previous display against an older engine.
Content v0.7.0
A capability is now judged by what a source can yield, not only by what it
carries — which turns "PDF export isn't available for this video" from a false
answer into a working one. Plus a second MCP transport, an honest polling
hint, and three security-audit findings closed.
A summary PDF from any talking video
The engine could always do it — analyze, transcribe, summarize, render — but
only if you spelled the chain out with from_outputs. Asked plainly,
[{"type": "pdf"}] on a video was refused at feasibility, and
POST /capabilities reported pdf.render: unavailable (missing_material: text) three lines under summary.generate: derivable. Every agent fronting
the engine faithfully repeated the false half.
Documents now get the judgement transcripts and summaries always had
(ADR 0028):
pdf.renderandmarkdown.exportarederivableon a source whose text
can be produced, withselected_variantnaming the path
(pdf.render.via_summary— subtitles when present, speech-to-text when
installed).text.extractstays honestlyunavailableon a video:
extracting is not deriving.- A plain
{"type": "pdf"}plans the derivation itself. It renders a text
output already in your request when there is one (a summary first, then a
transcript, then a translation), otherwise it inserts the default summary
chain.markdowndoes the same, minus the render step — its canonical form
already is Markdown — and asking for both costs one summarization,
shared by step signature. - The explicit form always wins.
{"type": "pdf", "from_outputs": ["t"]}
still means exactly the transcript, and stays the way to say precisely
which text you want rendered. The default is the summary on purpose: a
40-page verbatim transcript is rarely what "a PDF of this video" means. - The artifact says what it contains. A derived document is named
Talk - summary.pdf— the same name the explicit composition produces —
never the bareTalk.pdf, which would claim the file is the talk.
When a document is still impossible, the refusal now names the missing piece
in your terms — "the source carries no text, subtitles or audio to build it
from", a missing runner (text.summarize, audio.transcribe), or the
blocking policy — instead of the bare "'pdf' cannot be produced from source
'main'."
MCP: a second transport, for clients that cannot spawn a process
CONTENT_MCP_TRANSPORT=streamable-http starts the same server over HTTP —
for Open WebUI and every other client that cannot spawn a subprocess. The two
conditions stated when this was promised publicly hold: loopback by
default (CONTENT_MCP_HTTP_HOST=127.0.0.1, port 8770), and local paths
refused over the network transport rather than silently resolved on
whatever host the server happens to run on. stdio remains the default and the
recommended transport; the reasons in the
MCP guide
have not moved.
MCP: get_job tells you when to ask again
Job answers now carry poll_after — the engine's own estimate of when the
next poll is worth making. An agent that respects it stops burning its context
window on running… running… running….
MCP: three audit findings closed
From the 2026-08-23 security audit of the MCP server:
analyze_sourcegets a read boundary.CONTENT_MCP_ALLOWED_READ_DIRS
confines local-file reads the waydownload_artifactwas always confined —
and every local read is refused by default until the operator opens a
directory. Same shape as the engine's ownCONTENT_ALLOWED_INPUT_ROOTS.- A symlink as the final path component no longer slips past
download_artifact's boundary check — the path is fully resolved before
the check, matching what the docstring already claimed. - Artifact content read back through MCP is marked untrusted, so an agent
has a fighting chance against instructions embedded in a downloaded
subtitle file.
Operators: a release is not verified until the deployment answers
make verify-deployment ENGINE=http://…:8010 VERSION=x.y.z asks the running
engine the questions a test suite cannot: the served version, the real tool
versions behind /health, an analysis, a capability resolution, and one
end-to-end job whose artifact reads back byte-identical — submitted with
delivery: none, so verification leaves nothing in your library.
Content v0.6.8
The image builds again on a base that changed underneath it, and delivering to
a network share works.
Why this one matters
Two independent breakages, both invisible from inside a green CI.
The base image stopped shipping pip. Upstream jauderho/yt-dlp removed pip
and marked its Python externally managed (PEP 668), so pip install there is
now both absent and refused by policy. Nothing broke for anyone running 0.6.7 —
that image was already built — but the next build of it could not succeed.
The daily refresh that keeps latest on a current yt-dlp had been failing on
pip: not found since, and a maintainer bumping the base pin would have met
the same wall with nothing to say the contract had changed.
Dependencies now install into a virtual environment, which is what PEP 668
points at and asks nothing of the base beyond the standard library. Verified by
building and running against both the old base and the current one.
Publishing to a CIFS/SMB share was impossible. publish_file stages a copy
beside the destination and renames it — and on a share, "beside the
destination" is the share. It used shutil.copy2, which is copyfile plus
copystat, and copystat calls utime, which CIFS/SMB refuses outright. The
bytes landed and the call raised anyway, so the staged file was removed and the
publish failed with the copy already complete. A modification time was failing a
finished artifact.
It degraded honestly — the source survived, nothing partial was left behind, no
library was corrupted — but nobody delivering to a network share could publish
anything at all. The copy and the metadata are now separate operations:
timestamps are still preserved wherever the filesystem allows it, and a refusal
no longer costs the artifact.
Same root cause as HomeTube #122, fixed there in v2.12.1. Reported and
diagnosed by @stanthewizzard.
Also in this release
- The yt-dlp refresh ran the released copy of its own checker rather than the
current one, so it handed Docker empty build arguments and died on
jauderho/yt-dlp:@. The pin being judged belongs to the release; the checker
reading it is tooling and belongs to main. - When the released tree genuinely cannot be rebuilt on a newer base, that
refresh now files it as an issue and ends green instead of going red every
morning about something the reader cannot act on. Everything after the build —
the boot check, the publish — is still a hard failure. - The OpenSSF Scorecard badge read "invalid repo path" while the data was there
all along: the published URL redirects to shields.io, which lowercases the
path, and the Scorecard API is case-sensitive aboutLatentNoise.
Validated before shipping
make validategreen — 808 backend tests, plus the SDK, CLI, MCP and UI
suites.- The image built and run against both the pinned base and the current one:
the app constructs,python3resolves inside the venv, yt-dlp answers
2026.08.19, ffmpeg 8.1.2 and Typst 0.15.1 still work. - The CIFS/SMB fix has three new tests, checked against the unpatched module
first — they fail there withPermissionError: [Errno 1] Operation not permitted. One of them pins thatcopystatis still called on the ordinary
cross-filesystem path, so tolerating a refusal cannot quietly become "stop
preserving timestamps everywhere".
Upgrading
Nothing to change. docker compose pull && docker compose up -d, or pin
0.6.8 if you follow exact versions.
If your delivery directory is a CIFS/SMB mount, this is the release that makes
it work.
Content v0.6.7
The engine downloads with yt-dlp 2026.08.19, seven weeks newer than the
version 0.6.6 shipped with.
Why this one matters
yt-dlp is the dependency that rots on its own: YouTube changes, and a yt-dlp
that has not moved stops working — usually not with a clear error, but with
"Requested format is not available" or a refusal that looks like something
else. The pin sat on 2026.07.04 while 2026.08.19 had been out for weeks.
If you download from YouTube, take this release.
How it went unnoticed, and the fix for that
A scheduled job watches the base image and files an issue when upstream moves.
It had filed one, titled:
yt-dlp base image: 2026.07.04 available (pinned: 2026.07.04)
The same version on both sides, which reads like a broken checker. It was not:
the tag had been rebuilt — same yt-dlp, new digest — which is exactly why the
pin carries a digest and not just a version. But the title made a routine
rebuild indistinguishable from a real version bump, so the alert that mattered
looked like the noise, and the noise is what everyone learns to skip.
The watcher now says which of the three things happened:
| Situation | Title |
|---|---|
| New version | yt-dlp 2026.08.19 available (pinned: 2026.07.04) |
| Rebuilt tag | yt-dlp base image rebuilt: 2026.07.04 republished, same yt-dlp |
| Digest with no version tag | yt-dlp base image: untagged sha256:… (pinned: …) |
The body opens on the same distinction, and three tests pin the wordings. The
new pin also takes its digest from the version tag rather than from
latest, so the two cannot drift apart.
Validated before shipping
Per docs/operations/ytdlp-base-image.md, on the real image rather than from
reading the diff: it builds on the new base; yt-dlp --version inside answers
2026.08.19; create_app() still constructs (the boot guard added in 0.6.4);
Typst 0.15.1 and ffmpeg 8.1.2 survive the new base; a real YouTube download
lands 9.7 MB through it; make validate is green; and the release-marked
checks pass 7/7 — including the canary that asserts video.download stays
available for a known-good video, which is the check that would have caught a
stale yt-dlp on its own.
Upgrading
docker compose pull && docker compose up -dThe Python packages are unchanged in substance this release; the engine image
is the one that moved.
Full changelog: v0.6.6...v0.6.7
Content v0.6.6
Two answers to the announcement's first comments, and a PDF that states its
title once.
An uploaded file says where it went and how long it stays
"For a remote backend, I'd make upload location and retention explicit in
every tool response. That is the part that gets sketchy once an agent can
pull files off a laptop."
Correct, and it was not. analyze_source uploaded a local file and returned an
analysis_id and nothing else. The policy existed — an engine-owned store,
deliberately outside the allowed input roots that govern file sources, swept
24 h after an upload's last use rather than its creation — but only in
documentation, at the exact moment nobody reads documentation.
Now the answer carries the facts:
"upload": {
"upload_id": "upl_…", "filename": "report.pdf", "size_bytes": 1583,
"stored_on": "http://nas.local:8010",
"retention": "deleted 24h after last use",
"remove_with": "delete_upload"
}GET /api/v1/config reports the policy (TTL, whether it counts from last use,
size and quota limits) so a client knows before sending, and delete_upload
is the counterpart the server lacked: the agent put a copy of someone's file on
another machine, so it can take it back without waiting for the window. It
removes an upload and only that — never an artifact, never a file in the
library.
The retention string is read from the engine, never assumed. An engine too old
to report its policy answers unknown rather than a comfortable guess: found
by running it against a 0.6.5 engine, where the first version said "no TTL
configured" while the default is in fact 24 h.
Why the MCP server speaks stdio
"You should use http connection as it's more useful on general use."
Fair suggestion, and the README was answering it badly: stdio only sat under
what it does not support, so a considered choice read as a gap.
It is the opposite, and the reason is the point of the project. Content turns
resources you already have into artifacts, and many of those live on the
machine you are sitting at. Over stdio the server runs where you do, which is
the only reason "summarize ~/Documents/report.pdf" means anything: the file is
read locally and uploaded to an engine that may be a NAS in another room. Put
the server behind an HTTP endpoint next to the engine and the sentence
collapses — the path would resolve on the server's filesystem.
Two consequences worth keeping: stdio has no port, which matters on an engine
that deliberately has no authentication (ADR 0024), and the client owns the
process lifecycle, so there is no service to supervise.
The honest half is documented too: stdio cannot serve a client that cannot
spawn a process — Open WebUI in a container, a hosted UI. mcpo bridges that
today. An HTTP transport stays possible as a second mode, bound to loopback
and refusing local paths outright.
A PDF states its title once, and looks like this decade
A generated PDF showed its title twice. Not the renderer — neither backend
draws the title into the body, and a one-heading document renders as one
heading. The source carried it twice, which happens whenever something writes a
header above a body that titles itself, and an LLM summary titles itself almost
every time. The parser now collapses the narrow case: two headings, same level,
same text, adjacent or separated by nothing but rules. The same title further
down is left alone — a recurring section heading is a legitimate shape.
The Typst template also gained an actual design: Libertinus for the body, one
accent colour, a title over a hairline instead of a rule that repeats down the
page, coloured list markers, italic quotes, code in a bordered rounded block,
and a page number that starts at page two. Level-3 headings stop being grey,
which read as less important than the list underneath.
Upgrading
docker compose pull && docker compose up -d
uv tool install --reinstall --refresh content-mcp # or nothing, with uvxFull changelog: v0.6.5...v0.6.6
Content v0.6.5
The MCP server documents itself honestly, and gains the tool that was missing
beside cancel_job.
retry_job
An agent that watched a job fail could report the failure and nothing else.
cancel_job existed; its counterpart did not — and "try that again" is the
obvious move for a transient failure, a 429 from a provider or a network blip.
retry_job(job_id) → a new job, carrying retry_of
It re-runs the whole request, and the tool description says so: a playlist
where one member failed downloads every member again. Retrying only what failed
is a decision still being made
(ADR 0025,
proposed in this release), and a guess at the fine version would be worse than
an honest coarse one.
What the server supports, does not support, and will
apps/mcp/README.md — which is also the package page on PyPI — led with a tool
list. That answers how do I call it and leaves the two questions someone
actually has: can it do the thing I want, and what will I find out the hard way.
It now opens with three tables:
- What it supports today — URLs and playlists, local files uploaded from
the machine running the server, PDFs read for their text layer, the twelve
output types, delivery into a library, cookie-authenticated sources. Every
row was driven over stdio against a running engine, not inferred from code. - What it does not support — stdio only, no live progress, no
.docx/
.epub/.odt/.rtf, no OCR for scans, no transcoding, no playlist
sync, nothing deletes anything, and an API with no authentication. - What is coming — each line pointing at the ADR or milestone where the
decision lives, so a gap reads as a plan rather than as neglect.
The AI-backed outputs get their own paragraph, because summary being
unavailable on an engine with no Ollama is the most likely first surprise — and
the engine already answers it up front, reporting the capability as
unavailable instead of failing halfway.
Installing it without installing it
uvx content-mcp is now the first form the docs show. The client spawns it, uv
fetches the wheel on first use and follows PyPI from then on:
{ "mcpServers": { "content": {
"command": "uvx", "args": ["content-mcp"],
"env": { "CONTENT_API_URL": "http://localhost:8010" } } } }uv tool install and pipx install remain, for an offline machine or to pin
the version. Same wheel; the difference is only who updates it.
Also
- ADR 0025 ships as a proposal: retrying only what failed, and why the
obvious design ("skip the steps that succeeded") is unsound while
intermediates are job-local and the cross-job cache is off. - Makefile prompts accept Enter.
[Y/n]for a yes/no,[value]for a
default;version-updateoffers the patch bump it was already printing.
Upgrading
docker compose pull && docker compose up -d
uv tool install --reinstall --refresh content-mcp # or nothing, with uvxFull changelog: v0.6.4...v0.6.5
Content v0.6.4
If you are running the Docker image, take this release. Every published
backend image from 0.5.0 to 0.6.3 could not start.
The API could not boot
A stock docker compose up -d reported only:
dependency failed to start: container content is unhealthy
which is doubly misleading — the healthcheck never ran, because the process
died before it could. The real answer was in docker logs:
RuntimeError: Form data requires "python-multipart" to be installed.
0.5.0 added POST /api/v1/uploads (ADR 0020). Its UploadFile parameter makes
FastAPI require python-multipart to register the route — not to serve a
request, to build the app object at all. So create_app() raised on start and
there was no API, for four releases.
Anyone who pulled 0.5.0 or later has an engine that cannot start. Upgrading is
the whole fix:
docker compose pull && docker compose up -dThe older tags are left as they are. Republishing 0.5.0 with different
contents would make a version number mean two things, which is worse than a tag
that is honestly broken — and 0.6, 0.6.4 and latest all now point at an
image that boots. If you pinned an exact old tag, move to 0.6.4.
Why four releases went out with it
Three things were true at once, and all three are fixed rather than only the
first.
It was declared nowhere. Not in the pyproject, not in the image. It is now
a runtime dependency at FastAPI's own bound.
The image repeated the dependency list by hand. That is what let the two
diverge: the code gained a feature, the pyproject did not, and the Dockerfile's
list certainly did not. The image now derives its install from the pyproject it
already copies, so there is one list instead of two.
Nothing could have caught it. Content never imports python-multipart —
FastAPI does, internally and lazily — so no import-vs-declaration audit sees it,
and no unit test can: the defect exists only in the built artifact. Worse, the
suite stayed green because make install also installs the MCP server, whose
mcp dependency pulls python-multipart in. The development environment
supplied by accident exactly what the image lacked.
So the guard now sits where the defect lives: CI builds the image and calls
create_app() inside it before anything is pushed. A release that cannot boot
no longer reaches the registry.
Verifying it yourself
docker run --rm ghcr.io/latentnoise/content:0.6.4 \
python3 -c "from content.api.app import create_app; create_app(); print('ok')"Full changelog: v0.6.3...v0.6.4
Content v0.6.3
Content reads a PDF.
summarize this PDF now has an answer
.pdf was recognised and honestly refused — "recognised but not yet readable
by this installation" — because doing it well needs a real dependency. It is
also the single most common thing anyone asks an agent to do with a file.
The dependency is pypdf, chosen for the same two properties that decided
reportlab: BSD-3-Clause, and a pure-Python wheel that installs on musl with no
toolchain. It ships in the image by default, so this works on a stock install
rather than for whoever thought to add an extra.
"Summarize this PDF from my laptop and give me a Markdown file and a PDF."
That sentence now runs end to end through the MCP server: the file is uploaded
from the machine running the agent, its text is extracted, and the outputs land
in the engine's library.
It reads the text layer. A scanned page holds an image of words and no
words, so it gets its own answer — "most likely a scan, reading one needs
OCR" — rather than being reported as an empty document, because that sends you
somewhere useful. A password-protected file says so too, after trying the empty
user password that usually works. .docx, .epub, .odt and .rtf are still
recognised and refused, each needing its own reader.
Two defects this uncovered, worth knowing about
Both were found by running the thing end to end, not by reading the code, and
both are the same shape: analysis and execution read a file through different
lines.
The first is the one that shipped in this release's feature: teaching only the
analysis path made a PDF look readable, and the artifact then contained
%PDF-1.3 … /BaseFont /Helvetica. Nothing failed. The job succeeded and the
agent summarised PostScript operators — worse than a refusal, because a refusal
is actionable.
The second was already there and is fixed in 0.6.1: an uploaded file
analysed cleanly and then died in its first step, on every route that uploads
(Studio's From this device, the SDK, the MCP server).
If you are on 0.6.0 or earlier, this is the release to take.
Upgrading
docker compose pull && docker compose up -d
uv tool install --reinstall --refresh content-mcpNo migration. The image gains one pure-Python package.
Full changelog: v0.6.2...v0.6.3
Content v0.6.2
One fix, for the first thing a new user is likely to hit.
A YouTube refusal that reads as a format problem is a cookie problem
An anonymous download can fail like this:
WARNING: Only images are available for download
ERROR: Requested format is not available
The format is available. YouTube had stopped serving it to an anonymous client
it had come to distrust, and answered with a degraded list holding nothing but
storyboards. Verified while testing 0.6.1: the same URL, with a cookies file,
downloaded seconds later — byte for byte the same file.
Classified as format_unavailable, that sends you to read about codecs and
containers when what you need is a cookies.txt. It is now classified as
bot_detection, which carries the remedy added in 0.6.1: it says whether no
credential is configured, whether one is declared but its file is missing (so
the download ran anonymously), or whether cookies were used and still refused.
The marker is narrow — only images are available — so asking for a format a
source genuinely lacks is still reported as a format problem. Both directions
are pinned by tests.
If you download from YouTube without cookies, this is worth reading:
config/README.md.
From a clone, make cookies FILE=~/Downloads/cookies.txt installs an export.
Upgrading
docker compose pull && docker compose up -d
uv tool install --reinstall --refresh content-mcpFull changelog: v0.6.1...v0.6.2