DEV Community

Dinesh_gowtham
Dinesh_gowtham

Posted on

How to Build an AI-Powered Document Summarizer with Claude, S3, and Lambda, Explained Simply

When a PDF lands in an S3 bucket, a tiny piece of code can turn it into a concise summary without provisioning any servers. You’ll see how Claude’s language model, Bedrock’s API, and Lambda’s event‑driven model combine into a production‑ready workflow.


Why Serverless AI Makes Sense

The problem we’re solving

Imagine you run a small support team that receives dozens of PDF reports each day. A human has to skim each file, pull out the main points, and paste them into a ticket. That manual step adds latency and costs time. What we really need is a trigger: as soon as the file appears, something automatically reads it, asks an AI to “summarize this”, and stores the result for downstream consumers.

Why choose a serverless stack

  • No servers to manage – Lambda runs code only when it’s needed, so you never have to provision or patch an EC2 instance.
  • Automatic scaling – If ten PDFs arrive at once, Lambda will spin up ten concurrent executions without you writing any scaling logic.
  • Pay‑for‑use pricing – You are billed for the milliseconds your function actually runs, which keeps cost low for a bursty workload.

Think of serverless like a taxi service. When you need a ride, you hail a car; you don’t own a fleet or maintain a garage. The same idea applies to compute: you request a “car” (Lambda) only when you have a “passenger” (a PDF).

In plain English: Serverless lets you focus on the business logic (reading PDFs, summarizing) while AWS handles the heavy lifting of servers and scaling.

A quick glance at the pieces

Piece What it is Why it matters
Amazon S3 Object storage service that holds files like PDFs Acts as the inbox and outbox for our workflow
AWS Lambda Event‑driven compute that runs a function in response to S3 events Executes the summarization without a permanent server
Amazon Bedrock Managed service that gives API access to large language models (LLMs) such as Claude Provides the “brain” that can read text and write a summary
IAM Role Set of permissions that tell AWS services what they are allowed to do Guarantees the Lambda function can read PDFs, call Bedrock, and write back results

Below is a minimal Lambda handler skeleton that you will flesh out later. It uses the @aws-sdk/client-lambda package, which is the official TypeScript/JavaScript SDK for Lambda.

// src/handler.ts
import { S3Event, Context, Callback } from 'aws-lambda';

// Exported name must match the handler you configure in Lambda
export const summarizer = async (event: S3Event, context: Context, callback: Callback) => {
  // Lambda gives you an event object that tells you which bucket & key triggered it
  console.log('Received event:', JSON.stringify(event, null, 2));

  // For now we just acknowledge the event – real work will happen later
  callback(null, { message: 'Event received' });
};
Enter fullscreen mode Exit fullscreen mode

Key takeaway: Serverless gives you a cheap, elastic “listener” that can react the instant a file lands in S3, letting you build an AI pipeline with almost no operational overhead.


Setting Up the S3 Trigger and IAM Role

Why we need a trigger

A Lambda function cannot magically know when a new PDF appears. S3 must publish an ObjectCreated event that tells Lambda, “Hey, there’s a new file!” This event‑driven pattern decouples the storage layer from the compute layer, making the system resilient to spikes.

IAM role basics

AWS uses IAM (Identity and Access Management) to control who can do what. Our Lambda function needs a role that grants three abilities:

  1. Read the PDF from the source bucket (s3:GetObject).
  2. Invoke Bedrock (bedrock:InvokeModel).
  3. Write the summary back to the bucket (s3:PutObject).

If any permission is missing, the function will fail with an AccessDenied error, which can be hard to debug for beginners.

Creating the bucket, role, and notification with the SDK

Below is a script you can run locally (Node 22) to set up everything. It uses the @aws-sdk/client-s3 and @aws-sdk/client-iam packages. Pay attention to the comments; they explain each step.

