Sunday, 9 August 2026

Images, PDFs, and High-volume processing

 Images are not free in terms of context budget.

There are 3 patterns to process image to send to Claude : 

base64, a URL, or a Files API file_id

Base64 — most commonly used

Despite being the most verbose option, base64 dominates in production. The reason is simplicity — no external dependency, no URL management, no file storage infrastructure. You read the file, encode it, send it. Works everywhere, every time.

java
// Straightforward in Java — fits naturally into existing Spring Boot code
byte[] fileBytes = Files.readAllBytes(path);
String base64Data = Base64.getEncoder().encodeToString(fileBytes);

Common in: document processing pipelines, support ticket systems, invoice extractors, any backend that receives files via multipart upload and immediately processes them.


Files API (file_id) — growing fast, strong production case

Best choice when the same file is processed multiple times. You upload once, get a file_id, reuse it across requests without re-encoding and re-transmitting the same bytes repeatedly.

Upload PDF once    → file_id: file_01ABC
Request 1: analyse this file_id   → no re-upload
Request 2: summarize this file_id → no re-upload
Request 3: extract fields         → no re-upload

Common in: document management systems, legal review tools, any workflow where the same document is processed in multiple passes. The cost saving on repeated processing is significant — you're not paying to transmit the same large PDF on every API call.


URL — least common in production

Sounds convenient but has real problems in production:

- URL must be publicly accessible         → security concern
- Anthropic's servers fetch it directly   → your internal docs not reachable
- URL can expire or go offline            → reliability risk
- No control over when Anthropic fetches  → timing issues

Used mainly in: quick prototypes, demos, or public-facing content processing where the document is already publicly hosted. Rarely the right choice for enterprise production systems where documents are private.


Honest recommendation for your context

Given your Spring Boot / enterprise background:

Single-pass processing    → base64
                            simple, no infrastructure, fits your existing upload flow

Multi-pass same document  → Files API
                            upload once, reuse file_id, save bandwidth and token cost

Public documents already  → URL
hosted online               only if the document is already public and stable

Most mature production systems start with base64 and migrate specific high-volume document flows to Files API once the reuse pattern becomes clear.


In Files API (file_id) approach where to upload?

You upload to Anthropic's servers via a dedicated Files API endpoint:

POST https://api.anthropic.com/v1/files

This is a separate API call — not part of a messages call. You send the file bytes to Anthropic, they store it on their infrastructure, and they hand you back a file_id.


Who provides the file_id

Anthropic provides it in the response to your upload call:

java
// Step 1: Upload the file to Anthropic
POST /v1/files
Content-Type: multipart/form-data
Body: { file: <your PDF bytes> }

// Anthropic responds with:
{
  "id":         "file_01ABC",Anthropic generates this
  "filename":   "invoice.pdf",
  "size":       245678,
  "created_at": 1234567890
}

You store that file_id on your side — in your database, Redis, wherever makes sense for your application.


Step 2: Use the file_id in subsequent Messages API calls

java
// No re-upload needed — just reference the file_id
{
  "model": "claude-sonnet-4-6",
  "messages": [{
    "role": "user",
    "content": [
      {
        "type": "document",
        "source": {
          "type":    "file",
          "file_id": "file_01ABC"Anthropic fetches it from their own storage
        }
      },
      {
        "type": "text",
        "text": "Extract all line items from this invoice"
      }
    ]
  }]
}

The full flow in your Spring Boot context:

Your Application                    Anthropic
────────────────                    ──────────────────────────
User uploads PDF
    ↓
POST /v1/files          →           Receives PDF bytes
                                    Stores on their servers
                        ←           Returns file_01ABC

Store file_01ABC
in your DB
    ↓
User requests           →           Receives messages request
analysis                            Fetches PDF from own storage
                                    Processes it
                        ←           Returns analysis

User requests           →           Receives messages request
summary                             Fetches same PDF from storage
                                    Processes it
                        ←           Returns summary

Two important practical points:

File lifetime — Anthropic doesn't store your files forever. Files expire after a fixed period (currently 30 days). If your use case needs the file longer than that, you need to re-upload or manage expiry on your side.

Storage is not free — Storing files on Anthropic's infrastructure has its own cost separate from API token costs. Worth factoring in if you're processing high volumes of large documents.


Bottom line

You upload to Anthropic via /v1/files. Anthropic stores it and gives you back a file_id. You store that ID in your own database. Every subsequent Messages API call references the ID — Anthropic fetches the file from their own storage without you transmitting it again.


High-volume processing

The Message Batch API takes up to 100,000 requests or 256 MB per batch in a single batch call, returns a batch_id, and processes them asynchronously.

The fundamental difference is who does the work and when

