> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stackone.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Pydantic AI

> Build type-safe AI agents with StackOne tools and Pydantic AI

Build production-ready AI agents with Pydantic AI's type-safe framework and direct access to business data through StackOne's Tools.

<Note>
  **Supported languages:** Python.
</Note>

* **Type-safe agents** with Pydantic validation
* **Structured outputs** with full type checking
* **Dynamic tool loading** based on StackOne Linked Accounts
* **OpenAI-compatible** tool integration

## Calling actions

<Steps>
  <Step title="Install">
    ```bash theme={null}
    # Requires Python 3.10 or higher
    # uv or pip

    # Using uv (recommended)
    uv add 'stackone-ai[mcp]' pydantic-ai

    # Using pip
    pip install 'stackone-ai[mcp]' pydantic-ai

    # Optional: install with example dependencies
    uv add 'stackone-ai[mcp,examples]'
    pip install 'stackone-ai[mcp,examples]'

    export STACKONE_API_KEY=your_api_key_here
    ```
  </Step>

  <Step title="Fetch Tools">
    Two approaches to retrieving tools:

    1. [Fetch Tools (with filtering)](/features/tool-discovery#tool-filtering) — fetch all tools upfront with optional filters applied.
    2. [Search & Execute](/features/tool-discovery#search-and-execute) — hand the model just `tool_search` + `tool_execute` and let the agent discover what it needs at runtime.

    <Note>
      See [Tool Discovery](/features/tool-discovery) for more details on which may be best in your scenario.
    </Note>

    <Tabs>
      <Tab title="Fetch tools">
        Fetch StackOne tools for the linked account and convert each one to a Pydantic AI `Tool` with its schema:

        ```python theme={null}
        import json
        from stackone_ai import StackOneToolSet
        from pydantic_ai import Tool

        # Get account ID from your app's auth context or StackOne dashboard
        account_id = "your-account-id"

        # Initialize toolset and fetch tools.
        # Optionally narrow the fetch with filters:
        #   providers=["hibob", "workday"]    — only these connectors
        #   actions=["*_list_*", "*_get_*"]   — glob patterns, e.g. read-only actions
        toolset = StackOneToolSet()
        tools = toolset.fetch_tools(account_ids=[account_id])

        # Convert each StackOne tool to a Pydantic AI Tool with proper schema
        pydantic_tools = []
        for stackone_tool in tools:
            params_schema = stackone_tool.to_openai_function()["function"]["parameters"]

            def execute(t=stackone_tool, **kwargs: object) -> str:
                return json.dumps(t.execute(kwargs))

            pydantic_tools.append(
                Tool.from_schema(
                    execute,
                    name=stackone_tool.name,
                    description=stackone_tool.description,
                    json_schema=params_schema,
                )
            )
        ```
      </Tab>

      <Tab title="Search & execute">
        The LLM receives only 2 tools and searches the catalog autonomously:

        ```python theme={null}
        from stackone_ai import StackOneToolSet

        toolset = StackOneToolSet(
            search={"method": "semantic", "top_k": 3},
            execute={"account_ids": ["your-account-id"]},
        )

        # LLM receives only 2 tools - it searches and executes autonomously.
        # .pydantic_ai() returns Pydantic AI tools directly, so no conversion is needed
        pydantic_tools = toolset.pydantic_ai(mode="search_and_execute")
        ```
      </Tab>
    </Tabs>
  </Step>

  <Step title="Run the agent loop">
    Create the agent with the tools and run it — Pydantic AI executes tool calls automatically, so the same code works for both approaches:

    ```python theme={null}
    from pydantic_ai import Agent

    # Create agent with tools
    agent = Agent(
        "openai:gpt-5.4",
        system_prompt="You are a helpful HR assistant.",
        tools=pydantic_tools,
    )

    result = agent.run_sync("List the first 5 employees")
    print(result.output)
    ```
  </Step>
</Steps>

## Structured outputs

Leverage Pydantic AI's type-safe responses:

```python theme={null}
from pydantic import BaseModel
from pydantic_ai import Agent

class EmployeeSummary(BaseModel):
    total_count: int
    departments: list[str]
    average_tenure_years: float

agent = Agent(
    model=OpenAIModel('gpt-5.4'),
    result_type=EmployeeSummary,
    system_prompt="Analyze employee data and return structured summaries."
)

# Returns validated EmployeeSummary instance
result = agent.run_sync("Summarize the employee base")
summary: EmployeeSummary = result.data

print(f"Total employees: {summary.total_count}")
print(f"Departments: {', '.join(summary.departments)}")
```

## Best practices

### Account ID from context

```python theme={null}
# Get from user/tenant context
account_id = request.user.stackone_account_id
account_id = get_account_for_tenant(tenant_id)
```

### Error handling

```python theme={null}
from stackone_ai.models import StackOneError, StackOneAPIError

try:
    result = agent.run_sync(message)
except StackOneAPIError as e:
    print(f"API error: {e.message}")
except StackOneError as e:
    print(f"StackOne error: {e.message}")
```

### Multi-account usage

Each tool execution runs against a specific customer account. Set account IDs once on the toolset, or pass them per request.

```python theme={null}
# Set accounts on the toolset for all subsequent calls
toolset.set_accounts(["account-123", "account-456"])
tools = toolset.fetch_tools()

# Or pass account IDs per request
tools = toolset.fetch_tools(account_ids=["account-123", "account-456"])

# Loop over customer accounts dynamically
customer_accounts = ["account-1", "account-2", "account-3"]

for account_id in customer_accounts:
    tools = toolset.fetch_tools(
        actions=["workday_list_workers"],
        account_ids=[account_id],
    )
    employee_tool = tools.get_tool("workday_list_workers")
    if employee_tool:
        employees = employee_tool.call()
        print(f"Found {len(employees['data'])} employees")
```

<AccordionGroup>
  <Accordion title="Error handling">
    ```python theme={null}
    from stackone_ai.models import StackOneError, StackOneAPIError

    try:
        result = employee_tool.call(id="employee-123")
        print("Success:", result)
    except StackOneAPIError as e:
        print(f"API error: {e.message}")
    except StackOneError as e:
        print(f"StackOne error: {e.message}")
    except Exception as e:
        print(f"Unexpected error: {e}")
    ```
  </Accordion>

  <Accordion title="File downloads (binary responses)">
    Actions that download a file (for example `googledrive_unified_download_file`, `documents_download_file`, or any `*_unified_download_file`) return raw bytes plus metadata, not parsed JSON. The SDK decides from the response `Content-Type`: JSON is parsed as usual, and anything else is treated as a file download.

    `call()` and `execute()` both return the dict directly (it isn't wrapped in a result object), so read its values with dict keys like `result["content"]`.

    ```python theme={null}
    tools = toolset.fetch_tools(actions=["googledrive_*"], account_ids=["your-account-id"])
    download = tools.get_tool("googledrive_unified_download_file")

    result = download.execute({"id": "file-id"})

    # result describes the file. Write the bytes straight to disk:
    with open(result["file_name"] or "download.bin", "wb") as f:
        f.write(result["content"])
    ```

    The returned dict, from both `call()` and `execute()`:

    | Key            | Type          | Description                                                                    |
    | -------------- | ------------- | ------------------------------------------------------------------------------ |
    | `content`      | `bytes`       | Raw file bytes. Not JSON-serializable (see note).                              |
    | `content_type` | `str`         | File MIME type (for example `application/pdf`), or `application/octet-stream`. |
    | `status_code`  | `int`         | HTTP status of the download response.                                          |
    | `headers`      | `dict`        | Response headers.                                                              |
    | `file_name`    | `str \| None` | Filename from `Content-Disposition` (RFC 5987 `filename*` aware), else `None`. |

    <Warning>
      `content` is raw bytes and is not JSON-serializable. If you forward tool results to an LLM (or anything that re-serializes to JSON), handle or strip the `content` key. For example, base64-encode it on the LLM-facing path.
    </Warning>
  </Accordion>
</AccordionGroup>

## Troubleshooting

### Execute a tool

Direct execution is useful for testing and debugging. In production, your agent framework handles tool calls automatically.

```python theme={null}
tools = toolset.fetch_tools(account_ids=["your-account-id"])
employee_tool = tools.get_tool("workday_list_workers")

# call() with keyword arguments
result = employee_tool.call(id="employee-123", include_details=True)
print(result["data"])

# execute() with dictionary payloads
result = employee_tool.execute({"id": "employee-123", "include_details": True})

# execute() with OpenAI-style arguments
payload = {"arguments": {"id": "employee-123"}}
result = employee_tool.execute(payload)
```

## Next steps

<CardGroup cols={2}>
  <Card title="Observability" href="/features/observability/overview" icon="chart-line">
    Diagnose failing calls and monitor what your agent runs.
  </Card>

  <Card title="Tool Defense" href="/features/tool-defense" icon="shield-halved">
    Protect your agent from malicious content in tool results.
  </Card>
</CardGroup>
