Images, PDFs and Documents with Claude

Claude API & Tool Use with Angular and Node.js · Advanced API features · 12 min read · Updated

So far Claude has only read text that you typed. But real resumes arrive as PDF files, and people take photos of certificates and forms with their phones. Claude can read these too. In this lesson you add a Documents page to your AI Lab: the user uploads a resume file, and Claude finds the name, the skills and the jobs in it.

What Claude can read

Claude understands more than plain text. You can give it:

  • Images, such as a photo of a certificate, a screenshot or a scanned page (PNG, JPEG and WebP are common).
  • PDF files, including their text and their pictures and tables.

This is useful for many jobs: reading a resume from a PDF, pulling the numbers out of an invoice, describing a chart, or checking how a resume looks. Because it reads the picture of the page as well as its text, it can often manage layouts that simple text extraction breaks on, such as tables and columns.

How you send a file

A message's content does not have to be one piece of text. It can be a list of blocks: some files and some text. Files are sent as base64, which is a way of writing a file's bytes as plain text so that it can travel inside JSON. In Node, you read the file into a buffer and call buffer.toString("base64").

const data = fileBuffer.toString('base64');

const reply = await client.messages.create({
  model: MODEL,
  max_tokens: 1000,
  messages: [{
    role: 'user',
    content: [
      { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data } },
      { type: 'text', text: 'List the job titles and companies in this resume as JSON.' },
    ],
  }],
});

For an image, the block type is "image" and the media type is image/png, image/jpeg or image/webp. The text block comes after the file and tells Claude what to do with it. This is a good habit: the file first, the question after it.

Asking for JSON

Our page needs data it can show as a list, not a paragraph. So we ask Claude to reply with JSON only and we describe the exact shape: a name, a list of skills, and a list of jobs with title and company. We also say "use null when unknown" and "do not invent anything". This last sentence matters a lot for resumes. Without it, a model may try to be helpful and fill a gap with a guess, and a made-up job title on a resume is a serious problem.

Even with a good prompt, models sometimes wrap JSON in a code fence, or add a friendly sentence before it. Our server strips code fences and then parses the result. Then it checks the data against a zod schema, a small description of what valid data looks like: a name that is a string or null, skills that are a list of strings, and jobs that are a list of objects. If the check fails, we send a friendly error. We never pass unchecked model output to the browser or the database.

Handling uploads safely

A file upload is a risky door into your server, so be careful. In Express we use a library called multer to read the upload. These are the safety rules we follow.

  • Memory only. We keep the file in memory and never write it to disk, so there is nothing to clean up and nothing to leak. In your real product, decide how long you keep user files, and tell the users.
  • Limit the size. We accept 5 MB at most. A big file costs more tokens and can be used to attack your server. We turn the multer "too large" error into a clear 413 message.
  • Allow only the types you expect. PDF, PNG, JPEG and WebP only. Do not trust the file name or the type sent by the browser alone. For a PDF we also check that the file really starts with the characters "%PDF".
  • One file at a time. We set the maximum number of files to one.

Privacy comes first

A resume contains personal data: a name, a phone number, an address. When you send it to an AI service, you are sharing that data, so treat it carefully. Send only what you need. Tell users in plain words what happens to their file. Check the data-retention terms of your AI provider and follow the privacy laws that apply to your users. Never write the content of a user's file into your logs. In the lab page we show a short privacy reminder next to the upload button, and you should do the same in a real product.

Limits and quality

Files and images count as input tokens, so a long PDF costs more than a short text. There are limits on file size and on the number of pages, which you can find in the documentation. Scans with low quality, handwriting or very small text can be read wrongly. For anything important, show the extracted data to the user so they can correct it, rather than trusting it blindly. This "show and confirm" step is an easy way to catch mistakes.

The Angular side

In Angular, a file input gives you a File object when the user picks a file. To upload it, you create a FormData object, append the file to it, and post it with HttpClient. Do not set the content type header yourself; the browser adds the correct multipart header with a boundary automatically. After the answer arrives, store it in a signal and show the name, the skills as small chips and the jobs as a list.

Your task: build the Documents page

Let a user upload a resume PDF or photo, and show what Claude found in it.

What you will build

  • A POST /api/read-document route that accepts one file, checks it, sends it to Claude, validates the JSON answer and returns it.
  • A Documents page with a file input, a Read button, a privacy note, and a result view (name, skills, jobs).

Steps

  1. Start from your lesson 7 project, or download the lesson 7 solution.
  2. Use multer with memory storage, a 5 MB limit and one file. Write a small wrapper that converts multer errors into friendly 413 and 400 messages.
  3. Check the type: a PDF must have the PDF header; images must be PNG, JPEG or WebP. Reject everything else with 400.
  4. Build the file block (document or image) and the text block, and call Claude with max_tokens 1000.
  5. Strip code fences, parse the JSON and validate it with zod. On failure return 422 with a friendly message.
  6. In Angular, build the page with FormData. Show the result as a name, chips and a list.
  7. Test with a real PDF, with a photo of a resume, with a text file (it should be rejected) and with a file bigger than 5 MB.

Hints

  • Use upload.single("file") and read the file from req.file.
  • Do not set Content-Type yourself when sending FormData.
  • Keep the prompt strict: "JSON only, use null when unknown, do not invent anything".

You know it works when a clear resume gives a correct list, a bad file gives a friendly error, and the server never stores the file.

Solution

The route:

