Normalizing 571 APIs into One Agent Contract
Engineering deep-dive: how SandBase normalized 571 heterogeneous social media operations into a single /v1/run contract with naming rules, body wrapping, and fail-closed validation.
TL;DR — 571 social media API operations from four platforms, each with different HTTP methods, parameter positions, body shapes, and naming conventions. One adapter layer that normalizes them all into a single
POST /v1/runcontract. This article explains the four hardest engineering decisions: naming canonicalization, root body wrapping, parameter position mapping, and fail-closed validation.
You have 571 API operations. Some are GET with query parameters. Some are POST with JSON objects. Some are POST with a root-level array. One is POST with an optional root-level string. They come from four different platforms with four different naming conventions.
Your agent should not care about any of this.
The agent calls POST /v1/run with {"model": "douyin/search/challenge-search-v1", "keyword": "AI创作"} and gets back JSON. Whether that becomes a GET request with ?keyword=AI创作 or a POST with {"keyword": "AI创作"} is the adapter’s problem.
Here is how we built that adapter.
The problem space
571 operations. Four platforms. The raw heterogeneity:
| Dimension | Variants found |
|---|---|
| HTTP methods | GET (255), POST (316) |
| Parameter positions | query string, path, JSON body, mixed |
| Body shapes | JSON object (309), root array (4), root optional string (2), none (255 GET) |
| Naming patterns | fetch_video_search_result, get_unique_id, handler_hot_search, multi_video_v2 |
| Auth patterns | Bearer token (all, but different header conventions upstream) |
| Response shapes | JSON object (all, after normalization) |
The goal: an agent sees one flat namespace of operation names, sends one JSON body, and gets one JSON response. Everything else is hidden.
Decision 1: Naming canonicalization
The problem
Upstream operation names look like:
fetch_video_search_resultget_unique_idhandler_hot_search_listfetch_multi_video_v2kol_xingtu_index_v1
They have inconsistent prefixes (fetch_, get_, handler_), redundant platform tokens (douyin_ inside a path that already says douyin/), and mixed version indicators.
The rule
canonical_name = {platform}/{normalized-channel-version}/{semantic-action}
The normalization steps:
- Strip the outer path prefix (
/api/v1/→ removed) - Normalize channel+version —
douyin/app/v3/→app-v3,weibo/web/v2/→web-v2 - Strip action prefixes — Remove leading
fetch_,get_,handler_from the action segment - Strip redundant platform tokens — If the action repeats the platform name, remove it
- Convert to kebab-case —
multi_video_v2→multi-video-v2
Before → After
| Upstream path | Canonical name |
|---|---|
/api/v1/douyin/search/fetch_video_search_result | douyin/search/video-search-result |
/api/v1/tiktok/web/get_unique_id | tiktok/web/unique-id |
/api/v1/weibo/web/v2/fetch_hot_search | weibo/web-v2/hot-search |
/api/v1/douyin/app/v3/fetch_multi_video_v2 | douyin/app-v3/multi-video-v2 |
/api/v1/xiaohongshu/web/v3/fetch_hot_list | xiaohongshu/web-v3/hot-list |
Why not just use the original names?
Three reasons:
- Agent usability:
douyin/search/video-search-resulttells you platform/channel/action at a glance.fetch_video_search_resultdoesn’t tell you which platform or version. - Collision avoidance: With 571 operations, multiple platforms might have a
hot_search. The platform prefix guarantees uniqueness. - Stable identity: If the upstream renames
fetch_hot_searchtoget_hot_search, the canonical name stayshot-search. The mapping is from path structure, not from operation IDs.
All 571 names were validated for uniqueness (collisions = 0). The rule is: if two operations canonicalize to the same name, the entire import fails — no silent deduplication.
Decision 2: Root body wrapping
The problem
POST /v1/run expects a JSON object: {"model": "...", ...params}. But some upstream operations expect:
- A root array:
["video_id_1", "video_id_2", ...] - An optional root string:
"search_query_text"
You can’t put a bare array as a top-level request body when the contract requires an object.
The solution: body field wrapping
// Agent sends (root array case):
{
"model": "douyin/app-v3/multi-video-v2",
"body": ["video_id_1", "video_id_2", "video_id_3"]
}
// Adapter unwraps and sends upstream:
["video_id_1", "video_id_2", "video_id_3"]
// Agent sends (optional root string case):
{
"model": "douyin/search/query-user",
"body": "张三"
}
// Adapter unwraps and sends upstream:
"张三"
// Agent sends (optional root string, omitted):
{
"model": "douyin/search/query-user"
}
// Adapter sends upstream with empty body (field is optional)
The registry marks which operations need wrapping
Each operation’s metadata includes root_body_type:
{
"name": "douyin/app-v3/multi-video-v2",
"method": "POST",
"root_body_type": "array",
"body_required": true
}
Possible values:
nullor absent — standard object body, no wrapping"array"—bodyfield must be an array, unwrapped before sending"string"—bodyfield must be a string (or absent if optional)
Out of 571 operations: 4 are root array, 2 are root optional string, the rest (565) are standard objects.
Decision 3: Parameter position mapping
The problem
GET operations have parameters in query strings. But the agent always sends a JSON body. How do you map JSON fields to query parameters?
// Agent sends:
{
"model": "tiktok/web/unique-id",
"username": "example_user"
}
// Adapter must produce:
// GET /api/v1/tiktok/web/get_unique_id?username=example_user
The mapping rules
The adapter knows each parameter’s location from the OpenAPI spec:
| Location | Agent sends | Adapter produces |
|---|---|---|
query | JSON field | URL query parameter |
path | JSON field | Substituted into URL path |
body (object) | JSON field | Passed in POST body |
body (root array/string) | body field | Unwrapped as POST body |
For path parameters, the adapter does safe substitution:
// Operation has path: /api/v1/tiktok/user/{unique_id}/posts
// Agent sends:
{
"model": "tiktok/user-posts",
"unique_id": "example_user",
"count": 20
}
// Adapter produces:
// GET /api/v1/tiktok/user/example_user/posts?count=20
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ path substitution
// ^^^^^^^^^ remaining params as query
Zero-value handling
A critical subtlety: false, 0, and empty arrays are valid parameter values. The adapter must not confuse “field is absent” with “field is present but falsy.”
// Agent sends:
{"model": "douyin/app-v3/check-live", "room_id": "123", "include_offline": false}
// Adapter must produce:
// GET ...?room_id=123&include_offline=false
// NOT: GET ...?room_id=123 (dropping false would change semantics)
The rule: only truly absent fields are omitted. null, false, 0, "", and [] are all transmitted.
Decision 4: Fail-closed validation
The philosophy
When in doubt, reject. Don’t silently pass through unknown fields, don’t guess at missing required parameters, don’t try to be clever.
What gets rejected (before hitting upstream)
| Condition | Behavior | Why |
|---|---|---|
| Unknown field name | Reject with error | Prevents typos from becoming silent data loss |
| Required field missing | Reject with error | Upstream would fail anyway; fail fast with a clear message |
Path parameter still has {placeholder} | Reject | Means a required path param was not provided |
| Cookie/header parameters | Reject | Security: agents must not inject auth headers |
| Unsupported body content type | Reject | Only JSON is supported |
| Field location conflicts | Reject | Same field in query AND body is ambiguous |
What passes through (by design)
| Condition | Behavior | Why |
|---|---|---|
| Optional field omitted | Pass (don’t inject defaults) | Agent explicitly chose not to provide it |
| Extra fields beyond spec | Reject | Strict: unknown fields are potential injection vectors |
| Boolean false / numeric 0 | Pass as-is | Valid values, not absence |
Example: a rejected call
// Agent sends:
{
"model": "douyin/search/challenge-search-v1",
"keyword": "AI",
"unknown_field": "test",
"cursor": 0
}
// Adapter response:
{
"error": "unknown field 'unknown_field' for operation douyin/search/challenge-search-v1. Valid fields: keyword, count, cursor"
}
The agent gets an actionable error message naming the invalid field and listing valid options. It can self-correct.
The result
From the agent’s perspective:
# Call any of 571 operations the same way
response = client.chat.completions.create(
model="douyin/search/challenge-search-v1", # or weibo/web-v2/hot-search, or xiaohongshu/pgy/blogger-detail
messages=[],
extra_body={"keyword": "AI创作", "count": 20}
)
data = json.loads(response.choices[0].message.content)
No HTTP method thinking. No parameter position thinking. No body shape thinking. No auth thinking.
571 operations, one contract.
Trade-offs we accepted
-
Strict validation means more initial errors: Agents hit “unknown field” errors during development. But each error is self-correcting (the error message says what’s valid). We chose debuggability over permissiveness.
-
Naming canonicalization loses original identifiers: If you know the upstream
operationId, you can’t use it directly. But canonical names are more readable and stable across upstream renames. -
No streaming for data APIs: All 571 operations are sync-only. Data APIs return complete results; there is no partial response to stream. This keeps the contract simple but means you can’t get “first 10 results fast, rest later.”
-
Cookie/header injection blocked: Some upstream operations accept optional cookies for session stickiness. We block all cookie/header parameters from the agent. Security wins over rare-case convenience.
FAQ
Why not just proxy the original API as-is?
Because an agent calling 10 different operations would need to understand 10 different conventions: which are GET, which are POST, where parameters go, how errors look. That cognitive load defeats the purpose of an agent platform. One contract = one code path = one error handler.
What happens when upstream adds new operations?
The importer runs against the latest upstream spec, applies naming rules, validates uniqueness (all 571 must remain collision-free), and generates new metadata. If a new operation conflicts with an existing name, the entire batch fails until resolved manually.
Can I call the original API directly if I want?
Not through SandBase. The normalized contract is the only interface. This is intentional: it ensures every call gets the same auth, logging, cost tracking, and validation — which is what makes the operations composable inside agent workflows.
How do you handle upstream breaking changes?
If an upstream operation changes its parameter schema, the adapter’s validation catches the mismatch: agents sending old parameters get clear errors, and the operation’s metadata is updated to reflect the new schema. There is no silent degradation.
For the full catalog of available operations, see 310 Douyin APIs and Weibo + Xiaohongshu APIs. For context on why agents need this kind of normalization, see our social media data API guide.


