Skip to content

Give an OpenAI Agents SDK agent a PDF tool

An agent that can produce a real file is a different thing from one that can describe a file. This guide gives an OpenAI Agents SDK agent the render tool, two ways: over MCP, which needs no API key to start, and as a plain function tool over REST, which is the smaller dependency.

Requires Python 3.9 or newer and openai-agents:

Terminal window
pip install openai-agents
export OPENAI_API_KEY=sk-…

There is no Galley Python SDK yet — galley-render on PyPI is reserved but unpublished. The MCP route needs nothing beyond openai-agents; the REST route below uses only the standard library.

MCPServerStreamableHttp from agents.mcp connects to https://mcp.galleyrender.com/mcp and hands the agent all ten Galley tools. Omit headers entirely and the first render mints a 50-render trial.

galley_agent.py
"""An OpenAI Agents SDK agent that can render documents through Galley.
Usage:
export OPENAI_API_KEY=sk-…
export GALLEY_API_KEY=glr_sk_… # optional: omit for the keyless trial
python galley_agent.py
"""
from __future__ import annotations
import asyncio
import json
import os
import re
from typing import Any, Dict, List, Optional
from agents import Agent, Runner
from agents.mcp import MCPServerStreamableHttp
MCP_URL = "https://mcp.galleyrender.com/mcp"
INSTRUCTIONS = """\
You produce finished documents, not descriptions of documents.
Before rendering a template you have not used in this session, call get_template
and read its JSON Schema — it is the contract for `data`. If you are assembling a
payload from data you did not author, call validate_data first; it is free.
Pin the template version (invoice@1, not invoice) in anything you render.
Always report the signed URL from the render result back to the user.
"""
TASK = """\
Render a PDF invoice for Acme Robotics.
Invoice number: INV-1042
Issued: 2026-09-16, due 2026-10-16
From: Galley Render, billing@galleyrender.com,
2727 Jean Lafitte Dr, Fernandina Beach, FL 32034
To: Acme Robotics, ap@acme.test, 100 Market St, Austin, TX 78701
Lines:
- Starter plan, September — 1 x $19.00
- Overage, 3,200 renders — 3.2 x $4.00
Tax rate: 7%
Note: "Payment due within 30 days. ACH details on request."
Letter size, 0.5in margins. Give me the download URL.
"""
URL_PATTERN = re.compile(r"https://\S*?/v1/files/\S+?(?=[\s\"'<>)\]]|$)")
def tool_outputs(result: Any) -> List[str]:
"""Every tool result in the run, as text.
The SDK's run-item classes have moved between releases, so this reads
defensively rather than isinstance-checking a specific type.
"""
out: List[str] = []
for item in getattr(result, "new_items", []):
raw = getattr(item, "raw_item", None)
for candidate in (getattr(item, "output", None), raw, item):
if candidate is None:
continue
text = candidate if isinstance(candidate, str) else str(candidate)
if "/v1/files/" in text or '"object": "render"' in text or "'object': 'render'" in text:
out.append(text)
break
return out
def first_signed_url(result: Any) -> Optional[str]:
"""Pull the signed URL out of the run, preferring the tool result itself."""
for text in tool_outputs(result) + [str(result.final_output)]:
# Tool results are JSON; try that before falling back to a scan.
try:
payload: Dict[str, Any] = json.loads(text)
if isinstance(payload, dict) and payload.get("url"):
return str(payload["url"])
except (json.JSONDecodeError, TypeError):
pass
match = URL_PATTERN.search(text)
if match:
return match.group(0)
return None
async def main() -> int:
key = os.environ.get("GALLEY_API_KEY")
params: Dict[str, Any] = {"url": MCP_URL}
if key:
params["headers"] = {"X-Galley-Api-Key": key}
# No headers at all -> the server mints a 50-render trial on the first render
# and returns its token under `trial.trial_token` in the tool result.
async with MCPServerStreamableHttp(
name="galley",
params=params,
cache_tools_list=True, # the tool list is static; fetch it once
client_session_timeout_seconds=60,
) as galley:
agent = Agent(
name="Document agent",
instructions=INSTRUCTIONS,
model="gpt-4.1",
mcp_servers=[galley],
)
result = await Runner.run(agent, TASK, max_turns=12)
print(result.final_output)
print("-" * 60)
url = first_signed_url(result)
if url:
print(f"Signed URL: {url}")
return 0
print("No render URL in this run. Tool results were:")
for text in tool_outputs(result):
print(text[:800])
return 1
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

The agent makes three tool calls, in the order the server’s own instructions recommend:

list_templates -> 22 templates on this account, invoice@1 among them
get_template -> invoice@1 schema: invoice_number, issued_on, seller,
buyer, line_items required; quantity and unit_price
are numbers; tax_rate is 0–1
render -> {"object": "render", "id": "rnd_7hq2m4x8k1bv",
"status": "succeeded", "template": "invoice@1",
"format": "pdf", "cached": false,
"url": "https://<account>.r2.cloudflarestorage.com/galley-renders/renders/…",
"page_count": 1, "billable_units": 1,
"trial": {"mode": "keyless_trial",
"renders_limit": 50,
"renders_remaining": 49,
"trial_token": "glr_sk_…"}}

