Blog/Tutorials//Updated

Build a Social Monitor Agent (OpenAI SDK)

Build a Douyin and Weibo brand monitor in Python: requests calls SandBase public-data APIs, the OpenAI SDK runs tool-calling and sentiment alerts.

Cover image for Build a Social Monitor Agent (OpenAI SDK)

TL;DR — A working social monitoring agent in Python (a ~340-line reference implementation, not a minimal snippet). It uses requests to call SandBase’s public-data APIs at POST /v1/api/<endpoint> and the OpenAI SDK for LLM tool-calling. It searches Douyin and Weibo for your keywords, asks the LLM for a sentiment assessment, and raises an alert when the LLM flags one or the reported negative share crosses a threshold. Tested on 2026-10-01 (UTC).

What we’re building

A social monitoring agent that:

  1. Periodically searches Douyin and Weibo for mentions of specified keywords
  2. Uses an LLM to read the results and assess sentiment and urgency
  3. Produces a structured alert when the LLM flags one, or when the negative share it reports is above ALERT_THRESHOLD_NEGATIVE
  4. Uses direct HTTP requests for the data APIs and the OpenAI SDK for LLM reasoning

It does not detect volume spikes: each search reads one page of results, so a per-cycle result count is not a volume measure. See Add trend comparison for what that would take.

Why this two-client pattern

SandBase exposes each platform data API as its own route, POST https://api.sandbase.ai/v1/api/<vendor>/<path>, with a JSON body of only that endpoint’s parameters. LLMs go through the OpenAI-compatible chat completions endpoint. So:

  • requests for data retrieval (each endpoint returns structured JSON synchronously)
  • OpenAI SDK for LLM reasoning (chat completions with tool-calling)
  • One API key for both (same SandBase Authorization: Bearer header)

What data this uses

Everything here is public, read-only data: search results and public comments that anyone can see on Douyin and Weibo. You need a SandBase API key. SandBase is not an official partner of Douyin or Weibo, and this agent cannot read private-account data, DMs or owner-only analytics, and cannot publish or take account actions.

This tutorial (SandBase public-data APIs)Official developer platforms (Douyin Open Platform, Weibo Open Platform)
AccessOne SandBase API keyApp registration and platform review
ScopePublic content: keyword search, public commentsAuthorized, account-linked workflows for accounts that grant your app access
Account actions (publish, reply, DMs)NoWhere the platform grants the permission
Owner analyticsNoFor authorized accounts
Choose it whenYou need to watch public conversation about a keyword across platformsYou need to act on, or read private data of, accounts you manage

Neither platform offers a general unauthenticated API for arbitrary public data, which is the gap this approach fills.

Prerequisites

pip install openai requests pydantic

You’ll need a SandBase API key and access to:

The LLM client uses SandBase’s OpenAI-compatible POST /v1/chat/completions endpoint, described in the Chat Completions guide; model IDs keep the provider prefix (openai/gpt-4.1-mini). On 2026-10-01 (UTC) a request to that endpoint with model: "openai/gpt-4.1-mini" and one tools definition returned HTTP 200 with finish_reason: "tool_calls" and a tool_calls entry.

Endpoint references for every data call in the code:

The Douyin search reference below is where the request body in execute_tool comes from: a POST /v1/api/douyin/search/general-search-v2 route with keyword, cursor and backtrace among its optional fields.

SandBase endpoint reference for douyin/search/general-search-v2 showing the POST route, request body fields and the completed response shape

Caption: The douyin/search/general-search-v2 endpoint reference, showing the POST route, the backtrace, cursor and keyword fields and the outputs[0].data response shape the helper reads (captured 2026-10-02).

The complete agent

"""
Social Media Monitor Agent — OpenAI SDK + Direct HTTP Pattern
Monitors Douyin and Weibo for brand mentions, analyzes sentiment.
Uses requests for SandBase data APIs + OpenAI SDK for LLM reasoning.
"""

import json
import os
import time
from collections import deque
from datetime import datetime
import requests
from openai import OpenAI
from pydantic import BaseModel

# --- Configuration ---
SANDBASE_API_KEY = os.environ["SANDBASE_API_KEY"]
API_BASE = "https://api.sandbase.ai/v1/api"
MONITOR_KEYWORDS = ["你的品牌", "YourBrand", "品牌产品名"]
CHECK_INTERVAL_SECONDS = 900      # 15 minutes
ALERT_THRESHOLD_NEGATIVE = 30     # alert if the LLM reports >30% negative
MAX_ITERATIONS = 4                # tool-use LLM calls per keyword (upper bound)
MAX_DATA_CALLS_PER_KEYWORD = 4    # data API calls per keyword (upper bound)
MAX_RETRIES = 3

