
The demo version of a LangChain PDF tool is a few lines: subclass BaseTool, implement _run, done. The production version needed a lot more thought about what happens when the agent picks the wrong tool, passes malformed arguments, or gets a failure response it has to reason about, none of which shows up in the demo because the demo only ever exercises the happy path.
The minimal version
from langchain.tools import BaseTool
from pydantic import BaseModel, Field
class MergeInput(BaseModel):
files: list[str] = Field(description="URLs of PDF files to merge, in order")
class PDFMergeTool(BaseTool):
name = "merge_pdfs"
description = "Merge multiple PDF files into one, in the given order."
args_schema = MergeInput
def _run(self, files: list[str]) -> str:
result = pdf_api.run({"action": "merge", "files": files})
if result.status != "success":
return f"Merge failed: {result.status}"
return result.output_url
This works fine in a demo with two clean test files. It's also missing almost everything that matters once an agent is calling it with arguments it generated itself, from a natural-language instruction, without a human checking the call before it fires.
Why the tool description carries more weight than it looks like it should
An agent decides which tool to call, and with what arguments, based substantially on the tool's description field. A vague description, "merges PDFs," leaves the agent guessing about input format, ordering behavior, and what happens with more than two files. A more specific description meaningfully improves how reliably the agent picks the right tool and fills in the right arguments:
description = (
"Merge multiple PDF files into a single PDF, in the exact order the "
"'files' list is given. Use this when the user asks to combine, "
"join, or merge two or more PDF documents. Requires at least 2 files."
)
That level of specificity isn't overkill, it's doing real work: reducing the number of times the agent calls this tool for a task it wasn't meant for, or calls it with a single file because the description didn't make the minimum clear.
Making tool output legible to the agent, not just to a human
The _run method's return value becomes part of the agent's context for its next reasoning step. A raw exception traceback or an ambiguous string like "error" gives the agent almost nothing to act on. A specific, structured message lets the agent make a reasonable next decision, retry, apologize to the user, try a different tool, without needing a human to intervene:
def _run(self, files: list[str]) -> str:
if len(files) < 2:
return "Error: merge requires at least 2 files, only 1 was provided."
result = pdf_api.run({"action": "merge", "files": files})
if result.status == "encrypted_input":
return "Error: one or more files are password-protected and can't be merged automatically."
if result.status != "success":
return f"Error: merge failed ({result.status}). Try again or check the input files."
return f"Success: merged {len(files)} files into {result.output_url}"
The distinction between "Error: merge requires at least 2 files" and a generic failure matters a lot in practice, because it's the difference between an agent that can self-correct on the next turn and one that just retries the same broken call.
Guarding against the agent calling the tool with bad arguments
Pydantic's args_schema catches obviously malformed input, wrong types, missing fields, before _run even executes, which handles a meaningful share of agent mistakes for free. It doesn't catch everything: an agent can pass a syntactically valid list of URLs that don't actually point at PDFs, or an empty list that technically satisfies the schema. Validating the semantic content, not just the shape, inside _run itself is still necessary, as shown in the len(files) < 2 check above.
Registering multiple operations without one giant tool
Rather than one tool with an action parameter covering all six operations, merge, split, compress, rotate, watermark, convert, each operation gets its own tool with its own name and description. This costs a bit of boilerplate and buys a lot of reliability: an agent choosing between merge_pdfs, compress_pdf, and watermark_pdf by name is working with a much clearer decision than one choosing an action string buried inside a single generic tool's arguments, which is exactly the kind of narrow-surface design that tends to hold up better with agent callers generally.
Cost control when the agent decides how many times to call it
A human calling this API decides, consciously, when to trigger a merge or a compress. An agent decides that too, but it's deciding based on a chain of reasoning that can go wrong in ways a human wouldn't. An agent that misreads a task and merges the same set of files three times before noticing the output already exists, or one that gets stuck in a loop retrying a tool call that will never succeed because the underlying request is malformed, can run up real usage without anyone intending it to. This is a smaller problem with an API priced per successful result, since a failed or malformed call that never produces output isn't charged, but a tool that keeps producing valid results the agent didn't actually need still costs money for no benefit.
The practical fix isn't in the tool itself, it's in the surrounding agent framework: a simple call counter per session, or a check that flags when the same tool gets invoked with near-identical arguments more than once or twice in a row. Neither of those requires touching the PDF API, they're just guardrails around how liberally the agent is allowed to use the tools it has access to, and they're worth adding before an agent framework goes anywhere near production traffic rather than after a surprising bill shows up.
The API these tools are thin wrappers around
None of the six tools implement any actual PDF manipulation. Each one is a schema, a description, and a call to a PDF API for agent frameworks that handles merge, split, compress, rotate, watermark, and convert, priced per successful result. The work that actually mattered here was almost entirely about making the tool's interface legible to something that has to decide when and how to call it without a human in the loop, not about the PDF operations themselves.
Top comments (0)