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/run contract. 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:

DimensionVariants found
HTTP methodsGET (255), POST (316)
Parameter positionsquery string, path, JSON body, mixed
Body shapesJSON object (309), root array (4), root optional string (2), none (255 GET)
Naming patternsfetch_video_search_result, get_unique_id, handler_hot_search, multi_video_v2
Auth patternsBearer token (all, but different header conventions upstream)
Response shapesJSON 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_result
  • get_unique_id
  • handler_hot_search_list
  • fetch_multi_video_v2
  • kol_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:

  1. Strip the outer path prefix (/api/v1/ → removed)
  2. Normalize channel+versiondouyin/app/v3/app-v3, weibo/web/v2/web-v2
  3. Strip action prefixes — Remove leading fetch_, get_, handler_ from the action segment
  4. Strip redundant platform tokens — If the action repeats the platform name, remove it
  5. Convert to kebab-casemulti_video_v2multi-video-v2

Before → After

Upstream pathCanonical name
/api/v1/douyin/search/fetch_video_search_resultdouyin/search/video-search-result
/api/v1/tiktok/web/get_unique_idtiktok/web/unique-id
/api/v1/weibo/web/v2/fetch_hot_searchweibo/web-v2/hot-search
/api/v1/douyin/app/v3/fetch_multi_video_v2douyin/app-v3/multi-video-v2
/api/v1/xiaohongshu/web/v3/fetch_hot_listxiaohongshu/web-v3/hot-list

Why not just use the original names?

Three reasons:

  1. Agent usability: douyin/search/video-search-result tells you platform/channel/action at a glance. fetch_video_search_result doesn’t tell you which platform or version.
  2. Collision avoidance: With 571 operations, multiple platforms might have a hot_search. The platform prefix guarantees uniqueness.
  3. Stable identity: If the upstream renames fetch_hot_search to get_hot_search, the canonical name stays hot-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:

  • null or absent — standard object body, no wrapping
  • "array"body field must be an array, unwrapped before sending
  • "string"body field 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:

LocationAgent sendsAdapter produces
queryJSON fieldURL query parameter
pathJSON fieldSubstituted into URL path
body (object)JSON fieldPassed in POST body
body (root array/string)body fieldUnwrapped 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)

ConditionBehaviorWhy
Unknown field nameReject with errorPrevents typos from becoming silent data loss
Required field missingReject with errorUpstream would fail anyway; fail fast with a clear message
Path parameter still has {placeholder}RejectMeans a required path param was not provided
Cookie/header parametersRejectSecurity: agents must not inject auth headers
Unsupported body content typeRejectOnly JSON is supported
Field location conflictsRejectSame field in query AND body is ambiguous

What passes through (by design)

ConditionBehaviorWhy
Optional field omittedPass (don’t inject defaults)Agent explicitly chose not to provide it
Extra fields beyond specRejectStrict: unknown fields are potential injection vectors
Boolean false / numeric 0Pass as-isValid 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

  1. 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.

  2. 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.

  3. 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.”

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