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:
pip install openai-agentsexport 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.
The MCP route
Section titled “The MCP route”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.
"""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 asyncioimport jsonimport osimport refrom typing import Any, Dict, List, Optional
from agents import Agent, Runnerfrom 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_templateand read its JSON Schema — it is the contract for `data`. If you are assembling apayload 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()))What the run looks like
Section titled “What the run looks like”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 themget_template -> invoice@1 schema: invoice_number, issued_on, seller, buyer, line_items required; quantity and unit_price are numbers; tax_rate is 0–1render -> {"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.
The error case
Section titled “The error case”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.
The function-tool route
Section titled “The function-tool route”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.
"""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 asyncioimport jsonimport osimport urllib.errorimport urllib.requestfrom typing import Any, Dict, List, Optional
from agents import Agent, Runner, function_toolfrom 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_tooldef 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.
Which route
Section titled “Which route”| MCP | Function tool | |
|---|---|---|
| API key needed to start | no — 50-render trial | yes |
| Tools the model sees | all ten, including create_template | only what you wrote |
| Schema discovery | get_template at runtime | you hard-code it |
| Network hops | agent → MCP → API | agent → API |
| Good for | exploratory agents, new templates | one 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.
Caching and cost
Section titled “Caching and cost”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.
What next
Section titled “What next”- What the MCP server is — the ten tools and the keyless trial.
- Give a LangGraph agent a PDF tool — the same capability in a graph.
- Render API reference — every field, for the function-tool route.