Tip: When you use the AWS SDK for JavaScript v3, each service has its own client class. This separation keeps the bundle size small, which is important for Lambda layers.

// scripts/setup.ts
import { S3Client, CreateBucketCommand, PutBucketNotificationConfigurationCommand } from '@aws-sdk/client-s3';
import { IAMClient, CreateRoleCommand, AttachRolePolicyCommand } from '@aws-sdk/client-iam';

// ==== CONFIGURATION ==========================================================
const bucketName = 'doc-summarizer-input';
const lambdaArn   = 'arn:aws:lambda:us-east-1:123456789012:function:doc-summarizer';
const roleName    = 'DocSummarizerLambdaRole';

// ==== CREATE S3 BUCKET =======================================================
const s3 = new S3Client({ region: 'us-east-1' });
await s3.send(new CreateBucketCommand({ Bucket: bucketName }));
console.log(`Created bucket ${bucketName}`);

// ==== SETUP NOTIFICATION (ObjectCreated) =====================================
// The notification tells S3 to call our Lambda whenever a new object appears.
await s3.send(
  new PutBucketNotificationConfigurationCommand({
    Bucket: bucketName,
    NotificationConfiguration: {
      LambdaFunctionConfigurations: [
        {
          Events: ['s3:ObjectCreated:*'], // any create/put event
          LambdaFunctionArn: lambdaArn,
        },
      ],
    },
  })
);
console.log(`Linked bucket ${bucketName} to Lambda ${lambdaArn}`);

// ==== CREATE IAM ROLE =========================================================
// Trust policy allows Lambda service to assume this role.
const iam = new IAMClient({ region: 'us-east-1' });
const trustPolicy = {
  Version: '2012-10-17',
  Statement: [
    {
      Effect: 'Allow',
      Principal: { Service: 'lambda.amazonaws.com' },
      Action: 'sts:AssumeRole',
    },
  ],
};

await iam.send(
  new CreateRoleCommand({
    RoleName: roleName,
    AssumeRolePolicyDocument: JSON.stringify(trustPolicy),
  })
);
console.log(`Created IAM role ${roleName}`);

// ==== ATTACH MINIMAL POLICIES =================================================
// In a real project you would scope these down to the specific bucket ARN.
const s3PolicyArn = 'arn:aws:iam::aws:policy/AmazonS3FullAccess';
const bedrockPolicyArn = 'arn:aws:iam::aws:policy/AmazonBedrockFullAccess';
await iam.send(new AttachRolePolicyCommand({ RoleName: roleName, PolicyArn: s3PolicyArn }));
await iam.send(new AttachRolePolicyCommand({ RoleName: roleName, PolicyArn: bedrockPolicyArn }));
console.log('Attached S3 and Bedrock policies to role');
Enter fullscreen mode Exit fullscreen mode

Known S3 gotchas to keep in mind

  • Express One Zone – If you ever switch the bucket to the “Express One Zone” storage class, the SDK operations change (e.g., PutObject becomes PutObjectLegalHold). Our code assumes the standard class, so double‑check the bucket settings.
  • Presigned URLs – If another service later needs a temporary download link, remember that the URL expires after a default of 15 minutes. Ignoring the 403 error when it expires can cause silent failures.
  • List consistency – S3’s ListObjectsV2 is eventually consistent. If you upload a PDF and immediately list the bucket, the new key might not appear yet. Adding a short await new Promise(r => setTimeout(r, 500)); can smooth out race conditions in simple scripts.

In plain English: The bucket is the mailbox, the IAM role is the key that unlocks it, and the notification is the “ding‑dong” that tells our Lambda “mail arrived!”.


Calling Claude via Bedrock from a Lambda Function

Why we reach out to Claude

Claude is a large language model (LLM) hosted on Amazon Bedrock. An LLM is a statistical model that has read billions of words and can generate text that looks like a human wrote it. For summarization, we simply give the model the raw PDF text and ask, “Give me the main points.”