Synchronous loop (10,000 calls):
─────────────────────────────────
Your code:    send ticket-1  → wait → get result → send ticket-2 → wait → get result...
Your server:  blocked for hours, holding connections open
Anthropic:    sees 10,000 live requests demanding immediate responses
Rate limiter: FIRES

Batch API (1 call):
──────────────────
Your code:    send all 10,000 as a manifest → get batch_id → move on, do other things
Your server:  free immediately, not blocked
Anthropic:    queues 10,000 items, processes them at their own pace over hours
Rate limiter: never fires — no live requests demanding immediate response

You are submitting 10,000 independent classification requests — each one is a completely separate Claude conversation with its own input.


Concrete example — support ticket classification:

Your database has 10,000 support tickets:
ticket-1: "I was charged twice for April"
ticket-2: "API returning 429 error"
ticket-3: "Cannot login to my account"
...
ticket-10000: "Where is my refund?"

Each ticket needs to be independently classified as BILLING, TECHNICAL, or ESCALATION. These are 10,000 completely separate tasks — not one big task.


What you actually send in the batch call:

json
{
  "requests": [
    {
      "custom_id": "ticket-1",
      "params": {
        "model": "claude-sonnet-4-6",
        "messages": [{"role": "user", 
                      "content": "Classify: I was charged twice for April"}]
      }
    },
    {
      "custom_id": "ticket-2", 
      "params": {
        "model": "claude-sonnet-4-6",
        "messages": [{"role": "user",
                      "content": "Classify: API returning 429 error"}]
      }
    },
    ...10,000 entries total
  ]
}

Each entry is a fully self-contained Claude request — its own model, its own messages, its own parameters.


What Anthropic processes asynchronously:

Your submission (1 HTTP call):
"Here are 10,000 independent Claude requests"

Anthropic internally:
Request 1  → spins up Claude → classifies ticket-1  → stores result
Request 2  → spins up Claude → classifies ticket-2  → stores result
Request 3  → spins up Claude → classifies ticket-3  → stores result
...
(spread across their infrastructure, parallel, over hours)

Your polling call (hours later):
"Is batch_id_123 complete?"
→ YES → fetch 10,000 individual results


We get one single batch_id for the entire submission. Not 10,000 batch IDs.


What Anthropic returns for your entire submission:

{
  "id":         "batch_01ABC",    ← one single batch_id for all 10,000 requests
  "status":     "in_progress",
  "request_counts": {
    "total":      10000,
    "processing": 10000,
    "succeeded":  0,
    "errored":    0
  },
  "created_at":  1234567890,
  "expires_at":  1234654290
}

One batch_id covers all 10,000 requests inside it.


Your polling loop — checking one batch_id only:

String batchId = "batch_01ABC";
// Poll on a schedule — not a tight loop
while (true) {
    BatchStatus status = checkBatchStatus(batchId);
    
    if (status.isComplete()) {
        break;  // all 10,000 done
    }
    
    log.info("Still processing: {}/{} complete", 
             status.getSucceeded(), 
             status.getTotal());
             
    Thread.sleep(Duration.ofMinutes(10));  // check every 10 minutes
}

You poll once against that single batch_id — not 10,000 times.


When complete, you fetch all results in one call:

// One call fetches all 10,000 results
List<BatchResult> results = fetchBatchResults(batchId);

// Match each result back to your input using custom_id
results.forEach(result -> {
    String ticketId    = result.getCustomId();   // "ticket-1", "ticket-2" etc
    String classification = result.getContent(); // "BILLING", "TECHNICAL" etc
    
    updateTicket(ticketId, classification);
});

The complete flow visualised:

Your code                                                      Anthropic
──────────────────                 ──────────────────────────
Submit 10,000 requests  →                      Receives entire batch
                                                             Returns ONE batch_01ABC
                                   
                                                                   Internally processes all
                                                                   10,000 requests async...
                                   
Poll batch_01ABC        →          status: in_progress (3,000/10,000 done)
Poll batch_01ABC        →          status: in_progress (7,500/10,000 done)
Poll batch_01ABC        →          status: complete    (10,000/10,000 done)

Fetch results                  →          Returns all 10,000 results
Match via custom_id                in arbitrary order
Update your database

Bottom line

One submission = one batch_id. You poll that single batch_id on a schedule until Anthropic tells you it's complete. Then you fetch all results in one call and use custom_id to match each result back to its original input. No nested loops, no 10,000 individual status checks.


Agent memory/state - Memory patterns

 External Storage — most common by far in production

Almost every production system uses this. User data, session history, preferences, task state — all of it needs to survive beyond a single session. In your Spring Boot world this maps directly to what you already know:

