Wiring LiveView Uploads to Claude Vision
Boundary-validate uploads in allow_upload before they cost tokens, then wire consume_uploaded_entries into Claude vision and PDF calls from LiveView.
TL;DR: Every tutorial that wires a file upload to a vision model teaches the happy path: pick a file, send it, print the answer. None of them teach the part that actually costs money — validating the file before it burns tokens, not after. LiveView’s
allow_upload/3gives you that boundary for free: type and size limits enforced client-side and re-checked server-side, before a single byte reaches your API key. This post builds the full loop —allow_uploadwith real limits,consume_uploaded_entriesreading the file off disk, a page-count gate for PDFs that no vendor doc will tell you to add, both the image and PDF request shapes for Claude, two distinct loading states (uploading vs. analyzing), and what each request actually costs in tokens, cited against the current platform limits.
The boundary nobody enforces
Search “LiveView file upload” and you’ll land on tutorials from 2021
— allow_upload, drag a file, save it to disk, done. Search
“Claude vision API” and you’ll land on Anthropic’s own docs, which are
excellent at showing you the request shape and correctly silent on
everything downstream of it, because the vendor doc has no reason to
care what you spend. Nobody has written the connective tissue: what
happens when you wire the two together and a user drops a 40-page PDF
into a field you built for logo screenshots.
The fix isn’t clever. It’s enforcing limits at the boundary — in
allow_upload/3, before the file is even fully uploaded —
instead of checking after you’ve already paid for the model call. Here’s
the config, with every option doing real work:
def mount(_params, _session, socket) do
socket =
socket
|> assign(:stage, :idle)
|> assign(:analysis, AsyncResult.ok(:idle))
|> allow_upload(:doc,
accept: ~w(.jpg .jpeg .png .webp .pdf),
max_entries: 1,
# Claude's base64 image cap is 10MB, and base64
# inflates the raw bytes by ~33%. Cap the raw
# upload well under that so encoding never pushes
# a "valid" file over the API's real limit.
max_file_size: 7_500_000,
auto_upload: true,
progress: &handle_progress/3
)
{:ok, socket}
endThat comment is the whole point of this post in one place.
max_file_size isn’t an arbitrary round number — it’s
derived from a real constraint. Claude’s Messages API accepts
base64-encoded images up to 10MB, and base64 encoding inflates raw bytes
by roughly 4/3 (Anthropic’s
vision limits). A 9.5MB JPEG passes a naive
max_file_size: 10_000_000 check and then gets rejected by
the API after upload finishes, chunk cost paid and user staring at an
error. allow_upload’s own docs are explicit that
max_file_size defaults to 8MB and is enforced as chunks
arrive, not just at selection time (Phoenix.LiveView.allow_upload/3)
— set it to the number that matches your downstream API’s
actual ceiling, not LiveView’s default.
Validate at the boundary. The generic tutorials skip this because vendor docs have no reason to care what you spend.
accept does the same job for file type — reject a
.docx at the file picker, not inside your Claude wrapper
after it’s already on disk. That’s necessary, but for PDFs it isn’t
sufficient: a 50-page PDF can be well under 7.5MB and still blow through
Claude’s page limits and burn thousands of tokens you didn’t budget for.
That gate has to happen after the file lands but before you call the API
— more on it below.
Two upload paths, one shared entry point
Claude handles images and PDFs through two different content-block
shapes, but both start the same way in LiveView: a single
allow_upload config accepting both extensions, and
consume_uploaded_entries reading whichever one landed. The
branch happens once, at the API call, not in the upload plumbing.
The template needs live_file_input/1 for the picker and
a per-entry progress bar — entry.progress updates
automatically as chunks arrive, no polling required:
<div class="uploader" phx-drop-target={@uploads.doc.ref}>
<.live_file_input upload={@uploads.doc} />
<div :for={entry <- @uploads.doc.entries}>
<p>{entry.client_name}</p>
<progress value={entry.progress} max="100" />
<span>{entry.progress}%</span>
<p :for={err <- upload_errors(@uploads.doc, entry)}>
{upload_error_to_string(err)}
</p>
</div>
<p :for={err <- upload_errors(@uploads.doc)}>
{upload_error_to_string(err)}
</p>
</div>upload_errors/2 (per-entry) and
upload_errors/1 (upload-wide) surface exactly the failures
allow_upload’s limits produce — :too_large and
:not_accepted are the two you’ll see from the config above
(upload_errors/2).
Render them. A silently-rejected file with no feedback is worse than no
limit at all, because the user just tries again with the same file.
Consuming the entry: two states, not one
Here’s the detail almost every demo gets wrong: “uploading” and “analyzing” are different states with different failure modes, and collapsing them into one spinner hides which half of the pipeline actually broke. A stalled upload bar means the network is slow. A stuck “analyzing” spinner means Claude is slow, or down, or the file confused it. Users — and you, debugging a support ticket — need to know which.
I’m pairing auto_upload: true with a
progress callback, which is the pattern LiveView’s own docs
recommend for consuming files the moment they finish rather than waiting
on a form submit (allow_upload/3
progress + auto_upload example):
defp handle_progress(:doc, entry, socket) do
if entry.done? do
{:noreply, start_analysis(socket, entry)}
else
{:noreply, socket}
end
endstart_analysis/2 is where the upload state ends and the
analysis state begins — the seam where the two loading UIs need to
diverge:
defp start_analysis(socket, entry) do
{binary, media_type} =
consume_uploaded_entry(socket, entry, fn meta ->
{:ok, {File.read!(meta.path), entry.client_type}}
end)
case boundary_check(binary, media_type) do
:ok ->
socket
|> assign(:stage, :analyzing)
|> assign(:analysis, AsyncResult.loading())
|> start_async(:analysis, fn ->
MyApp.ClaudeVision.analyze(binary, media_type)
end)
{:error, reason} ->
socket
|> assign(:stage, :rejected)
|> put_flash(:error, reason)
end
endconsume_uploaded_entry/3 hands back whatever your
callback returns and removes the entry from the upload config; LiveView
guarantees it only runs once the entry is fully written to disk (consume_uploaded_entry/3).
Note the callback returns the file’s bytes, not
meta.path — the temp file is cleaned up once the entry is
consumed, so a path smuggled out of the callback points at nothing by
the time an async task gets around to reading it. The template reads
@stage to pick between “Uploading… 42%” and “Analyzing…”
instead of one generic loading blob:
<%= case @stage do %>
<% :analyzing -> %>
<p class="analyzing">
<span class="cursor">▌</span> Analyzing…
</p>
<% :rejected -> %>
<p class="error">File rejected before analysis.</p>
<% _ -> %>
<% end %>
<.async_result :let={text} assign={@analysis}>
<:loading>Thinking…</:loading>
<:failed :let={_reason}>
<p>The model couldn't read that file.</p>
</:failed>
<p>{text}</p>
</.async_result>handle_async/3 closes the loop, same
two-terminal-outcomes shape I used for streaming LLM tokens in
LiveView — a clean AsyncResult.ok or a
AsyncResult.failed, nothing left hanging:
def handle_async(:analysis, {:ok, text}, socket) do
{:noreply,
socket
|> assign(:stage, :done)
|> assign(:analysis,
AsyncResult.ok(socket.assigns.analysis, text))}
end
def handle_async(:analysis, {:exit, reason}, socket) do
{:noreply,
assign(socket, :analysis,
AsyncResult.failed(socket.assigns.analysis, reason))}
endThe page-count gate: the boundary check no vendor doc writes for you
allow_upload’s limits stop a file that’s the wrong
type or the wrong size. Neither stops a file that’s
the wrong shape — a PDF that’s technically 6MB but has 80 pages
of dense text, which is exactly the kind of file a real user uploads to
a “summarize this contract” feature. Claude’s own PDF limits cap
requests at 600 pages, or 100 pages when the request’s context window is
under 1M tokens — which is the default for most models you’ll actually
be calling (Anthropic
PDF support, page and size limits). A file under every
allow_upload limit can still land well past that.
Getting an exact page count means parsing the PDF’s object structure, which is more machinery than this feature needs. A byte-scan for page objects is a cheap, honest approximation — cheap enough to run before the API call, honest enough to catch the case that matters (an 80-page file, not an off-by-one on a 99-page one):
defmodule MyApp.PdfInspect do
# ponytail: byte-scan heuristic, not a real parser.
# Swap for a proper PDF library if you need exact
# counts on malformed or linearized PDFs.
def count_pages(binary) when is_binary(binary) do
~r{/Type\s*/Page[^s]}
|> Regex.scan(binary)
|> length()
end
end
defp boundary_check(binary, "application/pdf") do
pages = MyApp.PdfInspect.count_pages(binary)
if pages <= 100 do
:ok
else
{:error, "PDF has #{pages} pages; 100 max."}
end
end
defp boundary_check(_binary, _media_type), do: :okThis is the gate the brief for this post exists to make: the check
runs on bytes already in memory (read once, inside the consume callback)
but strictly before start_async fires the network call. A
rejected 200-page PDF costs one disk read and a regex scan. An accepted
one costs a real API call and every token on every page.
What the request actually looks like — image and PDF
Both paths funnel through one wrapper, because a LiveView should
never hold an HTTP client directly against a third-party API — same rule
I used for the streaming post’s MyApp.LLM module. The only
branch is the content-block type: "image" for
pictures, "document" for PDFs, both carrying the same
base64 source shape:
defmodule MyApp.ClaudeVision do
@moduledoc "Boundary around Claude's vision/PDF API."
@endpoint "https://api.anthropic.com/v1/messages"
@model "claude-sonnet-5"
def analyze(binary, media_type) do
block_type =
if media_type == "application/pdf",
do: "document",
else: "image"
data = Base.encode64(binary)
body = %{
model: @model,
max_tokens: 1024,
messages: [
%{
role: "user",
content: [
%{
type: block_type,
source: %{
type: "base64",
media_type: media_type,
data: data
}
},
%{type: "text", text: prompt_for(block_type)}
]
}
]
}
resp =
Req.post!(@endpoint,
headers: [
{"x-api-key", api_key()},
{"anthropic-version", "2023-06-01"}
],
json: body,
receive_timeout: 60_000
)
resp.body["content"]
|> Enum.map_join("", & &1["text"])
end
defp prompt_for("document"),
do: "Summarize this document's key points."
defp prompt_for("image"),
do: "Describe what's in this image."
defp api_key, do: System.fetch_env!("ANTHROPIC_API_KEY")
endThat request shape — type: "image"
vs. type: "document", both under
source: {type: "base64", media_type: ..., data: ...} —
comes straight from Anthropic’s Messages API reference for vision
and PDF
support respectively. Under the hood, a PDF isn’t parsed as
text-only: each page is rendered to an image and paired with its
extracted text, so Claude reasons over the layout, charts, and tables
the same way it reasons over a screenshot (how
PDF support works). That’s the meaningful difference from a RAG
pipeline: pgvector-backed
RAG in Phoenix chunks and embeds text, blind to layout;
this path reasons over the document visually, which is what you
want for a form, a scanned contract, or a chart nobody transcribed.
Inline base64 vs. the Files API
Every example above sends the file inline as base64, which is the
right default for this feature: one upload, one analysis, one request.
Reach for the Files
API instead when either of two things is true. First, you’re near
the ceiling — a PDF pushing toward the 32MB total request-size limit, or
an image close to the 10MB base64 cap, where the encoding overhead
itself becomes the constraint. Second, the same file gets referenced
across multiple turns — a chat-style feature where the user asks three
follow-up questions about the document they uploaded once. Anthropic’s
own guidance is explicit here: resending base64 data on every turn of a
multi-turn conversation grows your payload every turn even though the
file never changed; uploading once and referencing a
file_id keeps it flat (Files
API for images). For the single-shot “drop a file, get an analysis”
flow this post builds, inline base64 is the simpler correct answer —
don’t add the extra round trip until a real multi-turn feature earns
it.
Streaming the analysis back
Everything above waits for the full AsyncResult.ok
before rendering anything — fine for a one- or two-sentence description,
worse for a long document summary where the user is staring at
“Thinking…” for ten seconds with nothing to read. If you want the
response to stream token-by-token instead, the plumbing doesn’t change:
swap the single AsyncResult.ok call for Claude’s streaming
response wired into the same start_async task via
send/2 and handle_info/2. That’s the exact
pattern — cancellation-safe, backpressure-aware — I built out in full in
Streaming LLM Tokens
in LiveView, the 2026 Way; nothing about the upload, the boundary
check, or the process lifecycle here changes when you add it.
What this actually costs
The page-count gate above isn’t paranoia — vision requests are priced
in visual tokens, and they add up faster than text. Claude tiles an
image into 28×28-pixel patches, so cost is
⌈width / 28⌉ × ⌈height / 28⌉ tokens: a 1000×1000px image
runs 1,296 tokens on any tier, and models with high-resolution support
(Sonnet 5 and Opus 4.8 among them) can process up to a 2576px long edge
at roughly 4,784 tokens before downscaling caps it further (resolution
and token cost). A single screenshot is cheap. A batch of them adds
up exactly like any other per-token cost.
PDFs cost more per unit, because each page pays twice: roughly 1,500–3,000 text tokens per page for the extracted content, plus the same image-token cost as a rendered screenshot of that page, since PDF support is built on the vision pipeline (PDF cost estimation). A ten-page contract can run 20,000+ tokens before the model writes a single word back. That’s the real reason the page-count gate belongs in the request path and not in a monitoring dashboard you check after the bill arrives — same principle as validating a prompt won’t blow the context window before you ever open a socket to the model.
Where the process boundary matters
One more thing worth naming, because it’s easy to build this feature
and never think about it: every piece of this — the upload, the boundary
check, the Claude call, the async result — lives inside one user’s
LiveView process. If it crashes mid-analysis, no other user’s upload is
affected, and the Req call inside start_async
dies with it, which means you’re not paying for a request nobody’s
waiting on anymore. That’s not an accident of this design, it’s the
reason to build vision features in Phoenix in the first place — the same
per-request process isolation I laid out in Elixir’s BEAM Is the Runtime AI
Agents Want. If this single-shot analysis grows into something that
loops — ask a follow-up, call a tool, re-analyze with more context — the
shape to reach for is the same GenServer loop from Build an AI Agent Loop
in 50 Lines of Elixir, not a bigger LiveView.
When not to build it this way
If the files are large, numerous, or need to be processed unattended — a nightly batch that re-analyzes every document uploaded that day — don’t run it through a live user-facing socket at all. Use Anthropic’s Message Batches API and an Oban job, and show the user a “processing” state that updates when the job finishes rather than holding a LiveView connection open for a long-running batch. And if the upload target is genuinely enormous — video, multi-gigabyte archives — you’re outside what any of Claude’s vision or document endpoints are built for; that’s a different pipeline entirely.
For the common case — a user drops a screenshot or a short PDF and wants an answer in seconds — this is the whole shape: validate at the boundary before it costs a token, consume the entry off disk, split the upload state from the analysis state in the UI, and let the process die cleanly when either the user or the model gives up.
If you’re building this kind of feature into an early-stage product and want a second set of eyes on where the cost and security boundaries actually belong, that’s exactly the kind of conversation a fractional CTO engagement starts with.