Skip to content
Learn Agentic AI
Learn Agentic AI12 min read9 views

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:

flowchart LR
    INPUT(["User intent"])
    PARSE["Parse plus<br/>classify"]
    PLAN["Plan and tool<br/>selection"]
    AGENT["Agent loop<br/>LLM plus tools"]
    GUARD{"Guardrails<br/>and policy"}
    EXEC["Execute and<br/>verify result"]
    OBS[("Trace and metrics")]
    OUT(["Outcome plus<br/>next action"])
    INPUT --> PARSE --> PLAN --> AGENT --> GUARD
    GUARD -->|Pass| EXEC --> OUT
    GUARD -->|Fail| AGENT
    AGENT --> OBS
    style AGENT fill:#4f46e5,stroke:#4338ca,color:#fff
    style GUARD fill:#f59e0b,stroke:#d97706,color:#1f2937
    style OBS fill:#ede9fe,stroke:#7c3aed,color:#1e1b4b
    style OUT fill:#059669,stroke:#047857,color:#fff
# 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.

Hear it before you finish reading

Talk to a live CallSphere AI voice agent in your browser — 60 seconds, no signup.

Try Live Demo →

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.

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:

Still reading? Stop comparing — try CallSphere live.

CallSphere ships complete AI voice agents per industry — 14 tools for healthcare, 10 agents for real estate, 4 specialists for salons. See how it actually handles a call before you book a demo.

# 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

Share

Try CallSphere AI Voice Agents

See how AI voice agents work for your industry. Live demo available -- no signup required.