Because Claude lives in a managed service, we don’t have to worry about GPU provisioning, model versioning, or scaling the inference server. All we need is a REST call to Bedrock’s invokeModel endpoint.

The Bedrock invokeModel call

Bedrock uses a fetch‑compatible API (you can think of it like curl). The request body is a JSON object with the prompt and any model‑specific parameters. The response contains completion – the generated summary.

Below is a fully‑commented snippet that you can drop into the Lambda handler from the previous section. It demonstrates:

  • Building the fetch request with the proper X-Amz-Target header (BedrockRuntime.InvokeModel).
  • Sending the text chunk to Claude.
  • Extracting the bullet‑point list from the JSON response.

Tip: The @aws-sdk/client-bedrockruntime library exists, but using native fetch keeps the bundle tiny – a good practice for Lambda where every kilobyte matters.

// src/bedrock.ts
import { Credentials, Provider } from '@aws-sdk/types';
import { defaultProvider } from '@aws-sdk/credential-provider-node';

// Helper to call Claude on Bedrock
export async function summarizeWithClaude(text: string, modelId = 'anthropic.claude-v2:1'): Promise<string> {
  // Bedrock endpoint is region‑specific; us-east-1 is used here as an example.
  const endpoint = `https://bedrock-runtime.us-east-1.amazonaws.com/model/${modelId}/invoke`;

  // Build the request payload – the model expects a "prompt" field.
  const payload = {
    prompt: `Summarize the following text in 5‑6 bullet points:\n\n${text}\n`,
    max_tokens_to_sample: 512, // keep response short
    temperature: 0.2, // lower temperature = more deterministic output
  };

  // Get credentials from the Lambda execution environment.
  const credentials = await defaultProvider()() as Credentials;

  // Construct the signed fetch request – Bedrock requires AWS Signature V4.
  // The SDK can sign a request for us via the `signer` utility, but for simplicity
  // we rely on the built‑in support in Lambda’s runtime for fetch with
  // `AWS4Signer`.
  const response = await fetch(endpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      // The runtime automatically adds the Authorization header when
      // `fetch` runs inside Lambda with appropriate role permissions.
    },
    body: JSON.stringify(payload),
    // @ts-ignore – Lambda adds the signer automatically.
    signer: { credentials, service: 'bedrock', region: 'us-east-1' },
  });

  if (!response.ok) {
    const errBody = await response.text();
    throw new Error(`Bedrock error ${response.status}: ${errBody}`);
  }

  const result = await response.json();
  // Claude returns { completion: "text..." }
  return result.completion?.trim() ?? '';
}
Enter fullscreen mode Exit fullscreen mode

Gotchas with Lambda and Bedrock

  • Payload size limit – Lambda can only pass 6 MB of data to downstream services. If the PDF text exceeds this, the fetch call will be rejected. We solve that in the next section by chunking.
  • Response streaming – Bedrock can stream partial results, but Lambda requires the Content-Type: application/json header; otherwise the runtime buffers the entire response, adding latency. For our simple summarizer, the non‑streaming mode is fine.
  • Provisioned Concurrency – Some teams enable this feature to avoid cold starts, but it adds a fixed hourly charge even when the function sits idle. Keep an eye on the bill if you enable it.

In plain English: Think of Bedrock as a remote brain you talk to over HTTPS. You send a short request, it replies with a short answer, and you pay only for the time you’re actually chatting.


Handling Large Documents and Chunking

Why chunking matters

A typical PDF report can be dozens of pages long, resulting in tens of thousands of characters. Two limits bite us:

  1. Lambda payload limit (6 MB) – The request body you send to Bedrock must be smaller than this.
  2. Claude token limit – Claude’s model can only process a certain number of tokens (roughly 4 characters per token). Exceeding it yields a ModelInvocationException.

If we ignore these limits, the Lambda run will either crash with a PayloadTooLargeException or the Bedrock call will return an error, and the final summary will be incomplete.