The trial block only appears when you connected without a key. Capture trial.trial_token and pass it back as X-Galley-Api-Key on later runs to stay on the same trial — it also works as an ordinary API key against https://api.galleyrender.com.

If the agent sends a payload that does not match the schema, the tool comes back as an error result — isError: true — with the API’s error body verbatim:

{
"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
}
]
}
}

That is the whole point of returning it unchanged: the model reads path, expected and example, fixes the one field, and calls render again. You do not have to catch this in your code — but do keep max_turns above the default if your agent has other work to do, or a single retry can exhaust the budget.

The error to actually handle in code is quota_exceeded (HTTP 402), which means the trial or the free tier is spent. It will not fix itself on retry; the agent should stop and tell you.

If you would rather not speak MCP — one less network dependency, one less moving part — wrap the REST endpoint in a @function_tool. This version has no dependencies beyond openai-agents itself, and it gives the model a narrower, task-shaped tool instead of ten general ones.

galley_function_tool.py
"""Same capability as a plain function tool over the REST API.
Usage:
export OPENAI_API_KEY=sk-…
export GALLEY_API_KEY=glr_sk_… # required on this route
python galley_function_tool.py
"""
from __future__ import annotations
import asyncio
import json
import os
import urllib.error
import urllib.request
from typing import Any, Dict, List, Optional
from agents import Agent, Runner, function_tool
from pydantic import BaseModel, Field
API_BASE = "https://api.galleyrender.com"
def _post(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
req = urllib.request.Request(
f"{API_BASE}{path}",
data=json.dumps(body).encode("utf-8"),
method="POST",
)
req.add_header("Authorization", f"Bearer {os.environ['GALLEY_API_KEY']}")
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=60) 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
except json.JSONDecodeError:
return {"error": {"type": "internal_error", "message": raw[:500]}}
class LineItem(BaseModel):
description: str
quantity: float = Field(description="Units. A number, never a string.")
unit_price: float = Field(description="Price per unit in dollars.")
class Party(BaseModel):
name: str
email: Optional[str] = None
address: Optional[str] = None
@function_tool
def render_invoice(
invoice_number: str,
issued_on: str,
due_on: str,
seller: Party,
buyer: Party,
line_items: List[LineItem],
tax_rate: float = 0.0,
notes: Optional[str] = None,
) -> str:
"""Render a PDF invoice and return a signed download URL.
Dates are ISO `YYYY-MM-DD`. `tax_rate` is a fraction, so 7% is 0.07.
Returns a JSON object with `url`, `id`, `page_count` and `billable_units`,
or an `error` object with a `path` per bad field.
"""
payload = {
"template": "invoice@1",
"format": "pdf",
"options": {"page_size": "Letter", "margin": "0.5in", "print_background": True},
"data": {
"invoice_number": invoice_number,
"issued_on": issued_on,
"due_on": due_on,
"currency": "USD",
"seller": seller.model_dump(exclude_none=True),
"buyer": buyer.model_dump(exclude_none=True),
"line_items": [li.model_dump() for li in line_items],
**({"tax_rate": tax_rate} if tax_rate else {}),
**({"notes": notes} if notes else {}),
},
}
result = _post("/v1/render", payload)
if "error" in result:
# Hand the field errors straight to the model: path, expected, example.
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,
)
async def main() -> None:
agent = Agent(
name="Billing agent",
instructions=(
"You render invoices. Use render_invoice and report the `url` it returns "
"verbatim — never abbreviate a signed URL. If the tool returns an `error`, "
"read `errors[].path` and `errors[].expected`, fix that field, and retry once."
),
model="gpt-4.1",
tools=[render_invoice],
)
result = await Runner.run(
agent,
"Invoice Acme Robotics (ap@acme.test, 100 Market St, Austin, TX 78701) as "
"INV-1042 from Galley Render (billing@galleyrender.com). Issued 2026-09-16, "
"due 2026-10-16. One line: Starter plan September, 1 at $19. Another: "
"Overage 3,200 renders, 3.2 at $4. 7% tax.",
)
print(result.final_output)
if __name__ == "__main__":
asyncio.run(main())

Returning the error envelope as the tool’s return value rather than raising is deliberate. A raised exception is a failed tool call the model may not see the detail of; a returned JSON string with errors[0].path and errors[0].expected is something it can act on immediately.

MCPFunction tool
API key needed to startno — 50-render trialyes
Tools the model seesall ten, including create_templateonly what you wrote
Schema discoveryget_template at runtimeyou hard-code it
Network hopsagent → MCP → APIagent → API
Good forexploratory agents, new templatesone known job, tight token budget

A common arrangement is both: MCP while you are developing and the model is still discovering templates, then a narrow function tool in production once the document you need is settled.

One unit per PDF page, one per PNG or JPG. Cache hits — identical template version, data and options — return the stored object with cached: true, instantly and free. Agent loops retry a lot, so this is doing real work for you: a retried render of an unchanged payload is not a second charge.

Signed URLs expire in an hour; the stored object lives thirty days. If the URL has to survive longer than the run, keep id and call get_render (or GET /v1/renders/:id) for a fresh one.