Redis      → short-lived session state, fast retrieval
PostgreSQL → long-term user history, structured data
MongoDB    → flexible document storage for conversation history

It's the default choice because production systems almost always need state to survive restarts, deploys, and user return visits.


No Persistent Memory — second most common

More common than people expect. A large category of production agents are pure task executors:

"Classify this ticket"          → stateless, done
"Summarize this document"       → stateless, done
"Convert this data to JSON"     → stateless, done
"Answer this one-off question"  → stateless, done

These don't need memory at all. Each request is fully self-contained. Zero overhead, zero complexity. For pipeline-style workloads this is the right default.


In-context Memory — situationally common

Used within a single session naturally — every chatbot does this by passing conversation history in the messages array. But as a deliberate persistence strategy it's limited to short sessions. In production it's rarely a standalone choice — it's always combined with external storage to handle the cross-session problem.


Summarized Memory — least common

Technically elegant but operationally complex. You need a reliable summarization prompt, you accept detail loss, and you add an extra API call at session boundaries. Most teams solve the same problem more simply by storing structured state in a database rather than summarizing free-form conversation. Used mainly in long-running conversational AI products — therapy bots, coaching assistants, companion apps — where the narrative continuity matters more than specific facts.


The typical production combination:

Most real systems don't pick just one — they layer them:

In-context memory     → within the current session (automatic)
     +
External storage      → user profile, preferences, task history (database)
     +
No persistent memory  → stateless tool-execution steps within the agent

Summarized memory gets added only when conversation history genuinely threatens to overflow the context window across sessions — which is a specific problem, not a general one.


SKILL.md vs CLAUDE.md

Where they are used and who uses them

These are primarily Claude Code concepts — the CLI tool developers use to let Claude work directly in their codebase. Not something you'd typically use in a Messages API integration you're building for end users.

Claude Code CLI          → developer opens terminal, runs Claude against codebase
                                       CLAUDE.md and Skills live in the project directory
                                       Claude reads them automatically

Your Spring Boot Agent   → you build the loop yourself
                                           you inject instructions via system prompt
                                           CLAUDE.md / Skills not relevant here

Think of it this way — CLAUDE.md and Skills are for developers using Claude as a coding assistant, not for developers building Claude-powered applications.


Who uses them in practice:

CLAUDE.md   → the team that owns the codebase
                      "Here are our coding standards, commit message format,
                       test requirements — apply these to everything"

Skills      →     individual developers or the team
                      "Here is how to do a security review"
                      "Here is how to write our API documentation"
                      "Here is our deployment checklist"
                       Loaded only when that specific task is requested

Why CLAUDE.md still exists if Skills are more efficient

This is the right question. Skills are more efficient — but only for instructions that apply to some tasks. CLAUDE.md exists for instructions that genuinely apply to every single task without exception.

Consider these instructions:

"Never commit secrets or API keys"
"All code must be Java 21 compatible"
"Always write unit tests for new methods"
"Our package structure is com.accenture.project.*"

These apply whether Claude is:

  • Reviewing security vulnerabilities
  • Writing a new feature
  • Fixing a bug
  • Writing documentation
  • Refactoring existing code

There is no task where these don't apply. Putting them in a Skill means they only load when a description matches — which means they might not load when Claude is doing a simple bug fix, and Claude violates a core team standard.

CLAUDE.md = constitutional rules
            apply unconditionally to everything
            zero risk of being missed

Skills    = specialist playbooks
            apply only to specific task types
            zero cost when not relevant

The production reality

In Claude Code usage — which is the primary home for both:

Most commonly used:   CLAUDE.md
                      Almost every team using Claude Code has one
                      It's the first thing you set up
                      Simple, unconditional, always works

Second:               Skills
                      Used by teams with mature Claude Code workflows
                      Who have identified specific recurring task types
                      Worth the overhead of maintaining skill files

Least common:         In-context instructions
                      Ad hoc, one-off, exploratory work
                      No maintenance overhead but no reuse either

Agent Construction : Workflow vs Agent & Human-in-the-loop

 "The agent is the pattern"

Every agent — regardless of how you build it — runs the same fundamental loop:

Receive goal
→ Claude thinks, picks a tool
→ Execute tool
→ Feed result back to Claude
→ Claude thinks again
→ Repeat until goal is met

That loop is the pattern. It never changes. Whether you're building a support agent, a coding agent, or a research agent — the loop is identical.


Workflow — deterministic

You know every step upfront and encode them in code. The sequence never changes regardless of input. Think of it like a Java method with a fixed execution path — same steps, same order, every time. The value is predictability, auditability, and guardrails at each step.

Step 1 → Step 2 → Step 3 → Done
(always, no deviation)

Agent — non-deterministic

