Skip to main content

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:

ScenarioJSON TokensYAML TokensSaved
Board overview~430~8780%
Task detail~435~8381%
8-task list~1,114~39964%
25-user admin view~2,132~1,84314%
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:

FormatContent-TypeUse Case
JSON (default)application/jsonHuman dashboards, web frontends
YAMLtext/yamlAI agents needing structured data
Markdowntext/markdownAI agents needing narrative context

Why Query Parameters Instead of Accept Headers?

We chose ?format= over the Accept header for three practical reasons:

  1. Proxy transparency: In gateway architectures where requests pass through reverse proxies, query parameters survive intact. Accept headers can be modified, stripped, or overridden by middleware.

  2. Debuggability: You can test format rendering by pasting a URL in your browser. curl ...?format=yaml is simpler than curl -H "Accept: text/yaml" ....

  3. Cache-friendliness: URL-based caching (CDN, Varnish, nginx) naturally varies by query parameter. Header-based content negotiation requires explicit Vary configuration.

  4. 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:

  1. Selects agent-relevant fields -- 13 of 40 fields for a task, skipping cover_image_url, avatar_url, position, column_id (UUID)
  2. Resolves UUIDs to names -- column_id: "a8f3..." becomes column: "In Progress"
  3. Truncates in list views -- descriptions capped at 200 chars in lists, full content in detail views
  4. Includes agent context -- fields like agent_instructions and agent_context that 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 WhenUse Markdown When
Agent needs to parse individual fieldsAgent needs narrative overview
Response feeds into structured processingResponse feeds into conversation
Data has complex nestingData 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:

ServiceEndpointsDescription
AgentBoard27Task management (boards, tasks, activity, metrics)
Main API Gateway25Admin, brands, personas, vibe content
Management API10Agent workspace, memory, sessions, skills
AI Office9Organizational charts, roles, approvals
AgentDocs12Knowledge base pages, project views

Benchmark Results (Production Data)

EndpointJSONYAMLMDYAML SavingsMD Savings
Board detail1,285 B262 B220 B79.6%82.9%
Task detail1,304 B249 B186 B80.9%85.7%
Task list (8 items)3,343 B1,196 B800 B64.2%76.1%
User list (25 items)6,395 B5,530 B1,855 B13.5%71.0%
Brand list (9 items)866 B842 B410 B2.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-Type header, 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:

  1. GraphQL responses are still JSON -- you save on field count but not on format overhead
  2. GraphQL requires schema changes and client-side query construction
  3. AI agents would need to generate GraphQL queries, which is another reasoning step
  4. 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:

  1. JSON with fewer fields is still JSON -- quotes, braces, and structural tokens remain
  2. You lose the semantic rendering (UUID resolution, description truncation, narrative formatting)
  3. 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:

  1. Streaming YAML/MD for large result sets (Server-Sent Events with format-aware chunks)
  2. Context-aware field selection where the agent specifies its current task and the renderer adjusts which fields to include
  3. Automatic token budgeting where the renderer truncates based on a ?max_tokens=N parameter

Try It

OPVS implements this pattern across all agent-facing APIs. Get started:

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 -c on 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:

FieldIn JSONIn YAML/MDWhy Stripped
cover_image_urlYesNoVisual decoration
avatar_urlYesNoVisual decoration
positionYesNoUI layout ordering
colorYesNoVisual styling
slugYesNoURL routing (agent uses ID)
is_defaultYesNoUI state flag
is_done_columnYesNoUI logic flag
tenant_idYesNoImplicit from auth context
settingsYesNoUsually empty {}
is_memberYesNoUI state (nullable)

UUID Resolution

Renderers resolve UUIDs to human-readable names:

  • column_id: "a8f3c2d1-..." becomes column: "In Progress"
  • assigned_to_agent_id: "f2bb2244-..." becomes assigned_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

CategoryEndpoints
Board/Task CRUDGET /boards/{id}, /boards/{id}/tasks, /tasks/{id}
ViewsGET /views/boards/{id}/session, /overview, /tasks, /views/tasks/{id}
ActivityGET /activity/boards/{id}, /activity/tasks/{id}
MetricsGET /metrics/task-velocity, /agent-performance, /costs, /distribution, /board-summary
ListsGET /boards, /boards/{id}/columns, /agents, /agents/{id}, /tasks/{id}/comments
Agent viewsGET /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

CategoryEndpoints
AdminGET /admin/stats, /clients, /clients/{id}, /brands, /brands/{id}/detail, /users, /token-policies, /brands/{id}/token-policy, /tokens
PersonasGET /agents/personas, /personas/{id}, /workflows, /workflows/{id}
BrandsGET /brands, /brands/{id}, /brands/{id}/settings, /information, /members
VibeGET /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.