import { Router } from 'express';
import multer from 'multer';
import { z } from 'zod';
import { client, MODEL, textOf } from '../claude.js';

const router = Router();

// Keep the upload in memory only. We never write the file to disk or a database.
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024, files: 1 } });

// Turn upload problems (like a file that is too big) into a clear message.
const receiveFile = (req, res, next) =>
  upload.single('file')(req, res, (err) => {
    if (err) {
      err.status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
      err.userMessage = err.code === 'LIMIT_FILE_SIZE' ? 'That file is too large. The limit is 5 MB.' : 'The upload failed. Please try again.';
    }
    next(err);
  });

const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp'];

const Result = z.object({
  name: z.string().nullable(),
  skills: z.array(z.string()),
  jobs: z.array(z.object({ title: z.string(), company: z.string().nullable() })),
});

// Models sometimes wrap JSON in code fences. Strip them before parsing.
const parseJson = (text) => JSON.parse(text.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim());

// POST /api/read-document  (multipart form, field "file")
router.post('/read-document', receiveFile, async (req, res) => {
  const file = req.file;
  if (!file) return res.status(400).json({ error: { message: 'Please choose a file.' } });

  const isPdf = file.mimetype === 'application/pdf' && file.buffer.subarray(0, 4).toString() === '%PDF';
  const isImage = IMAGE_TYPES.includes(file.mimetype);
  if (!isPdf && !isImage) {
    return res.status(400).json({ error: { message: 'Upload a PDF, PNG, JPG or WebP file.' } });
  }

  const data = file.buffer.toString('base64');
  const fileBlock = isPdf
    ? { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data } }
    : { type: 'image', source: { type: 'base64', media_type: file.mimetype, data } };

  const reply = await client.messages.create({
    model: MODEL,
    max_tokens: 1000,
    messages: [{
      role: 'user',
      content: [
        fileBlock,
        { type: 'text', text: 'Read this resume. Reply with JSON only: {"name": string|null, "skills": string[], "jobs": [{"title": string, "company": string|null}]}. Use null when unknown. Do not invent anything.' },
      ],
    }],
  });

  let parsed;
  try {
    parsed = Result.parse(parseJson(textOf(reply)));
  } catch {
    return res.status(422).json({ error: { message: 'We could not read that file clearly. Try a clearer copy.' } });
  }
  res.json({ data: parsed, usage: reply.usage });
});

export default router;

The Angular page:

import { Component, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { errorText } from '../util';

type Parsed = { name: string | null; skills: string[]; jobs: { title: string; company: string | null }[] };

@Component({
  selector: 'app-documents',
  template: `
    <h1>Read a resume file</h1>
    <p class="hint">Upload a PDF or a photo of a resume. Claude reads it and we show the details it found.</p>

    <div class="card">
      <label for="f">Resume file (PDF, PNG, JPG, WebP; up to 5 MB)</label>
      <input id="f" type="file" accept="application/pdf,image/png,image/jpeg,image/webp" (change)="pick($event)" />
      <button (click)="upload()" [disabled]="busy() || !file">{{ busy() ? 'Reading...' : 'Read file' }}</button>
      <p class="muted">Privacy: the file is sent to the AI service for reading. This lab does not store it. In your own product, tell users what you do with their files.</p>
      @if (error()) { <div class="err">{{ error() }}</div> }
    </div>

    @if (result(); as r) {
      <div class="card">
        <h2>{{ r.name || 'Name not found' }}</h2>
        <h2>Skills</h2>
        <div class="chips">@for (s of r.skills; track s) { <span>{{ s }}</span> } @empty { <span class="muted">None found</span> }</div>
        <h2>Jobs</h2>
        <ul>@for (j of r.jobs; track $index) { <li>{{ j.title }}@if (j.company) { at {{ j.company }} }</li> } @empty { <li class="muted">None found</li> }</ul>
      </div>
    }
  `,
})
export class DocumentsPage {
  private http = inject(HttpClient);

  file: File | null = null;
  busy = signal(false);
  error = signal('');
  result = signal<Parsed | null>(null);

  pick(event: Event) {
    this.file = (event.target as HTMLInputElement).files?.[0] ?? null;
  }

  async upload() {
    if (!this.file) return;
    const body = new FormData();
    body.append('file', this.file);
    this.busy.set(true);
    this.error.set('');
    this.result.set(null);
    try {
      const r = await firstValueFrom(this.http.post<{ data: Parsed }>('/api/read-document', body));
      this.result.set(r.data);
    } catch (e) {
      this.error.set(errorText(e));
    } finally {
      this.busy.set(false);
    }
  }
}

Download the lesson 8 solution (zip)

What you learned

  • Claude can read PDFs and images. Send them as blocks, with your question after the file.
  • Ask for JSON, strip code fences, then validate with a schema before using the data.
  • Make uploads safe: memory only, a size limit, allowed types and a real-file check.
  • Treat user files as personal data and say what you do with them.

Lesson 9 is the big one: tool use, where Claude can ask your server to run functions.

Frequently asked questions

Can Claude read scanned PDFs?

It can often read text in images, but the quality depends on the scan. Always let the user check important extracted data.

Is it safe to send resumes to an AI API?

Treat them as personal data. Send only what you need, protect the data in transit and storage, follow your privacy policy and local law, and check the provider's data-retention terms.

Why validate JSON that Claude produced?

Because models can add extra text, skip a field or use a wrong type. Validation stops bad data from reaching your page or database.