Agent-Native APIs: How Content Negotiation Saves 40-76% of LLM Context Tokens
JSON wastes tokens. We added ?format=yaml|md to 83 FastAPI endpoints across 5 services and measured the impact on AI agent context efficiency.
Every API call an AI agent makes consumes context tokens. JSON -- the universal API format -- is optimized for machines parsing structured data, not for language models that process natural language. We measured the waste and built a solution.
The Problem: JSON Is the Wrong Format for AI Agents
Consider this typical API response for a task management board:
{
"name": "Customer Support",
"description": null,
"icon": null,
"color": null,
"id": "c67fda32-238e-4d58-b68c-bf0a4aef2d53",
"tenant_id": "00000000-0000-0000-0000-000000000001",
"is_default": false,
"is_archived": false,
"settings": {},
"created_by": "f2bb2244-69d8-5973-a064-90572e9c8bec",
"created_at": "2026-02-26T17:41:15.924680Z",
"updated_at": "2026-02-26T17:41:15.924680Z",
"visibility": "public",
"mode": "pipeline",
"member_count": 27,
"is_member": null,
"column_count": 5,
"task_count": 1,
"columns": [
{"id": "...", "name": "Backlog", "slug": "backlog", "position": 0, "color": "#6B7280", "is_done_column": false, "is_default": true, "task_count": 1},
{"id": "...", "name": "To Do", "slug": "to-do", "position": 1, "color": "#3B82F6", "is_done_column": false, "is_default": false, "task_count": 0},
{"id": "...", "name": "In Progress", "slug": "in-progress", "position": 2, "color": "#F59E0B", "is_done_column": false, "is_default": false, "task_count": 0},
{"id": "...", "name": "Review", "slug": "review", "position": 3, "color": "#8B5CF6", "is_done_column": false, "is_default": false, "task_count": 0},
{"id": "...", "name": "Done", "slug": "done", "position": 4, "color": "#10B981", "is_done_column": true, "is_default": false, "task_count": 0}
]
}
That's 1,285 bytes. An AI agent processing this needs to parse through UUIDs, null values, boolean flags for UI rendering, color hex codes, positional data, and nested objects with redundant keys -- none of which help it understand the board.
Here's the same data in YAML, rendered for agent consumption:
board:
id: c67fda32-238e-4d58-b68c-bf0a4aef2d53
name: Customer Support
description: null
columns:
- name: Backlog
task_count: 1
- name: To Do
task_count: 0
- name: In Progress
task_count: 0
- name: Review
task_count: 0
- name: Done
task_count: 0
262 bytes. A 79.6% reduction. Not through compression -- through intelligent field selection and format choice.
And in Markdown:
# Board: Customer Support
**Columns**: 5 | **Tasks**: 1
- **Backlog**: 1 tasks
- **To Do**: 0 tasks
- **In Progress**: 0 tasks
- **Review**: 0 tasks
- **Done**: 0 tasks
220 bytes. 82.9% reduction. The agent gets the same semantic information in a format it naturally understands.
Why This Matters
Context windows are the fundamental constraint of LLM-based agents. Every token spent parsing JSON syntax -- curly braces, square brackets, quoted keys, null values -- is a token not available for reasoning, planning, or generating responses.
The math is simple:
| Scenario | JSON Tokens | YAML Tokens | Saved |
|---|---|---|---|
| Board overview | ~430 | ~87 | 80% |
| Task detail | ~435 | ~83 | 81% |
| 8-task list | ~1,114 | ~399 | 64% |
| 25-user admin view | ~2,132 | ~1,843 | 14% |
| Average (non-trivial) | -- | -- | 48% YAML, 74% MD |
For an agent doing a typical workflow -- check board state, read 3 tasks, update status -- that's potentially thousands of tokens saved per interaction. At scale, this translates directly to:
- Lower costs: Fewer input tokens = lower API bills
- Better reasoning: More context budget for actual thinking
- Faster responses: Less data to process = lower latency
- Longer conversations: The agent can maintain context longer before hitting limits
The Solution: ?format=yaml|md
We added a simple query parameter to 83 GET endpoints across 5 FastAPI services:
GET /api/v1/boards/abc123 → JSON (default)
GET /api/v1/boards/abc123?format=yaml → YAML
GET /api/v1/boards/abc123?format=md → Markdown
The response includes the appropriate Content-Type header:
| Format | Content-Type | Use Case |
|---|---|---|
| JSON (default) | application/json | Human dashboards, web frontends |
| YAML | text/yaml | AI agents needing structured data |
| Markdown | text/markdown | AI agents needing narrative context |
Why Query Parameters Instead of Accept Headers?
We chose ?format= over the Accept header for three practical reasons:
-
Proxy transparency: In gateway architectures where requests pass through reverse proxies, query parameters survive intact.
Acceptheaders can be modified, stripped, or overridden by middleware. -
Debuggability: You can test format rendering by pasting a URL in your browser.
curl ...?format=yamlis simpler thancurl -H "Accept: text/yaml" .... -
Cache-friendliness: URL-based caching (CDN, Varnish, nginx) naturally varies by query parameter. Header-based content negotiation requires explicit
Varyconfiguration. -
Tool compatibility: Many API testing tools, webhook systems, and agent frameworks don't support custom Accept headers. Query parameters work everywhere.
The Renderer Architecture
The key insight is that format rendering is not serialization -- it's intelligent summarization. A good YAML renderer doesn't just dump the ORM object to YAML. It:
- Selects agent-relevant fields -- 13 of 40 fields for a task, skipping
cover_image_url,avatar_url,position,column_id(UUID) - Resolves UUIDs to names --
column_id: "a8f3..."becomescolumn: "In Progress" - Truncates in list views -- descriptions capped at 200 chars in lists, full content in detail views
- Includes agent context -- fields like
agent_instructionsandagent_contextthat are irrelevant to UI but critical for agents
Here's the pattern we use in FastAPI:
# 1. Add optional format parameter to any GET endpoint
@router.get("/boards/{board_id}")
async def get_board(
board_id: uuid.UUID,
format: str | None = Query(
None,
description="Response format: yaml or md",
pattern="^(yaml|md)$"
),
):
board = await service.get_by_id(board_id)
# 2. Return alternative format if requested
if format == "yaml":
return Response(
content=render_board_yaml(board),
media_type="text/yaml",
)
if format == "md":
return Response(
content=render_board_md(board),
media_type="text/markdown",
)
# 3. Default JSON response unchanged
return board # Pydantic serialization
The renderers live in a dedicated services/renderers.py per service:
def render_board_yaml(board, columns) -> str:
"""Render board as agent-friendly YAML."""
data = {
"board": {
"id": str(board.id),
"name": board.name,
"description": board.description,
},
"columns": [
{"name": col["name"], "task_count": col["task_count"]}
for col in columns
],
}
return yaml.dump(data, default_flow_style=False, sort_keys=False)
def render_board_md(board, columns) -> str:
"""Render board as Markdown summary."""
total_tasks = sum(c["task_count"] for c in columns)
lines = [
f"# Board: {board.name}",
f"**Columns**: {len(columns)} | **Tasks**: {total_tasks}",
"",
]
for col in columns:
lines.append(f"- **{col['name']}**: {col['task_count']} tasks")
return "\n".join(lines)
When to Use YAML vs Markdown
| Use YAML When | Use Markdown When |
|---|---|
| Agent needs to parse individual fields | Agent needs narrative overview |
| Response feeds into structured processing | Response feeds into conversation |
| Data has complex nesting | Data is mostly flat/tabular |
| Exact values matter (IDs, counts, dates) | Relationships and context matter |
Production Results
We deployed this across 5 FastAPI services with 83 GET endpoints:
| Service | Endpoints | Description |
|---|---|---|
| AgentBoard | 27 | Task management (boards, tasks, activity, metrics) |
| Main API Gateway | 25 | Admin, brands, personas, vibe content |
| Management API | 10 | Agent workspace, memory, sessions, skills |
| AI Office | 9 | Organizational charts, roles, approvals |
| AgentDocs | 12 | Knowledge base pages, project views |
Benchmark Results (Production Data)
| Endpoint | JSON | YAML | MD | YAML Savings | MD Savings |
|---|---|---|---|---|---|
| Board detail | 1,285 B | 262 B | 220 B | 79.6% | 82.9% |
| Task detail | 1,304 B | 249 B | 186 B | 80.9% | 85.7% |
| Task list (8 items) | 3,343 B | 1,196 B | 800 B | 64.2% | 76.1% |
| User list (25 items) | 6,395 B | 5,530 B | 1,855 B | 13.5% | 71.0% |
| Brand list (9 items) | 866 B | 842 B | 410 B | 2.8% | 52.7% |
Key observations:
- Detail endpoints (single entity) see the highest savings (80%+) because JSON includes many UI-only fields that renderers strip
- List endpoints vary by data shape -- lists with simple structures (brands) save less in YAML because YAML's per-item overhead is similar to JSON's, but Markdown's tabular format always wins
- Markdown consistently outperforms YAML for agent consumption because it eliminates all structural syntax
Test Coverage
We built a comprehensive test suite covering all 83 endpoints:
- 179 passed, 0 failed, 33 skipped (endpoints requiring test data)
- Tests verify: HTTP 200, correct
Content-Typeheader, YAML parseability (yaml.safe_load()) - Tests run in ~45 seconds across all 5 services
How to Add This to Your API
The pattern is fully additive -- it doesn't break existing JSON consumers. Here's a step-by-step guide:
Step 1: Create a renderers module
# your_service/services/renderers.py
import yaml
from typing import Any
def _entity_to_dict(entity) -> dict[str, Any]:
"""Extract agent-relevant fields from an ORM object."""
return {
"id": str(entity.id),
"name": entity.name,
"status": entity.status,
# Only include fields an AI agent would need
# Skip: avatar_url, position, color, cover_image_url
}
def render_list_yaml(items, total: int) -> str:
data = {
"items": [_entity_to_dict(item) for item in items],
"total": total,
}
return yaml.dump(
data,
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
)
def render_list_md(items, total: int) -> str:
lines = [f"# Items ({total} total)", ""]
for item in items:
lines.append(f"## {item.name}")
lines.append(f"- **Status**: {item.status}")
lines.append("")
return "\n".join(lines)
Step 2: Add format parameter to endpoints
from fastapi import Query, Response
@router.get("/items")
async def list_items(
format: str | None = Query(
None, pattern="^(yaml|md)$"
),
):
items = await service.list_all()
if format == "yaml":
return Response(
content=render_list_yaml(items, len(items)),
media_type="text/yaml",
headers={"Cache-Control": "max-age=60"},
)
if format == "md":
return Response(
content=render_list_md(items, len(items)),
media_type="text/markdown",
headers={"Cache-Control": "max-age=60"},
)
return {"items": items, "total": len(items)}
That's it. Two files per service. The JSON default is completely unchanged.
Step 3: Test it
# Verify JSON default still works
curl -s http://localhost:8000/api/v1/items | jq .
# Verify YAML
curl -s "http://localhost:8000/api/v1/items?format=yaml"
# Verify Markdown
curl -s "http://localhost:8000/api/v1/items?format=md"
# Verify YAML is parseable
curl -s "http://localhost:8000/api/v1/items?format=yaml" | \
python3 -c "import sys, yaml; yaml.safe_load(sys.stdin.read())"
Design Decisions
Why not GraphQL?
GraphQL lets clients request specific fields, which partially solves the "too much data" problem. But:
- GraphQL responses are still JSON -- you save on field count but not on format overhead
- GraphQL requires schema changes and client-side query construction
- AI agents would need to generate GraphQL queries, which is another reasoning step
- Our approach is additive to existing REST endpoints -- zero migration cost
Why not just truncate JSON?
You could strip fields from the JSON response based on a ?fields= parameter. But:
- JSON with fewer fields is still JSON -- quotes, braces, and structural tokens remain
- You lose the semantic rendering (UUID resolution, description truncation, narrative formatting)
- YAML and Markdown are formats LLMs are trained on extensively -- they parse them more naturally than JSON
Why per-service renderers instead of a generic middleware?
A generic JSON-to-YAML converter would be trivial but useless. The value comes from domain-specific intelligence: knowing that column_id should be resolved to a column name, that cover_image_url is irrelevant to agents, that a task list should show subtask progress as "3/5 completed" rather than nested objects.
What's Next
This pattern works for any REST API serving AI agents. We're exploring:
- Streaming YAML/MD for large result sets (Server-Sent Events with format-aware chunks)
- Context-aware field selection where the agent specifies its current task and the renderer adjusts which fields to include
- Automatic token budgeting where the renderer truncates based on a
?max_tokens=Nparameter
Try It
OPVS implements this pattern across all agent-facing APIs. Get started:
- Install the CLI and MCP Server -- Connect your AI agent in 3 steps
- Format Negotiation Reference -- Full endpoint list with response examples
- MCP Server -- Native tool integration (uses YAML by default)
If you're building APIs that AI agents consume, try adding ?format=yaml to your busiest GET endpoints. Measure the token savings. You might be surprised.
Appendix A: Benchmark Methodology
- Platform: OPVS.ai production instance (Docker Compose, 5 FastAPI services)
- Date: March 2026
- Measurement: Raw byte count via
wc -con HTTP response body - Token estimation: ~3.5 characters per token (GPT-4 tokenizer)
Fields Stripped by Renderers
The savings come not just from YAML syntax being more compact, but from intelligent field selection:
| Field | In JSON | In YAML/MD | Why Stripped |
|---|---|---|---|
cover_image_url | Yes | No | Visual decoration |
avatar_url | Yes | No | Visual decoration |
position | Yes | No | UI layout ordering |
color | Yes | No | Visual styling |
slug | Yes | No | URL routing (agent uses ID) |
is_default | Yes | No | UI state flag |
is_done_column | Yes | No | UI logic flag |
tenant_id | Yes | No | Implicit from auth context |
settings | Yes | No | Usually empty {} |
is_member | Yes | No | UI state (nullable) |
UUID Resolution
Renderers resolve UUIDs to human-readable names:
column_id: "a8f3c2d1-..."becomescolumn: "In Progress"assigned_to_agent_id: "f2bb2244-..."becomesassigned_to: "Agent Smith"
This saves tokens (UUIDs are ~36 characters) and makes responses more useful for LLM reasoning.
Appendix B: Endpoint Inventory (83 Endpoints)
AgentBoard -- 27 endpoints
| Category | Endpoints |
|---|---|
| Board/Task CRUD | GET /boards/{id}, /boards/{id}/tasks, /tasks/{id} |
| Views | GET /views/boards/{id}/session, /overview, /tasks, /views/tasks/{id} |
| Activity | GET /activity/boards/{id}, /activity/tasks/{id} |
| Metrics | GET /metrics/task-velocity, /agent-performance, /costs, /distribution, /board-summary |
| Lists | GET /boards, /boards/{id}/columns, /agents, /agents/{id}, /tasks/{id}/comments |
| Agent views | GET /views/boards, /views/tasks, /views/agents, /views/agents/{id}, /views/boards/{id}/agents, /views/boards/{id}/activity, /views/tasks/{id}/activity, /views/tasks/{id}/comments |
Main API Gateway -- 25 endpoints
| Category | Endpoints |
|---|---|
| Admin | GET /admin/stats, /clients, /clients/{id}, /brands, /brands/{id}/detail, /users, /token-policies, /brands/{id}/token-policy, /tokens |
| Personas | GET /agents/personas, /personas/{id}, /workflows, /workflows/{id} |
| Brands | GET /brands, /brands/{id}, /brands/{id}/settings, /information, /members |
| Vibe | GET /vibe/sources, /sources/{id}, /posts, /posts/{id}, /config, /logs, /notifications |
Management API -- 10 endpoints
GET /agents, /agents/{id}, /agents/{id}/workspace, /memory, /users, /sessions, /skills, /cron/jobs, /cron/jobs/{id}/runs, /config
AI Office -- 9 endpoints
GET /offices, /offices/{id}, /offices/{id}/roles, /roles/{id}, /relationships, /approvals, /approvals/{id}, /approvals/role/{id}/pending, /templates
AgentDocs -- 12 endpoints
GET /docs/projects/{id}, /pages, /pages/{id}, /views/projects/{id}, /views/projects/{id}/pages, /views/pages/{id}, /views/boards/{id}/docs, /views/tasks/{id}/docs, /views/search, /views/recent, /views/tree, /views/sitemap
Appendix C: Token Waste Breakdown
JSON (1,285 bytes):
█████████████████████████████████████████████████████████████████ 100%
├── Structural syntax (braces, quotes, commas): ~30%
├── UI-only fields (color, position, slug, etc.): ~50%
└── Agent-relevant data: ~20%
YAML (262 bytes):
██████████████ 20.4%
└── Agent-relevant data: 100%
Markdown (220 bytes):
████████████ 17.1%
└── Agent-relevant data + formatting: 100%
Built with FastAPI, deployed across 5 microservices serving AI agents in production. Benchmarked with real data from a multi-tenant SaaS platform.