A typed Python foundation for a public library of versioned prompts, reusable Pydantic AI tools, and observable OpenAI agents. Every agent created by the included factory is instrumented with Pydantic Logfire, so agent runs, model requests, and tool calls appear in one trace.
agentic-openai-starter/
├── agents/
│ ├── __init__.py
│ ├── base.py # Agent factory and Logfire lifecycle
│ └── py.typed
├── prompts/
│ ├── catalog/
│ │ └── browser-researcher/
│ │ └── 1.0.0.md # Immutable system-prompt version
│ ├── __init__.py
│ ├── registry.py # Safe lookup, SemVer selection, SHA-256 identity
│ └── py.typed
├── skills/
│ ├── __init__.py
│ ├── browser.py # Typed async Pydantic AI tool
│ └── py.typed
├── tests/
│ ├── test_agent.py
│ ├── test_browser.py
│ ├── test_prompt_registry.py
│ └── test_runtime.py
├── .env.example
├── .gitignore
├── LICENSE
├── main.py # Async CLI entrypoint
├── pyproject.toml
└── uv.lock
The boundaries are intentional: prompt content is data, skills are reusable functions with injected dependencies, agent modules compose prompts and skills, and entrypoints own resource lifetimes. Adding dozens of agents does not require a global agent registry or import-time network clients.
Install uv first, then run these commands from the copied repository directory:
git init -b main
uv sync --all-groups
cp .env.example .env.local
chmod 600 .env.local
read -rsp "OpenAI API key: " OPENAI_API_KEY; echo
export OPENAI_API_KEY
python3 - <<'PY'
import os
from pathlib import Path
path = Path(".env.local")
lines = [
line
for line in path.read_text(encoding="utf-8").splitlines()
if not line.startswith("OPENAI_API_KEY=")
]
lines.insert(0, f"OPENAI_API_KEY={os.environ['OPENAI_API_KEY']}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
unset OPENAI_API_KEY
uv run logfire auth
uv run logfire projects new agentic-openai-starter
uv run python main.pyThe Logfire commands open a browser for authentication and create the local .logfire/ credentials used by logfire.configure(). Both .logfire/ and .env.local are ignored by Git. If your organization already has the project, select it instead:
uv run logfire projects use agentic-openai-starter --org your-organizationThe default request tells the model to open https://example.com. A successful run prints the answer in the terminal and produces one Logfire trace containing the named browser_research_agent run, OpenAI model request, and browse_url tool call. Open the project URL shown by:
uv run logfire whoamiTo run a different request, pass it as one quoted argument. Add every destination host to BROWSER_ALLOWED_HOSTS in .env.local before using it:
uv run python main.py "Open https://example.com and summarize the page in one sentence."| Variable | Required | Default | Purpose |
|---|---|---|---|
OPENAI_API_KEY |
yes | none | OpenAI project API key; never logged or committed |
AGENT_MODEL |
no | openai:gpt-5.6-luna |
Pydantic AI provider-qualified model name |
APP_ENVIRONMENT |
no | development |
Logfire environment attribute |
LOGFIRE_SERVICE_NAME |
no | agentic-openai-starter |
Service name shown in Logfire |
BROWSER_ALLOWED_HOSTS |
no | example.com,www.example.com |
Comma-separated exact HTTPS host allowlist |
The browser skill does not follow redirects, accept embedded URL credentials, use nonstandard ports, or read non-text responses. It stops after 100 KB. Those defaults reduce SSRF and unbounded-context risk; extend them deliberately rather than disabling them globally.
Prompt identity is the directory name plus a MAJOR.MINOR.PATCH Markdown filename:
prompts/catalog/release-notes/1.0.0.md
prompts/catalog/release-notes/1.1.0.md
Use lowercase kebab-case for the name. Never edit a released prompt file in place: add a new version. PromptRegistry.load("release-notes") selects 1.1.0; PromptRegistry.load("release-notes", "1.0.0") pins the old behavior. Every resolved prompt carries a SHA-256 digest into the agent metadata, making a trace attributable to exact prompt content.
Create an agent with the new prompt by calling the existing factory:
from pydantic_ai import Agent
from agents.base import build_agent
from skills.browser import BrowserSession
release_notes_agent: Agent[BrowserSession, str] = build_agent(
prompt_name="release-notes",
prompt_version="1.1.0",
)For a distinct production agent, add another factory under agents/, call configure_observability() before constructing it, and give it a unique Pydantic AI name so Logfire traces remain searchable.
A skill is a typed function. Use RunContext[DependencyType] when it needs clients, configuration, identity, or other runtime resources; keep construction and cleanup in the entrypoint. Pydantic AI derives the JSON tool schema from the function signature and docstring.
from dataclasses import dataclass
from pydantic_ai import RunContext
@dataclass(frozen=True, slots=True)
class AccountDependencies:
account_id: str
async def get_account_id(ctx: RunContext[AccountDependencies]) -> str:
"""Return the authenticated account identifier."""
return ctx.deps.account_idExport the function from skills/__init__.py, register it in the target agent's tools=[...], and add tests that invoke its real dependency boundary. Avoid module-level clients: injected dependencies make tools async-safe, testable, and reusable across agents.
Run the complete local gate before opening a pull request:
uv lock --check
uv run ruff check .
uv run ruff format --check .
uv run mypy
uv run pytest -q
uv buildLogfire's Pydantic AI integration records prompt and completion content by default. Review the Logfire integration and content controls before sending sensitive data. OpenAI model names and availability change over time; the current catalog is documented in the OpenAI model guide.
MIT. See LICENSE.