# Building an AI Agent for Social Media Monitoring (https://www.socialcrawl.dev/blog/building-ai-agent-social-media-monitoring)
> Learn to build an AI agent for social media monitoring in under 150 lines of Python. Step-by-step tutorial: fetch posts, classify sentiment, send alerts.
A working AI social media monitoring agent fits in under 150 lines of Python. This tutorial walks from zero to a running agent that uses Claude's tool-calling API to reason over posts and the [SocialCrawl unified API](https://www.socialcrawl.dev/v1) to fetch brand mentions from Instagram and TikTok across 43 platforms with a single auth header, then classifies sentiment and fires a Slack alert when it finds something worth escalating.
There are now 5.66 billion active social media identities worldwide, and the average person uses 6.83 platforms every month [(DataReportal Digital 2026)](https://datareportal.com/reports/digital-2026-two-in-three-people-use-social-media). Monitoring 43 platforms manually means 42 different JSON schemas, 42 auth flows, and 42 rate-limit policies. One API key, one normalized schema, one agent loop fixes that.
A working AI social media monitoring agent fits in under 150 lines of Python:
Claude's tool-calling API decides which tool to run next, the SocialCrawl
unified API fetches brand mentions through `GET /v1/instagram/hashtag` and
`GET /v1/tiktok/search`, and a Slack webhook delivers the alert. Both
endpoints share one `x-api-key` header and return the same normalized schema —
with `engagement_rate`, `estimated_reach`, and `language` pre-computed — so
the agent reads fields instead of doing arithmetic.
> "Agents are systems where LLMs dynamically direct their own processes and tool usage, maintaining control over how they accomplish tasks." — Anthropic Research, [Building Effective Agents](https://www.anthropic.com/research/building-effective-agents)
**Stack:** Python 3.11+ · Anthropic SDK (Claude) · SocialCrawl REST API · `httpx` · `python-dotenv`
---
## Prerequisites
Before running any code, you need:
- Python 3.11+
- An **Anthropic API key** from [console.anthropic.com](https://console.anthropic.com)
- A **SocialCrawl API key** from the [SocialCrawl docs](/docs) (free tier includes 100 credits)
- A **Slack webhook URL** for alerts (optional but recommended)
Install dependencies:
```bash
pip install anthropic httpx python-dotenv
```
Create a `.env` file:
```bash
ANTHROPIC_API_KEY=sk-ant-...
SOCIALCRAWL_API_KEY=sc-...
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
```
---
## How Does the Agent Architecture Work?
The agent is a `while` loop where Claude decides which tool to call next based on the result of the previous call. [Anthropic's engineering team distinguishes](https://www.anthropic.com/research/building-effective-agents) **workflows** (predefined code paths where LLMs are orchestrated through fixed steps) from **agents** (the LLM directs its own tool usage, choosing what to do and when to stop). This tutorial builds the latter — the orchestration logic lives inside Claude, not in Python. If you're new to Claude's agent surface, our [guide to Claude agents](/blog/how-to-use-claude-agents) covers Managed Agents and sub-agents; for the wider ecosystem, see the [AI agent frameworks field guide](/blog/ai-agent-frameworks-2026-developer-field-guide).
| Dimension | Workflow | Agent |
| --------------------- | ----------------------------- | ---------------------------------------- |
| Control flow | Predefined in code | LLM decides at runtime |
| Predictability | High | Lower (but recoverable) |
| Best for | Narrow, repeatable tasks | Open-ended tasks with branching |
| Iteration guard | Implicit (fixed steps) | Explicit (`max_iterations`) |
| Social monitoring fit | Poor — each platform branches | Excellent — LLM routes posts dynamically |
The loop:
```text
System prompt + tool definitions (cache-anchored)
|
Claude reasons
|
stop_reason: "tool_use"
→ fetch_posts(platform="instagram", query="yourbrand")
|
SocialCrawl API
→ [{id, content, engagement_rate, estimated_reach, platform, author}]
|
tool_result appended to messages
|
Claude reasons again
→ stop_reason: "tool_use"
→ classify_post(post={...})
|
→ stop_reason: "tool_use"
→ send_alert(post={...}, classification={...})
|
stop_reason: "end_turn"
```
SocialCrawl normalizes every platform to the same schema (`content`, `engagement_rate`, `platform`, `author`, `timestamp`), so the system prompt needs zero platform-specific logic. Instagram and TikTok look identical to the reasoning loop.
Three tool definitions, each with `strict: True` which [guarantees calls always match the schema](https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview):
```python
from dotenv import load_dotenv
load_dotenv()
SOCIALCRAWL_BASE_URL = "https://www.socialcrawl.dev/v1"
SOCIALCRAWL_API_KEY = os.environ["SOCIALCRAWL_API_KEY"]
ANTHROPIC_API_KEY = os.environ["ANTHROPIC_API_KEY"]
SLACK_WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL", "")
# Tool definitions — cache-anchored (stable, passed as system-level content)
TOOLS = [
{
"name": "fetch_posts",
"description": (
"Fetch recent posts from a social media platform matching a search query. "
"Returns a list of posts with normalized fields: id, content, engagement_rate, "
"estimated_reach, platform, author, timestamp. "
"Use platform='instagram' or platform='tiktok'. "
"Example: fetch_posts(platform='instagram', query='yourbrand')"
),
"input_schema": {
"type": "object",
"properties": {
"platform": {
"type": "string",
"enum": ["instagram", "tiktok"],
"description": "The social platform to search.",
},
"query": {
"type": "string",
"description": "Hashtag or keyword to search for.",
},
},
"required": ["platform", "query"],
},
"strict": True,
},
{
"name": "classify_post",
"description": (
"Classify a single social media post for sentiment, urgency, and escalation. "
"Returns {sentiment, urgency, escalate, reason}. "
"Pass the full post dict from fetch_posts."
),
"input_schema": {
"type": "object",
"properties": {
"post": {
"type": "object",
"description": "A post dict with fields: id, content, engagement_rate, estimated_reach, platform, author.",
"properties": {
"id": {"type": "string"},
"content": {"type": "string"},
"engagement_rate": {"type": "number"},
"estimated_reach": {"type": "number"},
"platform": {"type": "string"},
"author": {"type": "string"},
},
"required": ["id", "content", "platform"],
}
},
"required": ["post"],
},
"strict": True,
},
{
"name": "send_alert",
"description": (
"Send a Slack alert for a post that requires attention. "
"Only call this when classify_post returns escalate=true."
),
"input_schema": {
"type": "object",
"properties": {
"post": {
"type": "object",
"description": "The post dict from fetch_posts.",
"properties": {
"id": {"type": "string"},
"content": {"type": "string"},
"engagement_rate": {"type": "number"},
"platform": {"type": "string"},
"author": {"type": "string"},
},
"required": ["id", "content", "platform", "author"],
},
"classification": {
"type": "object",
"description": "The classification dict from classify_post.",
"properties": {
"sentiment": {"type": "string"},
"urgency": {"type": "string"},
"escalate": {"type": "boolean"},
"reason": {"type": "string"},
},
"required": ["sentiment", "urgency", "escalate", "reason"],
},
},
"required": ["post", "classification"],
},
"strict": True,
},
]
```
---
## What API Endpoints Power the Agent?
Two SocialCrawl endpoints do all the data fetching, and both return a unified schema so the agent reads identical fields regardless of platform. The `fetch_posts` function wraps these [SocialCrawl](/docs/api-reference) endpoints:
- `GET /v1/instagram/hashtag?tag=` ([Instagram docs](/platforms/instagram))
- `GET /v1/tiktok/search?q=` ([TikTok docs](/platforms/tiktok))
Both use the same `x-api-key` header. The response already includes pre-computed `engagement_rate`, `estimated_reach`, and `language`, so the agent reads a field rather than doing arithmetic.
```python
def fetch_posts(platform: str, query: str) -> list[dict]:
"""
Fetch posts from SocialCrawl's unified API.
Returns a list of normalized post dicts.
"""
endpoint_map = {
"instagram": f"{SOCIALCRAWL_BASE_URL}/instagram/hashtag",
"tiktok": f"{SOCIALCRAWL_BASE_URL}/tiktok/search",
}
param_map = {
"instagram": {"tag": query},
"tiktok": {"q": query},
}
url = endpoint_map[platform]
params = param_map[platform]
headers = {"x-api-key": SOCIALCRAWL_API_KEY}
for attempt in range(4):
try:
response = httpx.get(url, params=params, headers=headers, timeout=15.0)
response.raise_for_status()
data = response.json()
posts = data.get("data", data.get("posts", []))
return [
{
"id": p.get("id", ""),
"content": p.get("content", p.get("caption", p.get("text", ""))),
"engagement_rate": p.get("engagement_rate", 0.0),
"estimated_reach": p.get("estimated_reach", 0),
"platform": platform,
"author": p.get("author", p.get("username", "unknown")),
"timestamp": p.get("timestamp", p.get("created_at", "")),
}
for p in posts
]
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
# Exponential backoff with jitter for rate limiting
wait = 2 ** attempt + random.random()
time.sleep(wait)
else:
raise
except httpx.RequestError:
if attempt == 3:
raise
time.sleep(1.5)
return []
```
---
## How Do AI Agents Classify Sentiment?
AI agents classify sentiment by passing each post through a focused LLM call that returns structured JSON with sentiment, urgency, and escalation fields. `classify_post` makes a direct Claude API call with a tight system prompt. Because `engagement_rate` and `estimated_reach` are already in the post payload, the prompt reasons over three fields only: sentiment, urgency, escalate. One inference call, no extra enrichment step.
Transformer-based classifiers reach up to 94% accuracy on social media sentiment benchmarks, substantially higher than traditional lexicon-based tools like VADER on informal or emoji-heavy text [(ACL Anthology, 2024)](https://aclanthology.org/2024.wassa-1.27/).
```python
CLASSIFICATION_SYSTEM_PROMPT = (
"You are a social media brand monitoring analyst. "
"Given a social media post with pre-computed engagement metrics, classify it. "
"Return ONLY a JSON object with these exact fields:\n"
" sentiment: 'positive' | 'negative' | 'neutral'\n"
" urgency: 'immediate' | 'routine' | 'ignore'\n"
" escalate: true | false\n"
" reason: one sentence explaining your decision\n\n"
"Escalate if: sentiment is negative AND (urgency is immediate OR engagement_rate > 0.05). "
"Urgency is 'immediate' if the post requires brand response within 2 hours. "
"Do not include any text outside the JSON object."
)
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
def classify_post(post: dict) -> dict:
"""
Classify a post's sentiment, urgency, and escalation priority.
Uses a focused Claude call, not the full agent loop.
"""
post_summary = (
f"Platform: {post['platform']}\n"
f"Author: {post['author']}\n"
f"Content: {post['content'][:500]}\n"
f"Engagement rate: {post.get('engagement_rate', 0):.4f}\n"
f"Estimated reach: {post.get('estimated_reach', 0):,}"
)
response = client.messages.create(
model="claude-haiku-4-5", # Fast + cost-efficient for high-volume classification
max_tokens=256,
system=CLASSIFICATION_SYSTEM_PROMPT,
messages=[{"role": "user", "content": post_summary}],
)
raw = response.content[0].text.strip()
try:
return json.loads(raw)
except json.JSONDecodeError:
# Fallback if the model adds preamble
start = raw.find("{")
end = raw.rfind("}") + 1
return json.loads(raw[start:end])
```
---
## How Does the Monitoring Loop Work?
The monitoring loop is a `while` loop that keeps calling Claude until it emits `stop_reason == "end_turn"` or hits a `max_iterations = 10` ceiling. Two things worth calling out: a `seen_ids` set deduplicates posts within each polling cycle, and the system prompt is cache-anchored so repeated runs don't pay full input token costs. Prompt caching reduces input token cost by up to 90% and latency by up to 85% for cached prefixes [(Anthropic Prompt Caching docs)](https://docs.claude.com/en/docs/build-with-claude/prompt-caching). [Anthropic recommends the iteration guard](https://www.anthropic.com/research/building-effective-agents) for all production agents.
```python
AGENT_SYSTEM_PROMPT = (
"You are a social media monitoring agent for brand reputation management. "
"Your job:\n"
"1. Use fetch_posts to retrieve recent mentions for each keyword on each platform.\n"
"2. Use classify_post on each post to determine sentiment and urgency.\n"
"3. Use send_alert for any post where classify_post returns escalate=true.\n"
"4. After processing all posts, stop (do not loop indefinitely).\n\n"
"Be systematic: process one platform+keyword combination at a time. "
"Do not classify the same post twice."
)
def run_monitoring_agent(keywords: list[str], platforms: list[str]) -> dict:
"""
Run one monitoring cycle. Returns a summary dict with counts.
"""
seen_ids: set[str] = set()
alerts_fired = 0
posts_processed = 0
max_iterations = 10
iteration = 0
# Build the initial user message listing what to monitor
task_description = (
f"Monitor these keywords: {', '.join(keywords)}\n"
f"On these platforms: {', '.join(platforms)}\n"
"Fetch posts, classify each one, and alert on anything that needs escalation."
)
messages = [{"role": "user", "content": task_description}]
# Tool dispatch table
tool_dispatch = {
"fetch_posts": lambda inp: fetch_posts(**inp),
"classify_post": lambda inp: classify_post(inp["post"]),
"send_alert": lambda inp: send_alert(inp["post"], inp["classification"]),
}
while iteration < max_iterations:
iteration += 1
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=4096,
system=[
{
"type": "text",
"text": AGENT_SYSTEM_PROMPT,
# Cache the system prompt — it never changes between calls
"cache_control": {"type": "ephemeral"},
}
],
tools=TOOLS,
# Cache the tool definitions — stable across the entire loop
# Prompt caching reduces cost on repeated polling cycles
messages=messages,
)
if response.stop_reason == "end_turn":
break
if response.stop_reason != "tool_use":
# Unexpected stop reason — bail safely
break
# Append the full assistant turn (required for tool_use blocks to be valid)
messages.append({"role": "assistant", "content": response.content})
# Execute all tool calls in this turn
tool_results = []
for block in response.content:
if block.type != "tool_use":
continue
tool_name = block.name
tool_input = block.input
tool_use_id = block.id
# Deduplication: skip posts we've already seen this cycle
if tool_name == "fetch_posts":
raw_posts = tool_dispatch[tool_name](tool_input)
new_posts = [p for p in raw_posts if p["id"] not in seen_ids]
for p in new_posts:
seen_ids.add(p["id"])
posts_processed += len(new_posts)
result_content = json.dumps(new_posts)
elif tool_name == "send_alert":
tool_dispatch[tool_name](tool_input)
alerts_fired += 1
result_content = json.dumps({"status": "alert_sent"})
else:
result = tool_dispatch[tool_name](tool_input)
result_content = json.dumps(result)
tool_results.append(
{
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": result_content,
}
)
messages.append({"role": "user", "content": tool_results})
return {
"posts_processed": posts_processed,
"alerts_fired": alerts_fired,
"iterations": iteration,
}
```
---
## How Do You Send Alerts and Run the Agent on a Schedule?
Alerts go out through a Slack webhook, and scheduling is a one-liner with the `schedule` library or APScheduler. `send_alert` closes the loop: the Slack payload includes platform, author, content, sentiment, urgency, and engagement rate, so the recipient has enough context to act without opening a separate dashboard.
```python
def send_alert(post: dict, classification: dict) -> dict:
"""
Post a Slack alert for an escalated mention.
Returns {"status": "sent"} on success.
"""
if not SLACK_WEBHOOK_URL:
print(f"[ALERT] {post['platform']} | {post['author']} | "
f"{classification['sentiment'].upper()} | {post['content'][:120]}")
return {"status": "printed_to_stdout"}
message = {
"text": (
f":rotating_light: *Brand mention requires attention*\n"
f"*Platform:* {post['platform'].capitalize()}\n"
f"*Author:* @{post['author']}\n"
f"*Sentiment:* {classification['sentiment']} | "
f"*Urgency:* {classification['urgency']}\n"
f"*Reason:* {classification['reason']}\n"
f"*Engagement rate:* {post.get('engagement_rate', 0):.2%}\n"
f"*Content:* {post['content'][:200]}"
)
}
response = httpx.post(SLACK_WEBHOOK_URL, json=message, timeout=10.0)
response.raise_for_status()
return {"status": "sent"}
# Entry point
if __name__ == "__main__":
result = run_monitoring_agent(
keywords=["yourbrand", "yourproduct"],
platforms=["instagram", "tiktok"],
)
print(f"Done. Posts processed: {result['posts_processed']}, "
f"alerts fired: {result['alerts_fired']}, "
f"loop iterations: {result['iterations']}")
```
To run on a schedule, wrap the call in a simple polling loop or use `APScheduler`:
```python
schedule.every(5).minutes.do(
run_monitoring_agent,
keywords=["yourbrand"],
platforms=["instagram", "tiktok"],
)
while True:
schedule.run_pending()
time.sleep(30)
```
Five-minute intervals work for most cases. For crisis-sensitive categories, drop to 1-2 minutes and batch classification calls to keep inference costs flat.
---
## What Could Go Wrong?
Six failure modes are worth planning for before running this in production, with concrete runtime numbers so you can size your budget and retries.
1. **Inference cost drift.** A single polling cycle against two platforms and two keywords uses roughly 1,500-2,500 input tokens (system prompt + tool defs + conversation) and 400-800 output tokens per Claude turn. Claude Haiku 4.5 is priced at $1 per million input tokens and $5 per million output tokens [(Anthropic Pricing)](https://www.anthropic.com/pricing). With prompt caching enabled, expect under $0.01 per polling cycle — roughly $2-$4 per day at 5-minute intervals.
2. **`429` from SocialCrawl.** Handled in `fetch_posts` with exponential backoff (2s, 4s, 8s + jitter, 4 attempts). For a decorator approach: `pip install tenacity` and `@retry(wait=wait_exponential(multiplier=1, min=2, max=30), stop=stop_after_attempt(4))`. SocialCrawl doesn't impose per-second rate limits — throttling happens at the credit layer, so `429` almost always means an upstream platform limit has been hit.
3. **Agent loops forever.** The `max_iterations = 10` guard prevents runaway inference. If you hit it consistently, verify `send_alert` is returning a valid `tool_result` with content. Anthropic's guidance is explicit: always set an iteration ceiling and log the final `stop_reason` [(Building Effective Agents)](https://www.anthropic.com/research/building-effective-agents).
4. **Duplicate alerts on restart.** `seen_ids` resets in memory. Persist to Redis (`SADD seen_posts {id}`) or SQLite (`INSERT OR IGNORE INTO seen_posts VALUES (?)`) and restore at startup. For a 7-day retention window, SQLite with a `created_at` column and a nightly `DELETE WHERE created_at < datetime('now', '-7 days')` is usually sufficient.
5. **Empty results.** Query too narrow, or the platform uses hashtag matching. For Instagram, omit the `#`. See the [SocialCrawl API reference](/docs/api-reference) for per-platform syntax.
6. **Slow classification at volume.** Batch up to 10 posts per call by changing `post: object` to `posts: array` in the tool schema and returning a JSON array. Typical single-post classification runs ~400ms round-trip; a 10-post batch completes in ~600ms — a 6-7x throughput win on classification-heavy workloads.
---
## What's Next?
This agent covers Instagram and TikTok. Adding [YouTube](/platforms/youtube) or Reddit is a one-line change to `endpoint_map` inside `fetch_posts`. The reasoning loop, system prompt, and tool definitions stay identical. That's the direct payoff of a unified schema.
Before wiring up the agent, paste any social URL into the [SocialCrawl Data Explorer](/explorer) and see your data before writing a single line of code. It confirms that `engagement_rate` and `estimated_reach` are populated for your target accounts.
If you need a deeper look at Instagram's current API landscape — rate limits, what broke in 2024, and which scraping options are legally defensible — read [Instagram API & Scrapers in 2026: What Still Works](/blog/instagram-scraping-2026).
For the full endpoint list, rate limits, and credit costs, see the [SocialCrawl API docs](/docs).
---
## Frequently Asked Questions
### What's the difference between social media monitoring and social listening?
Social media monitoring is reactive: you track specific mentions, keywords, and tags as they occur. Social listening is the broader layer, analyzing patterns to extract intent and sentiment trends. This agent does both. `fetch_posts` handles monitoring (raw mentions), and `classify_post` handles listening (structured meaning from each post).
### How do AI agents handle multiple social media platforms?
Without a unified API, each platform needs its own tool function, auth flow, and schema mapping. With SocialCrawl's unified schema, one `fetch_posts` tool covers all 43 platforms using the same fields, the same `x-api-key` header, and the same response shape. The reasoning loop never needs to know which platform it's reading, so adding platforms requires zero changes to agent logic.
### What are the most common mistakes when building social monitoring agents?
Five issues that kill production agents: (1) platform-specific parsing that breaks on schema changes; (2) no deduplication, causing repeated alerts; (3) missing rate-limit handling, so `429` errors silently kill the loop; (4) no iteration guard, risking runaway inference costs; (5) feeding raw unstructured data to the LLM instead of pre-normalized fields, which inflates token usage and hurts classification accuracy.
### How much does it cost to run this AI monitoring agent?
The two variable costs are inference and data. For classification, `claude-haiku-4-5` processes roughly 500 posts per dollar at typical post lengths. SocialCrawl charges 1 credit per Standard endpoint call on the free tier (100 credits included); each `fetch_posts` call to Instagram hashtag or TikTok search consumes 1 credit and returns up to 20 posts. A 5-minute polling loop monitoring two platforms and two keywords runs fewer than 30 credits per hour at normal cadence.