Chunking strategy

A straightforward approach is to split the plain‑text version of the PDF into paragraph‑sized chunks that stay well under the 6 MB ceiling (e.g., 100 KB each). For each chunk we call Claude, collect the bullet points, and finally combine them into a single summary.

Analogy: Think of a long novel you want to summarize. You wouldn’t try to read the whole book in one sitting; you read one chapter, write a short note, then move to the next. At the end you stitch the notes together.

Below is a helper that:

  • Splits a string on double newlines (\n\n) – a cheap way to approximate paragraph breaks.
  • Accumulates paragraphs into a buffer until adding the next one would exceed MAX_CHUNK_BYTES.
  • Returns an array of chunk strings.
// src/chunker.ts
export const MAX_CHUNK_BYTES = 100 * 1024; // 100 KB – well under Lambda limit

/**
 * Breaks a large text into smaller pieces that fit inside the byte limit.
 * @param text Full plain‑text extracted from a PDF.
 * @returns Array of chunk strings.
 */
export function chunkText(text: string): string[] {
  const paragraphs = text.split(/\n\s*\n/); // split on blank lines
  const chunks: string[] = [];
  let current = '';

  for (const para of paragraphs) {
    const candidate = current ? `${current}\n\n${para}` : para;
    // Buffer length in bytes (UTF‑8). Buffer.byteLength is accurate for multibyte chars.
    if (Buffer.byteLength(candidate, 'utf8') > MAX_CHUNK_BYTES) {
      // Current buffer is full – push it and start a new one.
      if (current) chunks.push(current);
      current = para; // start fresh with the paragraph that caused overflow
    } else {
      current = candidate; // safe to add paragraph
    }
  }

  // Push the final buffer if it has content.
  if (current) chunks.push(current);
  return chunks;
}
Enter fullscreen mode Exit fullscreen mode

Putting it together in the Lambda handler

Now we combine the PDF extraction (using pdf-parse), chunking, summarization, and final assembly.

// src/handler.ts
import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { S3Event } from 'aws-lambda';
import pdf from 'pdf-parse'; // lightweight PDF‑to‑text library
import { chunkText } from './chunker';
import { summarizeWithClaude } from './bedrock';

const s3 = new S3Client({ region: 'us-east-1' });

export const summarizer = async (event: S3Event) => {
  // 1️⃣ Extract bucket/key from the event.
  const record = event.Records[0];
  const bucket = record.s3.bucket.name;
  const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, ' '));

  // 2️⃣ Download the PDF.
  const pdfData = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
  const pdfBuffer = await streamToBuffer(pdfData.Body as any); // helper below

  // 3️⃣ Convert PDF to plain text.
  const { text } = await pdf(pdfBuffer);
  console.log(`Extracted ${text.length} characters from ${key}`);

  // 4️⃣ Chunk the text to stay under Lambda/Claude limits.
  const chunks = chunkText(text);
  console.log(`Split into ${chunks.length} chunk(s)`);

  // 5️⃣ Summarize each chunk sequentially (you could parallelize, but beware of concurrency limits).
  const bulletPoints: string[] = [];
  for (const [i, chunk] of chunks.entries()) {
    const summary = await summarizeWithClaude(chunk);
    console.log(`Chunk ${i + 1} summary: ${summary}`);
    bulletPoints.push(summary);
  }

  // 6️⃣ Combine all bullet points into a single summary file.
  const finalSummary = bulletPoints.join('\n');
  const summaryKey = key.replace(/\.pdf$/i, '.summary.txt');

  await s3.send(
    new PutObjectCommand({
      Bucket: bucket,
      Key: summaryKey,
      Body: finalSummary,
      ContentType: 'text/plain',
    })
  );

  console.log(`Wrote summary to s3://${bucket}/${summaryKey}`);
  return { status: 'success', summaryKey };
};

