Skip to content
Learn Agentic AI12 min read0 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:

# 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

Share this article
C

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.