Skip to content

Give a LangGraph agent a PDF tool

LangGraph agents are good at multi-step work — gather, check, produce — and “produce” often means a file. This guide adds a render_document tool that returns a signed URL, wires it into a prebuilt ReAct agent, then shows the same tool inside a StateGraph that carries the render id through the state.

Requires Python 3.9 or newer.

Terminal window
pip install langgraph langchain-core langchain-openai
export OPENAI_API_KEY=sk-…
export GALLEY_API_KEY=glr_sk_…

The tool itself calls the REST API with urllib from the standard library, so it has no dependency of its own. There is no Galley Python SDK yet — galley-render on PyPI is reserved but unpublished.

galley_tool.py
"""A LangGraph-compatible tool that renders documents with Galley Render."""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from typing import Any, Dict, Optional
from langchain_core.tools import tool
API_BASE = "https://api.galleyrender.com"
TIMEOUT = 60
def _call(path: str, method: str = "GET", body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(f"{API_BASE}{path}", data=data, method=method)
req.add_header("Authorization", f"Bearer {os.environ['GALLEY_API_KEY']}")
if data is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as res:
return json.loads(res.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
raw = exc.read().decode("utf-8", "replace")
try:
return json.loads(raw) # the API's error envelope, verbatim
except json.JSONDecodeError:
return {"error": {"type": "internal_error", "message": raw[:500]}}
@tool
def list_templates() -> str:
"""List the document templates available on this account, with their latest
version numbers. Call this first if you do not already know which template
to use. Free — it renders nothing."""
result = _call("/v1/templates")
if "error" in result:
return json.dumps(result, indent=2)
return json.dumps(
[
{"name": t["name"], "version": t.get("latest_version"), "description": t.get("description")}
for t in result.get("data", [])
],
indent=2,
)
@tool
def get_template(template: str) -> str:
"""Fetch one template's JSON Schema, default options and example payload.
`template` is a name, optionally versioned: "invoice" or "invoice@1".
Read the schema before rendering a template you have not used — it is the
contract for the `data` argument. Free."""
result = _call(f"/v1/templates/{template}")
result.pop("source", None) # the HTML is long and the model does not need it
return json.dumps(result, indent=2)
@tool
def render_document(template: str, data: Dict[str, Any], output_format: str = "pdf") -> str:
"""Render a template plus a JSON payload into a PDF, PNG or JPG and return a
signed download URL.
Args:
template: Template reference, e.g. "invoice@1". Pin the version.
data: The payload, matching the template's JSON Schema exactly.
Numbers must be numbers, not strings.
output_format: "pdf", "png" or "jpg". Defaults to "pdf".
Returns JSON with `url`, `id`, `page_count`, `billable_units` and `cached`;
or an `error` object whose `errors[].path` names each bad field."""
result = _call(
"/v1/render",
method="POST",
body={"template": template, "format": output_format, "data": data},
)
if "error" in result:
# Returned, not raised: the model can read the field paths and retry.
return json.dumps(result, indent=2)
return json.dumps(
{
"id": result["id"],
"status": result["status"],
"url": result["url"],
"page_count": result["page_count"],
"billable_units": result["billable_units"],
"cached": result["cached"],
},
indent=2,
)
GALLEY_TOOLS = [list_templates, get_template, render_document]

Two decisions in there are worth naming.

The docstring is the tool description. LangChain builds the tool schema from the signature and the docstring, so the constraints that matter — pin the version, numbers are numbers, read the schema first — belong in the prose, not in a comment.

Errors are returned, not raised. A raised exception ends the tool call and the model may see nothing useful. A returned envelope like this is directly actionable:

{
"error": {
"type": "validation_error",
"message": "The data payload does not match the template schema.",
"docs_url": "https://galleyrender.com/docs/errors/validation_error",
"errors": [
{
"path": "data.line_items[0].quantity",
"message": "data.line_items[0].quantity must be number",
"expected": "number",
"received": "string",
"example": 2
}
]
}
}

The model reads path and expected, fixes the one field and calls the tool again. A 422 renders nothing and bills nothing, so a retry loop here is cheap.

The shortest wiring is create_react_agent, which builds the model → tools → model loop for you.

react_agent.py
"""Usage: OPENAI_API_KEY=… GALLEY_API_KEY=… python react_agent.py"""
import json
import re
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import create_react_agent
from galley_tool import GALLEY_TOOLS
SYSTEM = """\
You produce finished documents. Before rendering a template you have not used,
call get_template and read its JSON Schema. Pin the template version. Report the
signed URL from the tool result exactly as given — never abbreviate it.
If a tool returns an `error`, read errors[].path and errors[].expected, fix that
one field, and retry once.
"""
TASK = """\
Render a PDF invoice for Acme Robotics using the invoice template.
Invoice INV-1042, issued 2026-09-16, due 2026-10-16
From: Galley Render, billing@galleyrender.com
To: Acme Robotics, ap@acme.test, 100 Market St, Austin, TX 78701
Lines: "Starter plan, September" 1 x $19
"Overage, 3,200 renders" 3.2 x $4
Tax rate 7%.
"""
agent = create_react_agent(
model=ChatOpenAI(model="gpt-4.1", temperature=0),
tools=GALLEY_TOOLS,
prompt=SYSTEM,
)
result = agent.invoke({"messages": [("user", TASK)]})
for message in result["messages"]:
message.pretty_print()
# Read the URL out of the tool result, not the model's prose.
url = None
for message in reversed(result["messages"]):
content = getattr(message, "content", "") or ""
if "/v1/files/" not in content:
continue
try:
url = json.loads(content).get("url")
except json.JSONDecodeError:
match = re.search(r"https://\S*?/v1/files/\S+", content)
url = match.group(0) if match else None
if url:
break
print("\nSigned URL:", url)

The run looks like this:

================================ Human Message =================================
Render a PDF invoice for Acme Robotics using the invoice template. …
================================== Ai Message ==================================
Tool Calls: get_template({"template": "invoice"})
================================= Tool Message =================================
{"name": "invoice", "version": 1, "schema": {"required": ["invoice_number",
"issued_on", "seller", "buyer", "line_items"], …}}
================================== Ai Message ==================================
Tool Calls: render_document({"template": "invoice@1", "output_format": "pdf",
"data": {"invoice_number": "INV-1042", "issued_on": "2026-09-16", …}})
================================= Tool Message =================================
{
"id": "rnd_7hq2m4x8k1bv",
"status": "succeeded",
"url": "https://<account>.r2.cloudflarestorage.com/galley-renders/renders/…?X-Amz-Expires=3600&X-Amz-Signature=…",
"page_count": 1,
"billable_units": 1,
"cached": false
}
================================== Ai Message ==================================
Invoice INV-1042 is rendered — one page, $34.03 after 7% tax. …
Signed URL: https://<account>.r2.cloudflarestorage.com/galley-renders/renders/…?X-Amz-Expires=3600&X-Amz-Signature=…

create_react_agent keeps everything in messages, which means the URL is only ever a string inside a tool message. For a longer graph — render, then email, then record — you want it in typed state instead.

graph_agent.py
"""A StateGraph that renders a document and carries the result in state."""
from __future__ import annotations
import json
from typing import Annotated, Any, Dict, List, Optional, TypedDict
from langchain_core.messages import AnyMessage, SystemMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode, tools_condition
from galley_tool import GALLEY_TOOLS
class DocState(TypedDict):
messages: Annotated[List[AnyMessage], add_messages]
render_id: Optional[str]
document_url: Optional[str]
page_count: Optional[int]
billable_units: int
SYSTEM = SystemMessage(
"You produce finished documents. Read a template's schema with get_template "
"before rendering it, pin the version, and report the signed URL verbatim."
)
llm = ChatOpenAI(model="gpt-4.1", temperature=0).bind_tools(GALLEY_TOOLS)
def call_model(state: DocState) -> Dict[str, Any]:
return {"messages": [llm.invoke([SYSTEM] + state["messages"])]}
def harvest(state: DocState) -> Dict[str, Any]:
"""After the tools run, lift any render result out of the tool message and
into typed state, so later nodes never have to re-parse the transcript."""
update: Dict[str, Any] = {}
for message in reversed(state["messages"]):
if getattr(message, "type", None) != "tool":
continue
try:
payload = json.loads(message.content)
except (json.JSONDecodeError, TypeError):
continue
if isinstance(payload, dict) and payload.get("url"):
update = {
"render_id": payload.get("id"),
"document_url": payload["url"],
"page_count": payload.get("page_count"),
# Cache hits are free, so only count what was actually billed.
"billable_units": state.get("billable_units", 0)
+ (0 if payload.get("cached") else payload.get("billable_units", 0)),
}
break
return update
builder = StateGraph(DocState)
builder.add_node("agent", call_model)
builder.add_node("tools", ToolNode(GALLEY_TOOLS))
builder.add_node("harvest", harvest)
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition, {"tools": "tools", END: END})
builder.add_edge("tools", "harvest")
builder.add_edge("harvest", "agent")
graph = builder.compile()
final = graph.invoke(
{
"messages": [("user", "Render an OG card, 1200x630 PNG, titled "
"'Documents for agents' with the subtitle "
"'JSON in, PDF out.' on the og-card template.")],
"render_id": None,
"document_url": None,
"page_count": None,
"billable_units": 0,
},
{"recursion_limit": 25},
)
print(final["messages"][-1].content)
print("render_id: ", final["render_id"])
print("document_url: ", final["document_url"])
print("billable_units: ", final["billable_units"])

harvest runs after every tool batch, so by the time the graph ends, document_url and render_id are plain fields any downstream node can read. Accumulating billable_units while skipping cache hits gives you a per-run cost you can log or cap.

Set recursion_limit deliberately. A validation retry costs two extra loops, and the default cuts off sooner than people expect once a template lookup is in the path.

Galley also exposes these tools over MCP at https://mcp.galleyrender.com/mcp, which has one real advantage: no API key is needed to start. The first render mints a 50-render trial and returns its token in the result.

The LangChain bridge for this is langchain-mcp-adapters:

Terminal window
pip install langchain-mcp-adapters
mcp_route.py — shape, not a verified signature
from langchain_mcp_adapters.client import MultiServerMCPClient
from langgraph.prebuilt import create_react_agent
client = MultiServerMCPClient({
"galley": {
"url": "https://mcp.galleyrender.com/mcp",
"transport": "streamable_http",
# Omit `headers` entirely for the keyless trial.
# "headers": {"X-Galley-Api-Key": os.environ["GALLEY_API_KEY"]},
}
})
tools = await client.get_tools()
agent = create_react_agent("openai:gpt-4.1", tools)
result = await agent.ainvoke({"messages": [("user", "Render an invoice for …")]})

Billing is one unit per PDF page and one per PNG or JPG; cache hits cost nothing. Because the cache key is a hash of the template version, the format, the data and the options, an agent that retries an unchanged render pays once. This is worth knowing when you are tuning a loop: retries are close to free, but a timestamp inside data makes every attempt a fresh render.

Signed URLs last an hour; the stored object lasts thirty days. If a later node in a long-running graph needs the file, carry render_id in state — as the graph above does — and re-sign with GET /v1/renders/{id} rather than re-rendering.