/**
 * Utility: Convert a readable stream (S3 GetObject) into a Buffer.
 */
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
  const chunks: Buffer[] = [];
  for await (const chunk of stream) {
    chunks.push(Buffer.from(chunk));
  }
  return Buffer.concat(chunks);
}
Enter fullscreen mode Exit fullscreen mode

Gotchas specific to this step

  • Lambda’s 6 MB payload limit applies to the request body we send to Bedrock, not the total size of the PDF in S3. Our MAX_CHUNK_BYTES of 100 KB guarantees we stay well under the limit, even after adding the JSON wrapper.
  • Cold start in a VPC – If your Lambda needs VPC access (e.g., to reach a private Bedrock endpoint), the cold‑start time is dominated by the VPC attachment, not by the JVM (there is no JVM for Node). Using SnapStart does not help in this scenario; keep the function out of a VPC unless required.
  • Layer compatibility – Lambda Node 22 now requires all layers to be built as ES modules (type: "module"). If you use a pre‑built layer that still uses CommonJS (require), you’ll see silent failures. Convert the layer or rebuild it with esm.

In plain English: By breaking the PDF into bite‑size pieces, we keep every request tiny, avoid errors, and let Claude do its job safely.


Storing Summaries Back to S3

Why write the result to S3

S3 is cheap, durable, and serves as a natural “output folder” for downstream consumers (e.g., a front‑end UI that lists all .summary.txt files). By storing the summary alongside the original PDF, we keep the data lineage clear: one bucket, one logical grouping.

Writing the file – code explained

The final part of the Lambda handler already performed a PutObjectCommand. Let’s isolate that logic into a helper so you can reuse it elsewhere (e.g., a batch job that re‑processes old PDFs).

// src/storage.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

const s3 = new S3Client({ region: 'us-east-1' });

/**
 * Saves a string as a .txt object in the same bucket as the source PDF.
 * @param bucket The S3 bucket name.
 * @param sourceKey The original PDF key (used to derive the summary key).
 * @param summaryText The text you want to store.
 */
export async function writeSummary(bucket: string, sourceKey: string, summaryText: string): Promise<void> {
  // Replace the .pdf extension with .summary.txt – keep the folder structure.
  const summaryKey = sourceKey.replace(/\.pdf$/i, '.summary.txt');

  await s3.send(
    new PutObjectCommand({
      Bucket: bucket,
      Key: summaryKey,
      Body: summaryText,
      ContentType: 'text/plain',
      // Optional: add a tag so you can filter summaries later.
      Tagging: 'type=summary',
    })
  );

  console.log(`✅ Summary stored at s3://${bucket}/${summaryKey}`);
}
Enter fullscreen mode Exit fullscreen mode

S3‑related gotchas you may hit

  • Object Lock in Compliance mode – If the bucket has Object Lock enabled, you cannot delete or overwrite a locked object even if you are the root user. Accidentally enabling this on a production bucket can cause panic when you try to re‑run the summarizer. Double‑check the bucket settings before enabling Object Lock.
  • Presigned URL expiration – If a front‑end wants to let a user download the summary, generate a presigned URL that lives for at most 5 minutes. Shorter lifetimes reduce the risk of a stale 403 error.
  • Transfer acceleration costs – Some teams turn on S3 Transfer Acceleration to speed up uploads from remote offices, but it adds a per‑GB charge. For our internal pipeline, the default endpoint is perfectly fine.

Key takeaway: Storing the summary in the same bucket keeps the workflow simple and cheap, but be aware of bucket‑level features that can unintentionally block writes.


The Takeaway

**


Transparency notice

This article was written with the help of an AI system — Groq (GPT OSS 120B).

Published: 2026-11-13 · Primary focus: Lambda

All code blocks are intended to be correct and runnable, but please verify them
against the official docs for the tools mentioned before using in production.

Find an error? Drop a comment — corrections are always welcome.

Top comments (0)