# Generate and Test Code With Pydantic AI

[Pydantic AI's native Daytona integration](https://pydantic.dev/docs/ai/harness/daytona-sandbox/) gives an agent an isolated workspace for generated code. The harness supplies file and shell tools separately, so the workspace determines where those tools operate. Files remain available after a run ends, and a later run can reattach to the same sandbox.

This example builds an agent that implements a Python moving-average function, writes pytest tests, and executes them in Daytona. The agent corrects failures before returning a validated test report. A follow-up run adds a test in the same workspace, and the application explicitly deletes the sandbox when finished.

---

### 1. Workflow Overview

The task is a moving average: the mean of every `window` consecutive values, a common way to smooth noisy data such as sensor readings. For example, `moving_average([1, 2, 3, 4], 2)` returns `[1.5, 2.5, 3.5]`.

The first run writes `moving_average(values, window)` in `stats.py` and tests in `test_stats.py`. It checks normal windows, empty input, oversized windows, invalid windows, and fractional values. The agent runs pytest, installs it if missing, and fixes failures until the suite passes.

The second run starts a new conversation in the same sandbox by passing its workspace ref. It reads the existing files, adds a test for negative values, and runs the full suite. A `finally` block deletes the sandbox after either success or failure.

### 2. Project Setup

#### Clone the Repository

Clone the [repository](https://github.com/daytona/guides) and navigate to the example directory:

```bash
git clone https://github.com/daytona/guides.git
cd guides/python/pydantic-ai/coding-agent
```

#### Install Dependencies

:::note[Python Version Requirement]
This example requires **Python 3.11 or higher**. It's recommended to use a virtual environment (e.g., `venv` or `poetry`) to isolate project dependencies.
:::

Create a virtual environment, then install the dependencies:


```bash
python3 -m venv .venv
source .venv/bin/activate  # On Windows: .venv\Scripts\activate
pip install -U "pydantic-ai-harness[daytona,anthropic]" python-dotenv
```


The `daytona` extra installs the Daytona SDK. The `anthropic` extra installs the matching `pydantic-ai-slim[anthropic]` provider dependencies. `python-dotenv` loads your local `.env` file.

#### Configure Environment

Get a [Daytona API key](https://app.daytona.io/dashboard/keys) and an [Anthropic API key](https://console.anthropic.com/). Copy the environment template:

```bash
cp .env.example .env
```

Set the keys in `.env`:

```bash
DAYTONA_API_KEY=your-daytona-api-key
ANTHROPIC_API_KEY=your-anthropic-api-key
# Optional:
# MODEL=anthropic:claude-sonnet-5
```

The default model requires access to `claude-sonnet-5`. To use another provider, install its Pydantic AI provider extra and set its API key and `MODEL`.

Your API keys stay on your machine: the agent calls the model from your application, and only the generated code runs in Daytona. Nothing from your machine's environment, including the keys in `.env`, reaches the sandbox.

### 3. Understanding the Core Components

The example combines these components:

| **Component** | **Role** |
| ------------- | -------- |
| Native Daytona integration | Supplies the persistent sandbox workspace, not tools |
| Coder capability | Supplies file and shell tools that use the workspace |
| Workspace ref | Identifies the sandbox for a later run or deletion |
| Test report | Records changed files and final pytest results as validated fields |
| Application | Owns sandbox cleanup, including after a failed run |

#### Pydantic AI Agent

The agent sends prompts to the model, dispatches tool calls, and returns the model's response with the run's messages and workspace. Async runs use `await agent.run(...)`. The Daytona SDK requires an asyncio event loop.

#### Native Daytona Workspace

Import the native integration from `pydantic_ai_harness.daytona_sandbox`. `DaytonaSandbox` supplies the workspace and adds no tools. Its sandbox keeps running after the run ends; Pydantic AI never stops or deletes it.

A workspace ref identifies the sandbox without holding credentials. A later run can reattach with the ref, or recover it from message history. Reattaching starts a stopped sandbox again; a deleted sandbox raises `WorkspaceUnavailableError` rather than creating an empty replacement.

#### Coder Capability

`Coder` supplies `shell`, `read_file`, `write_file`, `edit_file`, `list_files`, and `grep`. This example disables sub-agent delegation because the task is small. Files and commands use the Daytona workspace rather than your local filesystem.

### 4. Initialize the Agent

Start with the imports and the sandbox working directory:


```python
import asyncio
import os
from contextlib import aclosing
from textwrap import shorten

from dotenv import load_dotenv
from pydantic import BaseModel, Field
from pydantic_ai import Agent
from pydantic_ai.messages import ToolCallPart, ToolReturnPart
from pydantic_ai.run import AgentRunResult
from pydantic_ai_harness.coder import Coder
from pydantic_ai_harness.daytona_sandbox import DaytonaSandbox, DaytonaSandboxBackend

WORKING_DIR = "/home/daytona/project"
```


#### Typed Test Reports

Define the output model that each run returns:

```python
class TestReport(BaseModel):
    """Files changed and final pytest results from one run."""

    files: list[str] = Field(description="Files created or changed in this run")
    tests_passed: int = Field(description="Number of tests passed in the final pytest run")
    tests_failed: int = Field(description="Number of tests failed in the final pytest run")
    pytest_summary: str = Field(description="Final line of the pytest output, e.g. 11 passed in 0.03s")
```

Passed to the agent as `output_type=TestReport`, the class becomes a JSON schema that the model fills through an output tool. Pydantic validates the final answer, so `result.output` is a `TestReport`. If validation fails, Pydantic AI sends the validation errors back to the model to retry. By default it retries once; pass `retries={"output": N}` to `Agent` to change that. Running out of retries raises `UnexpectedModelBehavior`.

Validation checks the report's structure, not the truth of its test results. Compare the reported counts and summary with the shell trace. The application uses the validated failure counts to decide whether to continue.

#### Create the Agent

Inside `main()`, load the environment and compose the tools with the workspace capability:

```python
load_dotenv()
model = os.getenv("MODEL", "anthropic:claude-sonnet-5")
sandbox = DaytonaSandbox(working_dir=WORKING_DIR)
agent = Agent(model, output_type=TestReport, capabilities=[Coder(sub_agents=False), sandbox])
```

The native backend creates the project directory on a new sandbox. The example leaves snapshot selection at Daytona's default. Dependencies installed on your machine are not installed in that sandbox.

### 5. Define the Coding Task

The prompt defines the function's behavior and requires execution before the final response:


```python
TASK = """Implement moving_average(values, window) in stats.py with type hints.
Return the arithmetic mean of every consecutive full window as a list of floats.
For a valid window, an empty list or window > len(values) returns [].
window < 1 raises ValueError, including when values is empty.
Write test_stats.py using pytest: cover normal windows, window=1, window=len(values),
empty input, oversized windows, zero and negative windows, and fractional values.
Run python -m pytest -q. If pytest is missing, install it in the sandbox.
Fix any failures and re-run until green.
Report the files created or changed in this run and the final pytest results."""
FOLLOWUP = """The existing stats.py and test_stats.py are in this workspace.
Read them. Add one more pytest test for moving_average([-4, -2, 0, 2], 2),
expecting [-3.0, -1.0, 1.0]. Do not replace the existing tests.
Run the full suite with python -m pytest -q, fix failures, and re-run until green.
Report the files created or changed in this run and the final pytest results."""
```


The invalid-window rule takes precedence over the empty-input rule. With a positive window, an empty list returns `[]`; with a window below 1, it raises `ValueError` even when the list is empty.

### 6. Run and Reattach to the Workspace

First, define a trace helper that prints sandbox tool calls and results from each run. It excludes the default `final_result` output tool so the structured report appears only once, separately:

```python
def print_trace(result: AgentRunResult[TestReport]) -> None:
    """Print bounded tool arguments and results from this run only."""
    for message in result.new_messages():
        for part in message.parts:
            if isinstance(part, (ToolCallPart, ToolReturnPart)) and part.tool_name == "final_result":
                continue
            if isinstance(part, ToolCallPart):
                arguments = shorten(part.args_as_json_str(), width=180)
                print(f"  -> {part.tool_name} {arguments}")
            elif isinstance(part, ToolReturnPart):
                output = shorten(str(part.content), width=500)
                print(f"  <- {part.tool_name} {output}")
```

Keep a caller-owned backend reachable until cleanup. It creates the sandbox on first use during the run. A failed model run returns no result, but the backend still holds the ref for any sandbox it created:


```python
async def main() -> None:
    """Run the agent twice in one Daytona sandbox, then delete the sandbox."""
    load_dotenv()
    model = os.getenv("MODEL", "anthropic:claude-sonnet-5")
    sandbox = DaytonaSandbox(working_dir=WORKING_DIR)
    agent = Agent(model, output_type=TestReport, capabilities=[Coder(sub_agents=False), sandbox])

    # Keep the backend available for cleanup even if the first run fails.
    async with aclosing(DaytonaSandboxBackend(working_dir=WORKING_DIR)) as backend:
        try:
            first = await agent.run(TASK, workspace=backend)
            ref = first.workspace.ref
            assert ref is not None
            print(f"Sandbox: {ref.id}", flush=True)
            print("\nRun 1 tool trace:")
            print_trace(first)
            print(first.output.model_dump_json(indent=2))
            if first.output.tests_failed:
                raise RuntimeError(f"Run 1 reported {first.output.tests_failed} failed tests")

            print(f"\nReattaching: {ref.id}", flush=True)
            second = await agent.run(FOLLOWUP, workspace=ref)
            print("\nRun 2 tool trace:")
            print_trace(second)
            print(second.output.model_dump_json(indent=2))
            if second.output.tests_failed:
                raise RuntimeError(f"Run 2 reported {second.output.tests_failed} failed tests")
            print(f"Run 2 workspace: {second.workspace.ref}")
        finally:
            ref = backend.ref
            if ref is not None:
                await sandbox.destroy(ref)
                print(f"Deleted sandbox: {ref.id}", flush=True)


if __name__ == "__main__":
    asyncio.run(main())
```


The initial backend's settings apply to the first run. On reattachment, the capability supplies a new backend with the same working directory. Capability settings do not override a caller-supplied backend.

The follow-up uses `workspace=ref` without message history: it reuses files, not conversation context. To preserve both, pass `message_history=first.all_messages()` instead; the native integration finds the workspace ref in those messages. Without either a ref or history, a run starts a new sandbox.

Each run prints its report as JSON, and the script checks its failure count. If run 1 reports failed tests, `raise RuntimeError(...)` ends `main()` before the follow-up run; if run 2 does, the script exits with the error after it. Either way, the `finally` block still deletes the sandbox.

### 7. Clean up

**Native Daytona sandboxes persist after runs and keep their disk until deleted.** Closing a backend's client does not delete its sandbox. The example therefore deletes the sandbox explicitly in `finally`, then releases the caller-owned client's resources through `aclosing`.

Download any files you want to keep before cleanup.

The example deletes its sandbox before it exits. If your application keeps a sandbox instead, for example to continue work in a later session, store its ID (the script prints it after the first run). A later process can rebuild the ref from that ID, pass it as `workspace=ref` to reattach, or delete the sandbox:


```python
from pydantic_ai.workspaces import WorkspaceRef
from pydantic_ai_harness.daytona_sandbox import DaytonaSandbox

ref = WorkspaceRef(provider="daytona", id="YOUR_SANDBOX_ID")
await DaytonaSandbox().destroy(ref)
```


Deleting by ref does not start a stopped sandbox first. The ref holds no credentials, so the process that deletes or reattaches still needs `DAYTONA_API_KEY`.

### 8. Running the Example

Run the complete script from the example directory:


```bash
python main.py
```


#### Example Output

These excerpts come from a real Daytona sandbox and `anthropic:claude-sonnet-5`. Long file payloads and some tool results are omitted. Model responses and test counts can vary.

```text
Sandbox: fee5575f-8885-4ca8-a32c-02e7724bc894

Run 1 tool trace:
  -> list_files {"path":"."}
  <- list_files No files found.
  ... write_file calls created stats.py and test_stats.py ...
  -> shell {"command":"cd /home/daytona/project && python -m pytest -q 2>&1 | tail -30"}
  <- shell /usr/local/bin/python: No module named pytest ...
  -> shell {"command":"cd /home/daytona/project && pip install pytest -q 2>&1 | tail -20"}
  ... installation output omitted ...
  -> shell {"command":"cd /home/daytona/project && python -m pytest -q 2>&1 | tail -30"}
  <- shell .......... [100%] 10 passed in 0.03s ...
  ... workspace listing and cache cleanup omitted ...
{
  "files": [
    "stats.py",
    "test_stats.py"
  ],
  "tests_passed": 10,
  "tests_failed": 0,
  "pytest_summary": "10 passed in 0.03s"
}

Reattaching: fee5575f-8885-4ca8-a32c-02e7724bc894

Run 2 tool trace:
  -> read_file {"path":"stats.py"}
  -> read_file {"path":"test_stats.py"}
  ... edit_file added test_negative_values ...
  -> shell {"command":"cd /home/daytona/project && python -m pytest -q"}
  <- shell ........... [100%] 11 passed in 0.02s ...
{
  "files": [
    "test_stats.py"
  ],
  "tests_passed": 11,
  "tests_failed": 0,
  "pytest_summary": "11 passed in 0.02s"
}
Run 2 workspace: WorkspaceRef(provider='daytona', id='fee5575f-8885-4ca8-a32c-02e7724bc894')
Deleted sandbox: fee5575f-8885-4ca8-a32c-02e7724bc894
```

#### Understanding the Agent's Execution Flow

1. **Inspect the workspace:** The first run found no project files.
2. **Write the implementation and tests:** The agent created `stats.py` and ten tests in `test_stats.py`.
3. **Correct a missing dependency:** The first pytest invocation reported `No module named pytest`. The agent installed pytest inside Daytona and reran the suite: `10 passed in 0.03s`. No implementation fix was needed in this run.
4. **Reattach by ref:** The second run used the same sandbox ID, read both files, and added `test_negative_values` without replacing the original tests.
5. **Run the complete suite:** The second run returned `11 passed in 0.02s`. Both typed reports matched the final pytest results.
6. **Delete the sandbox:** The application's `finally` block deleted it.

### 9. Alternative: pydantic-ai-daytona

Daytona also maintains [`pydantic-ai-daytona`](https://pypi.org/project/pydantic-ai-daytona/) ([source](https://github.com/daytona/integrations/tree/main/packages/pydantic-ai-daytona)), a Pydantic AI capability that works without the harness. The two approaches differ as follows:

| **Feature** | **Native integration** | **pydantic-ai-daytona** |
| ----------- | ---------------------- | ----------------------- |
| Tools | None of its own; `Coder` and other harness capabilities supply them | Its own `run_command`, `read_file`, `write_file`, and `list_directory` |
| Dependencies | `pydantic-ai-harness`; Python 3.11+ | `pydantic-ai-slim` and the Daytona SDK; Python 3.10+ |
| Sandbox lifetime | Persists after a run until you delete it | Created per run and deleted when the run ends (default) |
| Reuse across runs | Reattach by ref or message history | Share one `DaytonaSandboxSession`, or attach with `sandbox_id=` |
| Sandbox creation options | `snapshot`, `allow_internet_access`, `auto_stop_interval` | Also a container `image`, network allow lists by CIDR or domain, `ephemeral`, `auto_delete_interval`, `labels`, and `os_user` |

Use the native integration to run harness capabilities such as `Coder` in Daytona and to keep workspaces between runs. Use the package for a self-contained capability that cleans up after each run and offers more control over how the sandbox is created.

**Both packages export a class named `DaytonaSandbox`. The import path decides which one you get.** Use one or the other, not both.

Install it in the same virtual environment as the harness; the Anthropic provider is already installed above:

```bash
pip install -U "pydantic-ai-daytona>=0.2.1"
```

With the same API keys loaded, inside an async function:


```python
import os

from pydantic_ai import Agent
from pydantic_ai_daytona import DaytonaSandbox

model = os.getenv("MODEL", "anthropic:claude-sonnet-5")
agent = Agent(model, capabilities=[DaytonaSandbox()])
result = await agent.run("Run python -c 'print(2 + 2)' in the sandbox and report the result.")
print(result.output)
```


The agent runs the command with the package's `run_command` tool and replies with something like:

```text
The command `python -c 'print(2 + 2)'` output: **4**
```

#### Reuse one sandbox across runs

`DaytonaSandboxSession` owns one sandbox for as long as its `async with` block runs. Pass it to the capability to share that sandbox across runs; the capability uses the session but never closes it. With `model` from the previous snippet:


```python
from pydantic_ai import Agent
from pydantic_ai_daytona import DaytonaSandbox, DaytonaSandboxSession

async with DaytonaSandboxSession() as session:
    agent = Agent(model, capabilities=[DaytonaSandbox(session=session)])
    await agent.run("Create hello.py that prints 'hello from Daytona'.")
    result = await agent.run("Run hello.py and report its output.")
    print(result.output)
```


The second run finds the `hello.py` that the first run created and reports `hello from Daytona`. The sandbox is deleted when the block exits. A sandbox you attach with `sandbox_id=` instead is never deleted by the package.

### 10. Complete Implementation

See the [complete runnable example](https://github.com/daytona/guides/tree/main/python/pydantic-ai/coding-agent) for `main.py`, the environment template, bounded tool traces, and cleanup that retains access to the sandbox ref after a failed run.

---

**Key advantages of this approach:**

- **Isolated execution**: Generated code and commands run in a Daytona sandbox, never on the machine running the agent, and your API keys stay on that machine
- **Persistent workspaces**: A later run reattaches by ref and continues with the same files instead of starting over
- **Typed results**: Each run returns a validated `TestReport` that your code can act on, instead of free text to parse
- **Self-verifying agent**: The agent runs pytest in the sandbox and fixes failures, including missing dependencies, before it reports
- **Any harness capability**: `DaytonaSandbox` supplies the workspace, so `Coder` and other harness capabilities run their tools in Daytona
- **Explicit cleanup**: The application decides when the sandbox is deleted, including after a failed run