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.
pip install langgraph langchain-core langchain-openaiexport 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.
The tool
Section titled “The tool”"""A LangGraph-compatible tool that renders documents with Galley Render."""from __future__ import annotations
import jsonimport osimport urllib.errorimport urllib.requestfrom 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]}}
@tooldef 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, )
@tooldef 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)
@tooldef 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 ReAct agent
Section titled “The ReAct agent”The shortest wiring is create_react_agent, which builds the model → tools → model loop for you.
"""Usage: OPENAI_API_KEY=… GALLEY_API_KEY=… python react_agent.py"""import jsonimport re
from langchain_openai import ChatOpenAIfrom 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 thesigned URL from the tool result exactly as given — never abbreviate it.If a tool returns an `error`, read errors[].path and errors[].expected, fix thatone 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 = Nonefor 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=…Carrying the render through state
Section titled “Carrying the render through state”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.
"""A StateGraph that renders a document and carries the result in state."""from __future__ import annotations
import jsonfrom typing import Annotated, Any, Dict, List, Optional, TypedDict
from langchain_core.messages import AnyMessage, SystemMessagefrom langchain_openai import ChatOpenAIfrom langgraph.graph import END, START, StateGraphfrom langgraph.graph.message import add_messagesfrom 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.
The MCP route
Section titled “The MCP route”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:
pip install langchain-mcp-adaptersfrom langchain_mcp_adapters.client import MultiServerMCPClientfrom 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 …")]})Caching and URLs
Section titled “Caching and URLs”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.
What next
Section titled “What next”- Give an OpenAI Agents SDK agent a PDF tool — the same capability over MCP.
- What the MCP server is — the ten tools and the keyless trial.
- Errors — every error type, and which ones are worth retrying.