# Pydantic AI Tools: Validate Real API Data, Not Toy JSON (https://www.socialcrawl.dev/blog/pydantic-ai-agents)
> See real, validated JSON from a Pydantic AI agent's tools: null engagement rates, optional fields, and per-source shapes captured from a live social data API.
A Pydantic AI agent's tools are only as trustworthy as the code inside them. Pydantic validates a tool's arguments against your function signature automatically.
It does nothing to check what a tool returns. The moment that tool calls a real API, you're on your own.
This post builds a Pydantic AI agent whose tools call SocialCrawl, a live social data API, and validates every response against a typed model: `pydantic-ai>=2.31,<3`, tested against 2.31.1 on 19/08/2026.
Five real calls, five real JSON payloads, including one field the API refused to publish because the number it computed wasn't a real engagement rate. Every response below is unedited, and every example is reproducible with your own key. No mock objects, no synthetic dicts, no toy JSON.
## What does Pydantic AI validate, and what doesn't it check?
Pydantic AI validates two different things, and conflating them is the fastest way to ship a tool that looks safe and isn't.
Tool **arguments** are checked automatically: "its arguments are validated against the function's signature using Pydantic. If validation fails... the framework automatically generates a `RetryPromptPart`... sent back to the LLM" ([docs](https://pydantic.dev/docs/ai/tools-advanced/)). A bad call from the model becomes a repair instruction, not a stack trace.
Tool **return values** are not checked against your type annotation. "Tools can return anything that Pydantic can serialize to JSON" ([docs](https://pydantic.dev/docs/ai/tools/)), and nothing stops that from being a malformed dict with the wrong keys in it. If you want the response an upstream API actually sent to be validated, the tool body has to call `Model.model_validate(payload)` itself. That's the argument this post exists to prove, five real calls at a time.
One freshness check while we're here: `output_type=` is correct on 2.31.x. `result_type=` and `result.data` were removed in v0.6.0 (06/08/2025) ([changelog](https://pydantic.dev/docs/ai/project/changelog/)). If a tutorial you're reading still shows either, it predates the current API by over a year.
Pydantic AI's distinctive combination is typed `deps_type`, `RunContext[T]`, and `output_type` on one agent object, all checkable statically before anything runs. For where that sits next to LangChain and LangGraph, see our [framework field guide](/blog/ai-agent-frameworks-2026-developer-field-guide) rather than re-litigating it here.
## How do you build a Pydantic AI agent that calls a live API?
```bash
pip install "pydantic-ai>=2.31,<3"
```
Tested against 2.31.1, 19/08/2026. `uv add pydantic-ai` works the same way if that's your tool of choice.
Every field on the model below exists somewhere in the two profile responses you're about to see. None of it is guessed ahead of time:
```python
from dataclasses import dataclass
from pydantic import BaseModel
from pydantic_ai import Agent, RunContext
class Author(BaseModel):
id: str
username: str
display_name: str
bio: str | None = None
verified: bool | None = None
followers: int
following: int | None = None
posts_count: int
likes_count: int | None = None
url: str | None = None
private: bool | None = None
joined_at: str | None = None
ext: dict | None = None
@dataclass
class SocialCrawlDeps:
api_key: str
agent = Agent(
"openai:gpt-5.2",
deps_type=SocialCrawlDeps,
output_type=str,
instructions="You look up creator profiles and describe what you find.",
)
@agent.tool
async def get_tiktok_profile(ctx: RunContext[SocialCrawlDeps], handle: str) -> Author:
async with httpx.AsyncClient() as client:
r = await client.get(
"https://www.socialcrawl.dev/v1/tiktok/profile",
params={"handle": handle},
headers={"x-api-key": ctx.deps.api_key},
)
r.raise_for_status()
payload = r.json()
return Author.model_validate(payload["data"]["author"])
```
The key comes in through `RunContext`, not a module-level constant. Set it once as `SOCIALCRAWL_API_KEY`, then run the agent with `agent.run_sync("Look up mrbeast on TikTok", deps=SocialCrawlDeps(api_key=os.environ["SOCIALCRAWL_API_KEY"]))`.
Here's what the SocialCrawl API actually returned for that call, on 19/08/2026:
```json
{
"success": true,
"platform": "tiktok",
"endpoint": "/v1/tiktok/profile",
"data": {
"author": {
"id": "6614519312189947909",
"username": "mrbeast",
"display_name": "MrBeast",
"bio": "Checkout My New Book!👇",
"verified": true,
"followers": 137654911,
"following": 354,
"posts_count": 467,
"likes_count": 1400000000,
"url": null,
"private": false,
"joined_at": null
},
"computed": {
"engagement_rate": null,
"language": null,
"content_category": "other",
"estimated_reach": null
},
"_warnings": [
"computed.engagement_rate: author ratio exceeded 1.0 (raw: 10.17036); returned null — a lifetime likes/followers ratio is not a real engagement rate"
]
},
"credits_used": 1,
"credits_remaining": 169326,
"request_id": "req-yxwJV4VNJhzpul4L",
"cached": false
}
```
`computed.engagement_rate` came back `null`, not a bogus number. The raw ratio was `10.17`, lifetime likes divided by followers, which isn't a real engagement rate, and the API said so directly in `_warnings` instead of quietly shipping something that looks like a valid float. A tool that types `engagement_rate: float | None` and reads `_warnings` before trusting a non-null value is the whole pattern in one field.
Three nulls, three different reasons. `language` is null because a one-line, emoji-heavy bio can't be language-detected. `url` and `joined_at` are null because TikTok's profile endpoint just doesn't expose them. Same type, `str | None`, completely different cause, which is exactly what the next section is about.
## Why do two platforms return such different nulls from the same Author model?
The tool for YouTube channels reuses the exact same `Author` model. Only the endpoint and the handle change:
```python
@agent.tool
async def get_youtube_channel(ctx: RunContext[SocialCrawlDeps], handle: str) -> Author:
async with httpx.AsyncClient() as client:
r = await client.get(
"https://www.socialcrawl.dev/v1/youtube/channel",
params={"handle": handle},
headers={"x-api-key": ctx.deps.api_key},
)
r.raise_for_status()
payload = r.json()
return Author.model_validate(payload["data"]["author"])
```
That's the whole payoff of typing the model once: the same `Author.model_validate()` call validates TikTok and YouTube, no platform-specific branch anywhere. Here's what came back for `mkbhd`, captured 19/08/2026:
```json
{
"success": true,
"platform": "youtube",
"endpoint": "/v1/youtube/channel",
"data": {
"author": {
"id": "UCBJycsmduvYEL83R_U4JriQ",
"username": "@mkbhd",
"display_name": "Marques Brownlee",
"verified": null,
"followers": 21100000,
"following": null,
"posts_count": 1841,
"likes_count": null,
"url": "https://www.youtube.com/channel/UCBJycsmduvYEL83R_U4JriQ",
"private": null,
"joined_at": "2008-03-21",
"ext": {
"country": "US",
"followers_approximate": true,
"madeForKids": false,
"hiddenSubscriberCount": false
}
},
"computed": {
"engagement_rate": null,
"language": "en",
"content_category": "other",
"estimated_reach": null
}
},
"credits_used": 1,
"credits_remaining": 169324,
"request_id": "req-XL6AZaE9dMhnQv9w",
"cached": false
}
```
Compare it to the TikTok response. `verified`, `following`, `likes_count`, and `private` are all populated for MrBeast and all `null` here: YouTube's channel endpoint doesn't expose them the same way. And `followers: 21100000` carries `ext.followers_approximate: true`. YouTube rounds subscriber counts above 1,000 to three significant figures ([source](https://developers.google.com/youtube/v3/docs/channels)), so that number is a rounded estimate, and the response says so instead of pretending precision it doesn't have.
This is the general shape of the problem, not a YouTube quirk. YouTube's API calls this field `subscriberCount`, TikTok's calls it `follower_count`, Instagram's calls it `followers_count`: three names, two casings, and one platform that only exposes it on a different API tier ([TikTok](https://developers.tiktok.com/doc/research-api-specs-query-user-info), [Instagram](https://developers.facebook.com/documentation/instagram-platform/instagram-graph-api/reference/ig-user)). One typed `Author` model, with the right fields marked optional, absorbs all of it.
## How do you write Pydantic AI tools that return validated models, not raw dicts?
None of the top Pydantic AI examples on the web validate the response a tool receives from an upstream API. The closest attempt returns a bare `dict`, and in one case stringifies the whole record before handing it to the model. That inverts the framework's promise: the least trustworthy part of the chain, an LLM's paraphrase, ends up the only part that's schema-checked.
Here's the "before": a tool that returns whatever came back, unchecked.
```python
@agent.tool
async def search_youtube_before(ctx: RunContext[SocialCrawlDeps], query: str) -> list[dict]:
async with httpx.AsyncClient() as client:
r = await client.get(
"https://www.socialcrawl.dev/v1/youtube/search",
params={"query": query, "includeExtras": "true"},
headers={"x-api-key": ctx.deps.api_key},
)
r.raise_for_status()
return r.json()["data"]["items"]
```
That runs fine. Nothing downstream knows a field is missing until it crashes on it. Here's the "after": typed models for the archetype this endpoint returns, validated before anything reaches the agent.
```python
class PostContent(BaseModel):
text: str
media_urls: list[str] | None = None
thumbnail_url: str | None = None
duration_seconds: int | None = None
class PostAuthor(BaseModel):
username: str
display_name: str
verified: bool | None = None
class Engagement(BaseModel):
views: int | None = None
likes: int | None = None
comments: int | None = None
shares: int | None = None
saves: int | None = None
class Flags(BaseModel):
nsfw: bool | None = None
spoiler: bool | None = None
pinned: bool | None = None
deleted: bool | None = None
class Computed(BaseModel):
engagement_rate: float | None = None
language: str | None = None
content_category: str | None = None
estimated_reach: int | None = None
class Post(BaseModel):
id: str
url: str
content: PostContent
author: PostAuthor
engagement: Engagement
flags: Flags
published_at: str
ext: dict | None = None
class PostResult(BaseModel):
post: Post
computed: Computed
@agent.tool
async def search_youtube(ctx: RunContext[SocialCrawlDeps], query: str) -> list[PostResult]:
async with httpx.AsyncClient() as client:
r = await client.get(
"https://www.socialcrawl.dev/v1/youtube/search",
params={"query": query, "includeExtras": "true"},
headers={"x-api-key": ctx.deps.api_key},
)
r.raise_for_status()
return [PostResult.model_validate(item) for item in r.json()["data"]["items"]]
```
One real result, captured 19/08/2026 for the query "pydantic ai tutorial":
```json
{
"post": {
"id": "zcYtSckecD8",
"url": "https://www.youtube.com/watch?v=zcYtSckecD8",
"content": {
"text": "How to Build AI Agents with PydanticAI (Beginner Tutorial)",
"media_urls": null,
"thumbnail_url": "https://i.ytimg.com/vi/zcYtSckecD8/hq720.jpg?...",
"duration_seconds": 2036
},
"author": {
"username": "@daveebbelaar",
"display_name": "Dave Ebbelaar",
"verified": null
},
"engagement": { "views": 79564, "likes": 1770, "comments": 81, "shares": null, "saves": null },
"flags": { "nsfw": null, "spoiler": null, "pinned": null, "deleted": false },
"published_at": "2024-12-05T08:01:50-08:00",
"ext": { "content_type": "video", "channel_id": "UCn8ujwUInbJkBhffxqAPBVQ" }
},
"computed": {
"engagement_rate": 0.023264,
"language": "en",
"content_category": "other",
"estimated_reach": 95477
}
}
```
`engagement.shares` and `engagement.saves` are `null` on all 18 results in this search, every single one. That's not a gap in this particular video, it's structural: YouTube's Post archetype has no concept of shares or saves at this endpoint. A field typed `int | None` that is always `None` for a platform is something a tool needs to represent honestly, not silently default to `0`, which would falsely claim "zero shares" instead of "not tracked here".
Comments are a different archetype entirely, and it shows in what `computed` carries:
```python
class CommentAuthor(BaseModel):
username: str
display_name: str
verified: bool | None = None
class CommentEngagement(BaseModel):
likes: int
replies: int
class CommentFlags(BaseModel):
pinned: bool | None = None
deleted: bool | None = None
class Comment(BaseModel):
id: str
url: str | None = None
parent_id: str | None = None
post_id: str
text: str
author: CommentAuthor
engagement: CommentEngagement
flags: CommentFlags
published_at: str
class CommentComputed(BaseModel):
language: str | None = None
class CommentResult(BaseModel):
comment: Comment
computed: CommentComputed
```
A real comment from the same video:
```json
{
"comment": {
"id": "UgwCYYsrgXoc9Ly9fGB4AaABAg",
"url": null,
"parent_id": null,
"post_id": "zcYtSckecD8",
"text": "About the temperature, is setted in run. Example:\nselected_columns=select_columns_agent.run_sync(...)\nIt makes sense, as in your workflow you can need to use the same Agent many times running with different temperature",
"author": {
"username": "@marcopancotti8505",
"display_name": "@marcopancotti8505",
"verified": null
},
"engagement": { "likes": 16, "replies": 0 },
"flags": { "pinned": null, "deleted": false },
"published_at": "2025-02-02T13:06:08Z"
},
"computed": { "language": "en" }
}
```
`comment.url` is `null` on every comment on this page: YouTube's comment model has no permalink field the way Reddit does, so `url: str | None` is correct. But code that assumes every object has a working URL will crash or silently drop rows here. `author.verified` is likewise always `null` on commenters, because YouTube exposes verification on channels, not on the people commenting on them.
Three archetypes, one API key, three different `computed` shapes: `Author` gets `engagement_rate`, `language`, `content_category`, and `estimated_reach`; `Post` gets the same four; `Comment` gets `language` and nothing else. That's the argument for one typed model per archetype rather than a single loose schema stretched to cover all three.
## Six sources, one query, four different engagement shapes
One call proves this at scale. `/v1/search/everywhere` fans a single query across multiple sources in one request, at a flat 20 credits regardless of how many actually return data:
```python
from typing import Annotated, Literal
from pydantic import Field, TypeAdapter
class HNEngagement(BaseModel):
source: Literal["hackernews"]
points: int
comments: int
class RedditEngagement(BaseModel):
source: Literal["reddit"]
score: int
num_comments: int
class GitHubEngagement(BaseModel):
source: Literal["github"]
comments: int
reactions: int
class TwitterEngagement(BaseModel):
source: Literal["twitter-ai-search"]
views: int
likes: int
reposts: int
replies: int
comments: int
saves: int
SourceEngagement = Annotated[
HNEngagement | RedditEngagement | GitHubEngagement | TwitterEngagement,
Field(discriminator="source"),
]
engagement_adapter = TypeAdapter(SourceEngagement)
def parse_engagement(item: dict):
return engagement_adapter.validate_python({"source": item["source"], **item["engagement"]})
@agent.tool
async def search_everywhere(ctx: RunContext[SocialCrawlDeps], query: str) -> list[dict]:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.get(
"https://www.socialcrawl.dev/v1/search/everywhere",
params={
"query": query,
"lookback_days": 180,
"sources": "hackernews,github,youtube,reddit,twitter-ai-search",
},
headers={"x-api-key": ctx.deps.api_key},
)
r.raise_for_status()
return [
{"title": item["title"], "url": item["url"], "engagement": parse_engagement(item)}
for item in r.json()["data"]["items"]
]
```
For the query "pydantic ai agents" on 19/08/2026, that call actually ran across `hackernews, github, youtube-hashtag, youtube, reddit, twitter-ai-search`, six sources, `sources_failed: {}`, `coverage: 1`, 40 fused and ranked items. The top-ranked result, an HN thread on the framework itself:
```json
{
"candidate_id": "https://ai.pydantic.dev",
"source": "hackernews",
"title": "Pydantic.ai: Python agent framework from Pydantic team",
"url": "https://ai.pydantic.dev/",
"source_items": [
{
"item_id": "43006835",
"author": "alexdong",
"published_at": "2025-02-10T23:47:19Z",
"date_confidence": "low",
"engagement": { "points": 5, "comments": 1 },
"metadata": {
"top_comments": [
{
"score": null,
"excerpt": "Looks interesting but I do wonder what the use cases and advantages are here...",
"author": "NomDePlum",
"url": "https://news.ycombinator.com/item?id=43007167",
"date": "2025-02-11T00:18:24.000Z"
}
]
}
}
]
}
```
The reason this needed a discriminated union rather than one `dict[str, int]`, straight from the same response:
| Source | `engagement` shape |
|---|---|
| Hacker News | `{points, comments}` |
| Reddit | `{score, num_comments}` |
| GitHub | `{comments, reactions}` |
| X / twitter-ai-search | `{views, likes, reposts, replies, comments, saves}` |
Code that assumes `engagement.likes` exists on every item breaks silently on Hacker News and GitHub the moment it runs. Validating each source into its own model at the boundary catches that at parse time, not three calls deep into someone else's rate limit.
One honest caveat: this is a single captured run, not a benchmark. Scores and titles will drift as new content gets published, and `youtube-hashtag` succeeded here but returned zero items, a legitimate empty result, structurally different from a source that never ran at all. A tool that can't tell those two states apart will misreport its own coverage. If a field ever arrives as the wrong type entirely, that's a `ValidationError` inside the tool, and what the model sees next is covered in the FAQ below.
## How do you start using this?
Sign up for a free key with starting credits, then set it as `SOCIALCRAWL_API_KEY`. Every call in this post is reproducible with your own:
```bash
curl -H "x-api-key: $YOUR_KEY" "https://www.socialcrawl.dev/v1/tiktok/profile?handle=mrbeast"
```
The exact numbers will have moved on (MrBeast's follower count changes daily), but the response shape and the null pattern won't.
From there: the [explorer](/explorer) lets you see a raw response before you write a model for it, which is worth doing before you commit to a schema. The [TikTok](/platforms/tiktok) and [YouTube](/platforms/youtube) reference pages document the unified schema per endpoint, including which fields are always null on a given platform. Every parameter used in the code above (`includeExtras`, `lookback_days`, `sources`) is documented there too, so you're not guessing at what else a tool could ask for.
If you'd rather not write direct HTTP calls at all, [MCP](/blog/what-is-an-mcp-server) is the alternative transport: same schema, no `httpx` boilerplate. And if you want to turn any of this into a standing job instead of a one-off script, that's a different post: [building an agent that monitors social accounts on a schedule](/blog/building-ai-agent-social-media-monitoring).
## Frequently asked questions
### Does Pydantic AI validate the data a tool returns?
No, not automatically. Pydantic validates a tool's arguments against its function signature, but return values aren't checked against your type annotation: "Tools can return anything that Pydantic can serialize to JSON" ([docs](https://pydantic.dev/docs/ai/tools/)). If you want the upstream response itself validated, call `Model.model_validate()` on it inside the tool, which is what every example above does.
### How do I pass an API key into a Pydantic AI tool?
Through `deps_type` and `RunContext`, not a global variable or a hardcoded string. Define a small dataclass holding the key, pass an instance via `agent.run_sync(prompt, deps=SocialCrawlDeps(api_key=...))`, and read it inside any tool as `ctx.deps.api_key`. Every example in this post reads that value from the env var `SOCIALCRAWL_API_KEY`.
### What happens when a tool's arguments fail validation?
A `ValidationError` becomes a `RetryPromptPart` sent back to the model automatically, and each retry consumes one unit of the retry budget (`Agent(retries=N)`, default 1) ([docs](https://pydantic.dev/docs/ai/agents/)). The same repair loop fires if your tool body raises `ModelRetry` deliberately, for example after an upstream 429. Exhaust the budget either way and the run raises `UnexpectedModelBehavior` instead of silently returning something wrong.
### How is Pydantic AI different from LangChain?
Typed `deps_type`, `RunContext[T]`, and `output_type` on one agent object, all checkable statically before anything runs, is the distinctive combination. Typed tools alone aren't unique to Pydantic AI: the OpenAI Agents SDK uses Pydantic-powered validation too ([docs](https://openai.github.io/openai-agents-python/)). LangGraph is a different shape entirely, a graph and state-machine model built around checkpointing. See our [framework field guide](/blog/ai-agent-frameworks-2026-developer-field-guide) for the fuller comparison.
### How many credits does a call like this cost?
In this run, single-object calls (TikTok profile, YouTube channel, YouTube search, YouTube comments) cost 1 credit each. `/v1/search/everywhere` is a flat 20 credits regardless of how many of its sources actually return data. Total spend across all five calls in this post: 24 credits.