# --- Client Setup ---
HEADERS = {
    "Authorization": f"Bearer {SANDBASE_API_KEY}",
    "Content-Type": "application/json",
}

# OpenAI SDK client for LLM reasoning only
client = OpenAI(
    base_url="https://api.sandbase.ai/v1",
    api_key=SANDBASE_API_KEY,
)

# --- Data Models ---
class Alert(BaseModel):
    timestamp: str
    severity: str  # "low", "medium", "high", "critical"
    keyword: str
    platform: str
    reason: str
    details: str

# --- Rate limiting (client-side pacing you choose, not a documented SandBase limit) ---
class RateLimiter:
    def __init__(self, max_calls: int, window_seconds: float):
        self.max_calls = max_calls
        self.window = window_seconds
        self.calls = deque()

    def wait(self):
        now = time.monotonic()
        while self.calls and self.calls[0] <= now - self.window:
            self.calls.popleft()
        if len(self.calls) >= self.max_calls:
            time.sleep(self.calls[0] + self.window - now)
        self.calls.append(time.monotonic())

limiter = RateLimiter(max_calls=30, window_seconds=60)

# --- Tool name -> SandBase endpoint ---
TOOL_ENDPOINTS = {
    "search_douyin_content": "douyin/search/general-search-v2",
    "search_weibo_topics": "weibo/web-v2/realtime-search",
    "get_douyin_video_comments": "douyin/web/video-comments",
}

