Back to Blog
Tutorial

Give Your AI Agent the Power to Generate PDFs

2026-01-21·3 min read
aivercel-ai-sdktoolsllm

Your AI chatbot can search the web, query databases, and draft emails. But ask it to "generate a PDF report" and it apologizes. It describes what a PDF would look like. It offers to write the content. It does everything except produce an actual file.

That's because LLMs generate text. They don't have hands. They can't reach into your filesystem or call your APIs.

Unless you give them tools.

LLM Tools in 60 Seconds

Tools are functions the LLM can call. You define a schema (what parameters the function accepts) and an implementation (what it does). The LLM decides when to call it based on the conversation.

typescript
tool({
  description: 'Generate a PDF from HTML',
  parameters: z.object({
    html: z.string().describe('HTML content'),
    filename: z.string().describe('Output filename'),
  }),
  execute: async ({ html, filename }) => {
    // Your code runs here
  },
})

That's it. Zod validates the input. Your function does the work. The LLM gets the result.

Why PDF Generation Works Well as a Tool

PDF generation is a perfect fit for tool use:

  1. Structured input. HTML is text. LLMs are good at generating text.
  2. Clear side effect. A file gets created. Success or failure is obvious.
  3. Minimal output. Return a URL. The LLM doesn't need the binary.

The LLM writes the HTML. NeatPDF renders it. You upload the result. Everyone's happy.

The Implementation

Here's a Next.js API route that wires NeatPDF as a tool:

typescript
// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai'
import { streamText, tool } from 'ai'
import { z } from 'zod'
import { NeatPdf } from '@neatpdf/sdk'
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'

const neatpdf = new NeatPdf({ apiKey: process.env.NEATPDF_API_KEY! })
const s3 = new S3Client({ region: 'us-east-1' })

export async function POST(req: Request) {
  const { messages } = await req.json()

  const result = streamText({
    model: openai('gpt-4o'),
    messages,
    tools: {
      generatePdf: tool({
        description: 'Generate a PDF document from HTML content',
        parameters: z.object({
          html: z.string().describe('The HTML content to convert to PDF'),
          filename: z.string().describe('Output filename without extension'),
        }),
        execute: async ({ html, filename }) => {
          const response = await neatpdf.htmlToPdf({
            body: {
              html,
              paperWidth: 8.5,
              paperHeight: 11,
            },
          })

          if (response.error) {
            return { success: false, error: 'PDF generation failed' }
          }

          const key = `pdfs/${filename}-${Date.now()}.pdf`
          await s3.send(
            new PutObjectCommand({
              Bucket: process.env.S3_BUCKET!,
              Key: key,
              Body: response.data,
              ContentType: 'application/pdf',
            }),
          )

          const url = `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${key}`
          return { success: true, url, filename: `${filename}.pdf` }
        },
      }),
    },
  })

  return result.toDataStreamResponse()
}

On the client, render the download link when the tool returns:

typescript
// components/Chat.tsx
'use client'

import { useChat } from 'ai/react'

export function Chat() {
  const { messages, input, handleSubmit, handleInputChange } = useChat()

  return (
    <div className="flex flex-col gap-4">
      {messages.map((m) => (
        <div key={m.id}>
          <strong>{m.role}:</strong> {m.content}
          {m.toolInvocations?.map((invocation) => (
            <div key={invocation.toolCallId} className="mt-2">
              {invocation.state === 'result' && invocation.result.success && (
                <a
                  href={invocation.result.url}
                  className="text-blue-500 underline"
                  download
                >
                  Download {invocation.result.filename}
                </a>
              )}
              {invocation.state === 'result' && !invocation.result.success && (
                <span className="text-red-500">
                  Failed: {invocation.result.error}
                </span>
              )}
            </div>
          ))}
        </div>
      ))}
      <form onSubmit={handleSubmit} className="flex gap-2">
        <input
          value={input}
          onChange={handleInputChange}
          className="flex-1 border rounded px-3 py-2"
          placeholder="Ask me to generate a PDF..."
        />
        <button type="submit" className="bg-black text-white px-4 py-2 rounded">
          Send
        </button>
      </form>
    </div>
  )
}

Now when a user says "Generate an invoice for $500 to Acme Corp," the LLM writes the HTML, calls your tool, and the user gets a download link.

Handling Bad HTML

Sometimes the LLM generates broken HTML. Missing closing tags. Invalid CSS. Weird nesting.

NeatPDF uses Chromium under the hood. Chromium is forgiving—it'll render most garbage into something. But if you want stricter validation, wrap the execute function:

typescript
execute: async ({ html, filename }) => {
  // Basic sanity check
  if (!html.includes('<') || html.length < 10) {
    return { success: false, error: 'Invalid HTML provided' }
  }

  // Proceed with generation...
}

You could also use a library like htmlparser2 for proper validation. But honestly? Chromium handles most edge cases fine. Ship it and see what breaks.

Tip: Return URLs, Not Data

Notice how the tool returns a URL, not the PDF binary.

This matters. Every byte in the tool result consumes tokens. A 500KB PDF as base64 would burn through your context window. The LLM doesn't need the file contents—it just needs to tell the user "here's your download link."

The pattern:

  1. Generate PDF with NeatPDF
  2. Upload to S3, R2, or any bucket
  3. Return only the URL string

Your token budget stays healthy. The user gets their file.

Advanced: Let the LLM Design the Layout

Want the LLM to handle the full design? Add guidance in your system prompt:

typescript
const result = streamText({
  model: openai('gpt-4o'),
  system: `When generating PDFs, create professional HTML layouts with:
    - Tailwind CSS classes for styling
    - Proper semantic HTML structure
    - Print-friendly margins and page breaks
    - Clean typography`,
  messages,
  tools: { generatePdf },
})

GPT-4o writes decent HTML. It won't win design awards, but it'll produce clean invoices, reports, and summaries without you templating anything.

Get Started

Install the SDK:

bash
npm install @neatpdf/sdk ai @ai-sdk/openai

Check out the NeatPDF docs for the full API reference.

Your AI agent can now produce real files. That's one less thing it has to apologize for.

Ready to generate PDFs?

Zero data retention. GDPR compliant.

Start Free TrialZero data retention · GDPR