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.
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:
- Structured input. HTML is text. LLMs are good at generating text.
- Clear side effect. A file gets created. Success or failure is obvious.
- 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:
// 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:
// 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:
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:
- Generate PDF with NeatPDF
- Upload to S3, R2, or any bucket
- 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:
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:
npm install @neatpdf/sdk ai @ai-sdk/openaiCheck 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.