# --- Tool Definitions ---
tools = [
    {
        "type": "function",
        "function": {
            "name": "search_douyin_content",
            "description": "Search Douyin for videos matching a keyword. Returns recent videos with engagement metrics.",
            "parameters": {
                "type": "object",
                "properties": {
                    "keyword": {"type": "string", "description": "Search keyword"},
                    "count": {"type": "integer", "description": "Number of results to return (max 20)", "default": 10}
                },
                "required": ["keyword"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "search_weibo_topics",
            "description": "Search Weibo for recent posts matching a keyword. Returns posts with engagement data.",
            "parameters": {
                "type": "object",
                "properties": {
                    "keyword": {"type": "string", "description": "Search keyword"},
                    "count": {"type": "integer", "description": "Number of results to return (max 20)", "default": 10}
                },
                "required": ["keyword"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "get_douyin_video_comments",
            "description": "Get public comments on a specific Douyin video for sentiment analysis.",
            "parameters": {
                "type": "object",
                "properties": {
                    "video_id": {"type": "string", "description": "Douyin video ID (video_id from search_douyin_content)"},
                    "count": {"type": "integer", "description": "Number of comments to return (max 20)", "default": 20}
                },
                "required": ["video_id"]
            }
        }
    }
]

# --- Data API call: POST /v1/api/<endpoint>, read outputs[0].data ---
def call_data_api(endpoint: str, params: dict) -> dict:
    """Rate-limited call with retry/backoff on network errors, HTTP 429 and 5xx."""
    last_error = None
    for attempt in range(MAX_RETRIES):
        limiter.wait()
        try:
            resp = requests.post(f"{API_BASE}/{endpoint}", headers=HEADERS, json=params, timeout=90)
        except (requests.ConnectionError, requests.Timeout) as e:
            last_error = e
        else:
            if resp.status_code != 429 and resp.status_code < 500:
                resp.raise_for_status()  # other 4xx (bad params, bad key): retrying won't help
                body = resp.json()
                outputs = body.get("outputs") or []
                if body.get("status") != "completed" or not outputs:
                    raise RuntimeError(f"SandBase call did not complete: {body.get('status')} {body.get('error')}")
                return outputs[0].get("data") or {}
            last_error = RuntimeError(f"HTTP {resp.status_code} from {endpoint}")
        if attempt < MAX_RETRIES - 1:
            time.sleep(2 ** attempt)  # 1s, 2s
    raise RuntimeError(f"{endpoint} failed after {MAX_RETRIES} attempts: {last_error}")

def execute_tool(tool_name: str, arguments: dict) -> str:
    """Route a tool call to its SandBase endpoint.
    Raw responses are large, so each tool returns a compact list for the LLM."""
    count = min(int(arguments.get("count", 10)), 20)
    endpoint = TOOL_ENDPOINTS.get(tool_name)
    if endpoint is None:
        return json.dumps({"error": f"Unknown tool: {tool_name}"})

    if tool_name == "search_douyin_content":
        data = call_data_api(endpoint, {"keyword": arguments["keyword"], "cursor": 0, "sort_type": "0"})
        items = []
        for card in data.get("business_data") or []:
            aweme = (card.get("data") or {}).get("aweme_info")
            if not aweme:
                continue  # skip related-search cards etc.
            stats = aweme.get("statistics") or {}
            items.append({
                "video_id": aweme.get("aweme_id"),
                "desc": aweme.get("desc"),
                "author": (aweme.get("author") or {}).get("nickname"),
                "likes": stats.get("digg_count"),
                "comments": stats.get("comment_count"),
                "shares": stats.get("share_count"),
            })
        return json.dumps(items[:count], ensure_ascii=False)

    if tool_name == "search_weibo_topics":
        data = call_data_api(endpoint, {"query": arguments["keyword"], "page": 1})
        posts = []
        for p in (data.get("parsed_data") or {}).get("results") or []:
            inter = p.get("interaction") or {}
            posts.append({
                "weibo_id": p.get("weibo_id"),
                "user": p.get("user_name"),
                "content": p.get("content"),
                "publish_time": p.get("publish_time"),
                "reposts": inter.get("repost_count"),
                "comments": inter.get("comment_count"),
                "likes": inter.get("like_count"),
            })
        return json.dumps(posts[:count], ensure_ascii=False)

    # get_douyin_video_comments
    data = call_data_api(endpoint, {"aweme_id": arguments["video_id"], "cursor": 0, "count": 20})
    comments = [
        {"text": c.get("text"), "likes": c.get("digg_count"), "replies": c.get("reply_comment_total")}
        for c in data.get("comments") or []
    ]
    return json.dumps(comments[:count], ensure_ascii=False)

# --- Agent Loop ---
def run_monitoring_cycle(keywords: list[str]) -> list[Alert]:
    """Run one monitoring cycle: search, analyze, alert."""
    alerts = []

    for keyword in keywords:
        # Step 1: Ask the agent to monitor this keyword
        messages = [
            {
                "role": "system",
                "content": """You are a social media monitoring agent. For each keyword:
1. Search both Douyin and Weibo for recent mentions
2. Analyze the sentiment of the results
3. Identify any urgent items (complaints, crises, viral negative content)
4. Produce a structured assessment

Be concise. Focus on actionable signals, not summaries of neutral content.
If engagement is unusually high on negative content, flag it immediately."""
            },
            {
                "role": "user",
                "content": f"Monitor keyword: '{keyword}'. Search both platforms and provide sentiment analysis."
            }
        ]

        # Step 2: Agent reasons and calls tools (both loops are capped)
        data_calls = 0
        for _ in range(MAX_ITERATIONS):
            response = client.chat.completions.create(
                model="openai/gpt-4.1-mini",
                messages=messages,
                tools=tools,
                tool_choice="auto"
            )
            message = response.choices[0].message
            messages.append(message)

            # If no tool calls, agent is done reasoning
            if not message.tool_calls:
                break

            # Every tool call needs a tool message, even when it is refused or fails
            for tool_call in message.tool_calls:
                if data_calls >= MAX_DATA_CALLS_PER_KEYWORD:
                    result = json.dumps({"error": "data call budget for this keyword is used up; answer with what you have"})
                else:
                    data_calls += 1
                    try:
                        arguments = json.loads(tool_call.function.arguments)
                        result = execute_tool(tool_call.function.name, arguments)
                    except Exception as e:
                        result = json.dumps({"error": str(e), "tool": tool_call.function.name})
                messages.append({
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "content": result
                })

        # Step 3: Extract structured assessment
        assessment_prompt = messages + [{
            "role": "user",
            "content": """Based on your analysis, provide a JSON response with:
{
    "keyword": "the keyword",
    "platforms_checked": ["douyin", "weibo"],
    "total_mentions": <number of results you reviewed>,
    "sentiment": {"positive": <pct>, "neutral": <pct>, "negative": <pct>},
    "alert_needed": true/false,
    "alert_severity": "low|medium|high|critical",
    "alert_reason": "why an alert is needed (or empty)",
    "urgent_items": ["list of specific concerning items"],
    "summary": "one-paragraph summary"
}
Only output the JSON, no markdown."""
        }]

        assessment_response = client.chat.completions.create(
            model="openai/gpt-4.1-mini",
            messages=assessment_prompt
        )

        try:
            assessment = json.loads(assessment_response.choices[0].message.content)
        except json.JSONDecodeError:
            print(f"? {keyword}: assessment was not valid JSON, skipped")
            continue

        # Step 4: Alert if the LLM flags it OR the reported negative share is over the threshold
        try:
            negative_pct = float((assessment.get("sentiment") or {}).get("negative") or 0)
        except (TypeError, ValueError):
            negative_pct = 0.0
        over_threshold = negative_pct > ALERT_THRESHOLD_NEGATIVE

        if assessment.get("alert_needed") or over_threshold:
            reason = assessment.get("alert_reason") or (
                f"{negative_pct:.0f}% negative (threshold {ALERT_THRESHOLD_NEGATIVE}%)")
            alert = Alert(
                timestamp=datetime.now().isoformat(),
                severity=assessment.get("alert_severity") or "medium",
                keyword=keyword,
                platform=", ".join(assessment.get("platforms_checked") or ["douyin", "weibo"]),
                reason=reason,
                details=assessment.get("summary", "")
            )
            alerts.append(alert)
            print(f"🚨 ALERT [{alert.severity.upper()}] — {alert.keyword}: {alert.reason}")
        else:
            print(f"✓ {keyword}: Normal ({assessment.get('total_mentions', 0)} results reviewed, "
                  f"{negative_pct:.0f}% negative, {data_calls} data calls)")

    return alerts

# --- Alert Delivery ---
def deliver_alerts(alerts: list[Alert]):
    """Print alerts. To push them to Slack, POST {"text": ...} to an incoming webhook URL."""
    for alert in alerts:
        if alert.severity in ("high", "critical"):
            print(f"\n{'='*60}")
            print(f"🔴 {alert.severity.upper()} ALERT — {alert.timestamp}")
            print(f"   Keyword: {alert.keyword}")
            print(f"   Platform: {alert.platform}")
            print(f"   Reason: {alert.reason}")
            print(f"   Details: {alert.details}")
            print(f"{'='*60}\n")
        else:
            # Low-priority: log for daily digest
            print(f"📋 [{alert.severity}] {alert.keyword}: {alert.reason}")

# --- Main Loop ---
def main():
    """Run the monitoring agent on a schedule."""
    print("🔍 Social Monitor Agent started")
    print(f"   Keywords: {MONITOR_KEYWORDS}")
    print(f"   Interval: {CHECK_INTERVAL_SECONDS}s")
    print(f"   Alert threshold: >{ALERT_THRESHOLD_NEGATIVE}% negative (LLM-reported)")
    print()

    while True:
        print(f"--- Cycle start: {datetime.now().isoformat()} ---")
        alerts = run_monitoring_cycle(MONITOR_KEYWORDS)

        if alerts:
            deliver_alerts(alerts)
        else:
            print("No alerts this cycle.")

        print(f"--- Next cycle in {CHECK_INTERVAL_SECONDS}s ---\n")
        time.sleep(CHECK_INTERVAL_SECONDS)

if __name__ == "__main__":
    main()

How the pattern works

Data APIs use direct HTTP and LLM calls use the OpenAI SDK:

# LLM reasoning — OpenAI SDK (chat completions)
client.chat.completions.create(model="openai/gpt-4.1-mini", messages=...)

# Data retrieval — one route per endpoint, body = only that endpoint's params
requests.post("https://api.sandbase.ai/v1/api/douyin/search/general-search-v2",
    headers=HEADERS,
    json={"keyword": "...", "cursor": 0, "sort_type": "0"})

SandBase’s data APIs are not LLMs: they don’t take messages or return chat completions. TOOL_ENDPOINTS maps each tool to its endpoint, and call_data_api posts only that endpoint’s parameters. The endpoint reference documents the completed response as {id, status, model, outputs: [{data: ...}]}, so the helper reads outputs[0].data and raises an error with status and error when the call did not complete. These are the Model API POST routes from the endpoint reference, not the GET routes shown on the catalog pages.

Neither search endpoint takes a result count, so the tools slice locally. The agent only reads the first page. Pagination differs per endpoint: Douyin general search pages by sending back cursor, search_id and backtrace from the previous response; Weibo realtime search takes a page number; video comments take cursor.

The two other references show those parameters directly: Weibo realtime search lists query (required) and page, and video comments lists aweme_id (required), count (default 20) and cursor (default 0).

SandBase endpoint reference for weibo/web-v2/realtime-search showing the POST route with page and required query fields

Caption: The weibo/web-v2/realtime-search reference, with query required and page defaulting to 1, matching the {"query": ..., "page": 1} body the agent sends (captured 2026-10-02).

SandBase endpoint reference for douyin/web/video-comments showing the POST route with aweme_id, count and cursor fields

Caption: The douyin/web/video-comments reference, showing required aweme_id plus count (default 20) and cursor (default 0), the three fields get_douyin_video_comments sends (captured 2026-10-02).

Every call_data_api call waits on the RateLimiter (30 calls per 60 seconds here; that number is your own pacing, not a SandBase quota) and retries network errors, HTTP 429 and 5xx with 1s/2s backoff. Tool errors are passed back to the LLM as {"error": ...} so one failed call doesn’t kill the cycle.

Tested on 2026-10-01 (UTC)

I ran the full agent code above with the LLM stubbed and every data call live, using the brand keyword 瑞幸咖啡 (Luckin Coffee). The business field names the tools read come from these calls. They are observed-only, not documented guarantees, which is why every read uses .get(). Creator names and post text are replaced with <redacted> below; the numbers are as returned.

POST https://api.sandbase.ai/v1/api/douyin/search/general-search-v2
{"keyword": "瑞幸咖啡", "cursor": 0, "sort_type": "0"}

{"id": "a7e4f79e-0231-4a05-821f-7db4d4799a76", "status": "completed", "model": "douyin/search/general-search-v2",
 "outputs": [{"data": {"business_data": [{"data": {"aweme_info": {
   "aweme_id": "<redacted>", "desc": "<redacted> #瑞幸 ...",
   "author": {"nickname": "<redacted>"},
   "statistics": {"digg_count": 943163, "comment_count": 12982, "share_count": 215170, ...}}}}, ...]}}]}
POST https://api.sandbase.ai/v1/api/weibo/web-v2/realtime-search
{"query": "瑞幸咖啡", "page": 1}

{"id": "0c118fd2-0dda-4b11-9cad-b2cfe1cccefe", "status": "completed", ...
 "outputs": [{"data": {"parsed_data": {"results": [{
   "weibo_id": "<redacted>", "user_name": "<redacted>", "content": "<redacted>",
   "publish_time": "2分钟前", "interaction": {"comment_count": 0, "like_count": 0, "repost_count": 0}}, ...]}}}]}
POST https://api.sandbase.ai/v1/api/douyin/web/video-comments
{"aweme_id": "<video_id from the search above>", "cursor": 0, "count": 20}

{"id": "a0ed3f3d-0c2f-412b-9159-9bfc91569a5e", "status": "completed", ...
 "outputs": [{"data": {"comments": [{"text": "<redacted>", "digg_count": 18, "reply_comment_total": 2}, ...]}}]}

In that cycle the tools got 17 videos, 9 Weibo posts and 19 comments before slicing. Across 28 data calls in my two test runs (EN and ZH code), two returned HTTP 503 and the retries succeeded. Raw Douyin search payloads in these runs were close to 1 MB, so the tools return a compact list instead of dumping the whole response into the LLM context.

Cost breakdown

Data API calls: the three endpoints are listed as Free in the SandBase catalog as of 2026-10-01, so the data side is counted in calls. LLM: openai/gpt-4.1-mini is $0.40 per million input tokens and $1.60 per million output tokens on SandBase as of 2026-10-01.

Call counts per keyword are bounded by the code: 1 to MAX_ITERATIONS (4) tool-use LLM calls, 1 assessment call, and 0 to MAX_DATA_CALLS_PER_KEYWORD (4) data calls. The typical path is: search both platforms, fetch comments for one video, answer — 3 tool-use calls, 1 assessment and 3 data calls.

Estimate with illustrative token assumptions (tool-use call ~2K input + 500 output; assessment ~1K input + 200 output):

Tool-use call:   2,000 × $0.40/M + 500 × $1.60/M = $0.0008 + $0.0008   = $0.0016
Assessment call: 1,000 × $0.40/M + 200 × $1.60/M = $0.0004 + $0.00032  = $0.00072

Per keyword (typical, 3 tool-use + 1 assessment): 3 × $0.0016 + $0.00072 = $0.00552
Per keyword (upper bound, 4 + 1):                 4 × $0.0016 + $0.00072 = $0.00712

Per cycle, 3 keywords:  typical $0.01656   upper bound $0.02136
Data calls per cycle:   typical 9          upper bound 12

Running every 15 minutes (96 cycles a day, 30-day month):

Typical:      $0.01656 × 96 = $1.59/day, × 30 ≈ $47.69/month; 864 data calls/day
Upper bound:  $0.02136 × 96 = $2.05/day, × 30 ≈ $61.52/month; 1,152 data calls/day

These are estimates, not measurements. Real input tokens grow as tool results pile up in messages, and the assessment call resends the whole conversation, so log response.usage for a few days and redo the math with your numbers.

Extending the agent

Add more platforms

# Add Xiaohongshu monitoring: add to TOOL_ENDPOINTS
#   "search_xiaohongshu_notes": "xiaohongshu/app-v2/search-notes"
# and route it in execute_tool with {"keyword": ..., "page": 1}
{
    "type": "function",
    "function": {
        "name": "search_xiaohongshu_notes",
        "description": "Search Xiaohongshu for notes mentioning a keyword.",
        "parameters": {
            "type": "object",
            "properties": {
                "keyword": {"type": "string"},
                "count": {"type": "integer", "default": 10}
            },
            "required": ["keyword"]
        }
    }
}

In a 2026-10-01 call to xiaohongshu/app-v2/search-notes with {"keyword": "瑞幸咖啡", "page": 1}, 20 notes came back under data.items[] (run 1a422dd3-d11a-446b-a0ea-0ba15c3a2a9c, observed-only). Remember to raise MAX_DATA_CALLS_PER_KEYWORD when you add a tool.

Add trend comparison

The agent above is stateless per cycle. Spike detection needs two things it doesn’t have: a volume signal that isn’t fixed by page size (for example, the number of Weibo weibo_ids you haven’t seen before), and history persisted across restarts. A starting point, not wired into the loop:

# In-memory history; persist it (SQLite, JSON file) for real use
class TrendTracker:
    def __init__(self):
        self.history = {}  # keyword -> [(timestamp, new_posts)]

    def record(self, keyword: str, new_posts: int):
        self.history.setdefault(keyword, []).append((time.time(), new_posts))

    def detect_spike(self, keyword: str, window_hours: int = 24) -> bool:
        """True if the latest count is 3x the average over the window."""
        cutoff = time.time() - (window_hours * 3600)
        recent = [n for t, n in self.history.get(keyword, []) if t > cutoff]
        if len(recent) < 10:
            return False
        avg = sum(recent[:-1]) / len(recent[:-1])
        return avg > 0 and recent[-1] > avg * 3

Add automated response drafting

def draft_response(alert: Alert) -> str:
    """Use the LLM to draft a response for a human to review."""
    response = client.chat.completions.create(
        model="openai/gpt-4.1-mini",
        messages=[
            {"role": "system", "content": "You are a brand communications specialist. "
             "Draft a brief, empathetic response to the following social media issue. "
             "Keep it professional, acknowledge the concern, and offer next steps."},
            {"role": "user", "content": f"Issue: {alert.reason}\nDetails: {alert.details}\n"
             "Draft a response suitable for posting on social media."}
        ]
    )
    return response.choices[0].message.content

The data APIs are read-only, so posting the draft is up to your team through the platform’s own tools.

Key takeaways

  1. Two clients, one API key. requests for SandBase data APIs at POST /v1/api/<endpoint>, the OpenAI SDK for LLM reasoning, both authenticated with the same key.
  2. Tool-calling is the integration pattern. Map each tool to one endpoint, let the LLM decide when to call it, and cap both the LLM loop and the data calls.
  3. Read only outputs[0].data, and fail loudly. Raise when a call doesn’t complete instead of silently returning {}.
  4. LLM calls are the dollar cost. The data endpoints are listed as Free as of 2026-10-01; log token usage and tune the LLM layer first.
  5. Sync APIs keep the loop simple. Because social data APIs are sync-only, there are no callbacks or task management. The response schema also lists pending and running states; this agent treats anything other than completed as an error rather than polling.

Get a SandBase API key · Browse the Douyin catalog · Browse the Weibo catalog