Building a Python SDK for Your AI Agent Platform: Client, Models, and Error Handling
A hands-on guide to building a production-quality Python SDK for an AI agent platform, covering package structure, the HTTP client class, Pydantic response models, and a structured exception hierarchy.
Package Structure That Scales
A Python SDK needs a clean package structure from day one. Retrofitting structure later breaks imports for every user. Here is a layout that supports growth without reorganization:
myagent-python/
src/
myagent/
__init__.py # Public API exports
_client.py # HTTP client implementation
_config.py # Configuration and defaults
_exceptions.py # Exception hierarchy
types/
__init__.py
agents.py # Agent-related models
runs.py # Run-related models
tools.py # Tool-related models
resources/
__init__.py
agents.py # AgentsResource class
runs.py # RunsResource class
tools.py # ToolsResource class
tests/
pyproject.toml
The underscore-prefixed modules (_client.py, _exceptions.py) are internal. Everything users need is re-exported from __init__.py. This gives you freedom to refactor internals without breaking the public surface.
The HTTP Client Class
The client is the entry point. It holds configuration, manages authentication, and delegates to resource classes:
# src/myagent/_client.py
from __future__ import annotations
import os
from typing import Any
import httpx
from ._config import DEFAULT_BASE_URL, DEFAULT_TIMEOUT
from ._exceptions import AuthenticationError, APIError, APIConnectionError
from .resources.agents import AgentsResource
from .resources.runs import RunsResource
class AgentClient:
"""Client for the MyAgent API."""
def __init__(
self,
api_key: str | None = None,
base_url: str = DEFAULT_BASE_URL,
timeout: float = DEFAULT_TIMEOUT,
) -> None:
self.api_key = api_key or os.environ.get("MYAGENT_API_KEY")
if not self.api_key:
raise AuthenticationError(
"No API key provided. Pass api_key= or set MYAGENT_API_KEY."
)
self._http = httpx.Client(
base_url=base_url,
timeout=timeout,
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
"User-Agent": "myagent-python/0.1.0",
},
)
self.agents = AgentsResource(self)
self.runs = RunsResource(self)
def _request(
self, method: str, path: str, **kwargs: Any
) -> dict[str, Any]:
try:
response = self._http.request(method, path, **kwargs)
except httpx.ConnectError as exc:
raise APIConnectionError(
f"Failed to connect to {self._http.base_url}"
) from exc
if response.status_code == 401:
raise AuthenticationError("Invalid API key.")
if response.status_code >= 400:
raise APIError(
status_code=response.status_code,
message=response.json().get("error", response.text),
)
return response.json()
def close(self) -> None:
self._http.close()
def __enter__(self) -> AgentClient:
return self
def __exit__(self, *args: Any) -> None:
self.close()
The client supports both explicit close() and context manager usage. The _request method is the single point of HTTP interaction — every resource class delegates here, so logging, retries, and error mapping happen in one place.
Pydantic Response Models
Every API response should deserialize into a typed Pydantic model. This gives users autocompletion, validation, and serialization for free:
# src/myagent/types/agents.py
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class Agent(BaseModel):
id: str
name: str
model: str
instructions: str
created_at: datetime = Field(alias="createdAt")
tools: list[ToolRef] = Field(default_factory=list)
class Config:
populate_by_name = True
class ToolRef(BaseModel):
id: str
name: str
type: str
class AgentCreateParams(BaseModel):
name: str
model: str = "gpt-4o"
instructions: str = ""
tool_ids: list[str] = Field(
default_factory=list, alias="toolIds"
)
The AgentCreateParams model validates user input before it hits the network. If someone passes an integer for name, they get a clear Pydantic validation error instead of a cryptic API response.
See AI Voice Agents Handle Real Calls
Book a free demo or calculate how much you can save with AI voice automation.
Resource Classes
Resource classes group related operations and use the client for HTTP:
# src/myagent/resources/agents.py
from __future__ import annotations
from typing import TYPE_CHECKING
from ..types.agents import Agent, AgentCreateParams
if TYPE_CHECKING:
from .._client import AgentClient
class AgentsResource:
def __init__(self, client: AgentClient) -> None:
self._client = client
def create(self, **kwargs) -> Agent:
params = AgentCreateParams(**kwargs)
data = self._client._request(
"POST", "/agents",
json=params.model_dump(by_alias=True),
)
return Agent.model_validate(data)
def get(self, agent_id: str) -> Agent:
data = self._client._request("GET", f"/agents/{agent_id}")
return Agent.model_validate(data)
def list(self, limit: int = 20, offset: int = 0) -> list[Agent]:
data = self._client._request(
"GET", "/agents",
params={"limit": limit, "offset": offset},
)
return [Agent.model_validate(item) for item in data["data"]]
def delete(self, agent_id: str) -> None:
self._client._request("DELETE", f"/agents/{agent_id}")
Exception Hierarchy
A structured exception hierarchy lets users catch errors at the right granularity:
# src/myagent/_exceptions.py
class MyAgentError(Exception):
"""Base exception for all SDK errors."""
class APIError(MyAgentError):
def __init__(self, status_code: int, message: str):
self.status_code = status_code
self.message = message
super().__init__(f"[{status_code}] {message}")
class AuthenticationError(MyAgentError):
pass
class APIConnectionError(MyAgentError):
pass
class RateLimitError(APIError):
pass
class NotFoundError(APIError):
pass
Users can catch MyAgentError for a blanket handler, APIError for HTTP-specific failures, or RateLimitError for retry logic.
FAQ
Should I use httpx or requests for the HTTP client?
Use httpx. It supports both sync and async usage from the same library, has a cleaner API for timeouts and base URLs, and supports HTTP/2. This means you can offer both AgentClient (sync) and AsyncAgentClient (async) without maintaining two separate HTTP abstractions.
How do I handle API responses that have extra fields my models do not define?
Configure your Pydantic models with model_config = ConfigDict(extra="ignore"). This way, if the API adds new fields in the future, existing SDK versions do not break. Warn users about unknown fields in debug logging rather than raising validation errors.
Should I validate parameters client-side before sending requests?
Yes, but validate structure and types, not business logic. Check that required fields are present, that IDs match expected formats, and that enum values are valid. Leave domain-specific validation (like whether an agent name is unique) to the server — the SDK cannot know the current state.
#PythonSDK #Pydantic #APIClient #ErrorHandling #AgenticAI #DeveloperTools #LearnAI #AIEngineering
CallSphere Team
Expert insights on AI voice agents and customer communication automation.
Try CallSphere AI Voice Agents
See how AI voice agents work for your industry. Live demo available -- no signup required.