Generating a tool from docs isn't just "read page, output code." One endpoint can span 10 doc pages with 200 nested schemas. Miss one enum, one casing rule, one input-only field — the tool breaks. R28 handles all of it.
1. Crawl
Follows every link across doc pages. Discovers all nested types, enums, and transitive dependencies.
2. Extract
Pulls schema definitions from noisy doc pages. Not regex — semantic understanding.
3. Verify
Automated checks catch bad extractions before they make it to code.
4. Generate
Outputs strictly typed models with the same constraints as the docs.
One endpoint, four pages
R28 crawls every linked doc page, extracts all schema definitions, resolves the full dependency graph, and generates typed tools with every field, enum, and nested type intact.
POST /v1/charges
└─ ChargeCreateInput
├─ amount: int
├─ currency: str (required)
├─ customer: str (optional)
└─ source: Source ← page 2
├─ type: SourceType ← page 3 (enum)
│ ├─ "card"
│ ├─ "bank_account"
│ └─ "ach_debit"
└─ card: CardParams ← page 4
├─ number: str
├─ exp_month: int
├─ exp_year: int
└─ cvc: strA 1:1 projection of the API documentation — not an approximation, not a "good enough" guess.
Auth
OAuth scopes, credential providers — configured once at the factory level, inherited by every tool.
URL templates
Path parameters like {calendarId} are extracted from Path() fields automatically.
Body routing
Path(), Query(), Body() — each field knows where it goes in the HTTP request.
Mode filtering
Mode("response_only") strips fields the LLM can't set. The schema stays complete — the LLM just never sees the noise.
Key casing
body_case, query_case — the LLM writes snake_case, the API gets camelCase. Per-location, per-field overrides when needed.
# ── client.py ──
calendar_tool = api_tool_factory(
base_url="https://googleapis.com/",
credential=GoogleCredentialProvider,
scope=GoogleScopes.CALENDAR,
body_case="camel",
query_case="camel",
)
# ── tools.py ──
events_insert = calendar_tool(
name="events_insert",
method="POST",
url_template=
"calendar/v3/calendars/{calendarId}/events",
args_schema=EventsInsertRequest,
)
# ── models.py ──
class EventsInsertRequest:
calendar_id: str, Path()
send_updates: str | None, Query()
summary: str, Body()
start: DateTime, Body()
end: DateTime, Body()
attendees: [Attendee], Body()
id: str, Mode("response_only")
status: str, Mode("response_only")Every transformation — formats, casing, field filtering — is handled in the middleware, invisible to both sides.
{
"raw": "dG86IGFsaWNlQG
V4YW1wbGUuY29t
Ck1JTUUtVmVyc2l
..."
}Format("rfc822_base64")
* Builds MIME message
* Encodes to base64url
* Wraps in {"raw": "..."}{
"values": [
[{"stringValue":"Name"},
{"stringValue":"Age"}],
[{"stringValue":"Alice"},
{"numberValue":30}]
]
}Format("proto_json")
* Converts plain JSON
to protobuf Value type
* Wraps each cellA single raw Gmail thread can be 50KB+. That's more than simply noise — it's a context window bomb. With R28 you can transform responses before the LLM ever sees them to keep your context window clean.
What the LLM actually gets
Clean, readable text. No MIME, no base64, no tracking pixels, no 40+ headers. Just the content that matters.
{"payload":{"mimeType":"multipart/mixed","parts":[{"mimeType":"text/plain","body":{"data":"SGVsbG8gQWxpY2Uh..."}},{"mimeType":"text/html","body":{"data":"PGh0bWw+PGhlYWQ+PHN0eWxlPi5FeHRl...tracking pixel..."}},{"mimeType":"application/pdf","filename":"invoice.pdf","body":{"attachmentId":"ANGjdJ..."}}],"headers":[{"name":"From","value":"bob@..."},{"name":"Date","value":"Thu, 27..."},{"name":"X-Mailer","value":"..."},{"name":"DKIM-Signature","value":"v=1;a=rsa-sha256;c=relaxed/relaxed;d=example.com;s=..."},{"name":"Received","value":"from mx.google.com..."},...40+ more headers]}}From: bob@example.com
To: alice@example.com
Date: Thu, 27 Mar 2026
Subject: Invoice for March
Hello Alice!
Please find attached the invoice for March. Let me know if you have any questions.
Best,
Bob
Attachments: invoice.pdf (application/pdf)