You give Claude a goal and a toolset, and it decides at runtime which tools to call, in what order, and how many times. The path through the work is chosen by the model, not by your code. The value is flexibility — it handles inputs and situations you didn't anticipate when you wrote the system.

Goal → Claude decides → Tool A → Claude decides → Tool C → Tool A again → Done
(path chosen at runtime, different every time)

The one practical dimension worth adding

Most real production systems are neither pure workflow nor pure agent — they're a hybrid. The outer shell is a deterministic workflow (step 1: classify intent, step 2: route to handler, step 3: format output), and specific steps inside delegate to an agent where flexibility is genuinely needed.

Reach for the agent only at the points where the path truly cannot be predetermined. Keep everything else deterministic — it's cheaper, safer, and easier to debug.


In your raw loop (Messages API):

You pass everything on every single API call:

java
// You rebuild and resend this on EVERY turn of the loop
ObjectNode requestBody = mapper.createObjectNode();
requestBody.put("model",      "claude-sonnet-4-6");  // sent every turn
requestBody.put("max_tokens", 1024);                  // sent every turn
requestBody.set("tools",      tools);                 // sent every turn
requestBody.set("messages",   messages);              // sent every turn

Every iteration of your loop reconstructs and resends the full request. You own that loop in your Java code.


In Managed Agents:

You define all of those once upfront when creating the agent, and Anthropic stores it:

json
// One-time agent definition — stored by Anthropic
{
  "model":         "claude-sonnet-4-6",     // same as your requestBody.put("model"...)
  "system_prompt": "You are a support...",  // same as your system prompt message
  "tools":         [...],                   // same as your requestBody.set("tools"...)
  "mcp_servers":   [...],                   // MCP servers to connect (new — not in raw loop)
  "skills":        [...]                    // pre-built capabilities (new — not in raw loop)
}

Anthropic gives you back an agent ID. From that point, every request is just:

json
// Every subsequent call — just the user input
{
  "agent_id": "agt_01ABC",
  "input":    "What is the weather in Mumbai?"
}

No model, no tools, no system prompt repeated — Anthropic already has all that stored against the agent ID.


The two new parameters you don't have in your raw loop:

mcp_servers — instead of manually calling ListToolsRequest and wiring MCP yourself, you just declare which MCP servers the agent should connect to. Anthropic handles the connection and tool registration automatically.

skills — pre-built capabilities Anthropic provides (like web search, code execution) that you can attach to the agent without writing tool implementations yourself. There's no equivalent in your raw loop — you'd have to implement those tools manually.


Typical production pattern — hybrid usage

java
// Simple single-turn classification → raw Messages API
// No loop needed, full control, minimal overhead
ObjectNode requestBody = mapper.createObjectNode();
requestBody.put("model", "claude-sonnet-4-6");
requestBody.set("tools", tools);
requestBody.set("messages", messages);
JsonNode response = callClaudeAPI(requestBody);

// Complex multi-step agentic task → Agent SDK
// Loop management, tool execution handled automatically
AgentResult result = sdk.run(goal, tools, systemPrompt);

Why enterprises still lean toward raw Messages API even for agentic work

This is the part that surprises most people. The reasons are practical:

Observability — In enterprise environments, every tool call, every context state, every iteration needs to be logged and auditable. The raw loop makes every step explicit in your own code. SDK abstracts those steps — which means your existing monitoring, logging, and alerting infrastructure has less visibility into what's happening inside the loop.

Existing infrastructure — Teams like yours already have Spring Boot, WebFlux, Jackson, retry logic, circuit breakers. The SDK introduces a new dependency with its own design decisions that may conflict with or duplicate what you already have.

Control over context management — As you learned earlier, context management is critical in production. The raw loop lets you apply pruning, compaction, and token counting exactly where you need them. The SDK makes those decisions for you — which is convenient but sometimes not what you want.

Debugging — When something breaks in production at turn 6 of a complex agentic loop, you want to see exactly what was sent and received at each step. Raw loop gives you that naturally. SDK requires you to dig into its internals.

Human-in-the-loop (HITL): Insertion points and when each applies

A human-in-the-loop checkpoint pauses agent execution and routes to a human review step before proceeding. The question that determines where to insert one is: what is the worst possible outcome if this step runs without a human check?

Insertion pointWhat triggers the checkRisk level it addresses
Before a destructive tool callThe agent is about to execute a write, delete, or send operation.High: irreversible actions where a wrong call cannot be undone
After a planning stepThe agent has generated a plan and is about to begin executing it.Medium: incorrect plans that would produce the wrong outcome even if all steps execute correctly
On unexpected outputThe tool result contains an error flag, an empty result, or a value outside expected bounds.Variable: catches failure modes that retry logic alone will not resolve