name: Agent Lab Project Skills description: Library-specific rules, best practices, and gotchas for the Agent Lab tech stack
Agent Lab Project Skills
MANDATORY: Read this file BEFORE writing any code for the Agent Lab project. This is a living document — update it when you discover new gotchas or patterns.
Backend — Python 3.11+
FastAPI
Rules:
- Always use
async deffor route handlers — this project is fully async - Use Pydantic v2 model_validator / field_validator, NOT the deprecated v1
@validator - Return explicit status codes:
status_code=201for creation,204for deletion - Use
Depends()for dependency injection (DB sessions, auth, settings) - Use
APIRouterto organize routes by domain (agents, runs, skills, settings) - WebSocket endpoints go through FastAPI's
@app.websocket(), not a separate library - Use
lifespancontext manager for startup/shutdown, NOT the deprecated@app.on_event()
Patterns:
# ✅ Correct — lifespan context manager
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
# startup
await init_db()
yield
# shutdown
app = FastAPI(lifespan=lifespan)
# ❌ Wrong — deprecated events
@app.on_event("startup")
async def startup():
...
# ✅ Correct — Pydantic v2 validators
from pydantic import BaseModel, field_validator
class AgentCreate(BaseModel):
name: str
system_prompt: str
@field_validator("name")
@classmethod
def name_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("Name cannot be empty")
return v.strip()
# ❌ Wrong — Pydantic v1 style
from pydantic import validator # deprecated
Gotchas:
JSONResponseauto-serializes dicts but NOT SQLAlchemy models — always convert to Pydantic first- WebSocket connections must be explicitly accepted with
await websocket.accept() - Background tasks via
BackgroundTasksare fire-and-forget; use for non-critical work only
SQLAlchemy 2.0
Rules:
- Use the 2.0-style query API (
select(),session.execute()), NOT the legacysession.query() - Use
Mapped[]andmapped_column()for model definitions, NOT the oldColumn()style - Always use
async_sessionmakerandAsyncSession— this project is fully async - Use
AsyncEnginecreated viacreate_async_engine() - SQLite async requires
aiosqlitedriver:sqlite+aiosqlite:///path/to/db
Patterns:
# ✅ Correct — 2.0 style with async
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class Agent(Base):
__tablename__ = "agents"
id: Mapped[str] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(default=func.now())
# Querying
async def get_agent(session: AsyncSession, agent_id: str):
result = await session.execute(select(Agent).where(Agent.id == agent_id))
return result.scalar_one_or_none()
# ❌ Wrong — legacy 1.x style
class Agent(Base):
id = Column(String, primary_key=True) # old style
agent = session.query(Agent).filter_by(id=id).first() # old style
Gotchas:
- SQLite does NOT support
ALTER TABLEfor all operations — use batch mode in Alembic migrations - Async SQLite sessions are NOT thread-safe; don't share across threads
- Always
await session.commit()— forgetting theawaitsilently does nothing - Use
session.refresh(obj)after commit if you need auto-generated fields (likeid)
Python Docker SDK (docker package)
Rules:
- Use
docker.DockerClient.from_env()to connect (this readsDOCKER_HOSTor the socket) - Always set resource limits:
mem_limit,cpu_period,cpu_quota - Always set
auto_remove=Trueor clean up containers manually in afinallyblock - Mount user files as read-only volumes:
{'/host/path': {'bind': '/container/path', 'mode': 'ro'}} - Use
container.logs(stream=True)for real-time log streaming - Set timeouts with
container.stop(timeout=N)to avoid hanging containers
Patterns:
# ✅ Correct — container lifecycle with cleanup
import docker
client = docker.DockerClient.from_env()
try:
container = client.containers.run(
image="python:3.11-slim",
command="python /app/agent.py",
volumes={input_dir: {"bind": "/app/inputs", "mode": "ro"}},
mem_limit="512m",
cpu_quota=50000, # 50% of one CPU
detach=True,
network_mode="none", # no network access by default
)
for log_line in container.logs(stream=True, follow=True):
yield log_line.decode("utf-8")
container.wait(timeout=300)
finally:
try:
container.remove(force=True)
except docker.errors.NotFound:
pass
Gotchas:
- Docker socket path differs by OS:
/var/run/docker.sock(Linux/Mac) vs named pipe on Windows container.logs()withoutstream=Trueblocks until container finishescontainer.wait()can hang forever if no timeout is set- Image pulls are slow — pre-pull base images on startup
LLM Provider SDKs
OpenAI SDK (openai)
Rules:
- Use the new client-based API:
OpenAI()/AsyncOpenAI(), NOT the old module-levelopenai.ChatCompletion - Always use
AsyncOpenAIin this project (we're fully async) - Use streaming (
stream=True) for real-time log output - Track
usage.prompt_tokensandusage.completion_tokensfrom the response for cost calculation
Patterns:
# ✅ Correct — new async client
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key=api_key)
response = await client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "system", "content": prompt}],
stream=True,
)
async for chunk in response:
if chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
# ❌ Wrong — old module-level API
import openai
openai.api_key = "..."
openai.ChatCompletion.create(...) # deprecated
Anthropic SDK (anthropic)
Rules:
- Use
AsyncAnthropic()client, NOT synchronous - Anthropic uses
max_tokens(required), NOTmax_completion_tokens - System prompt goes in the
systemparameter, NOT as a message - Use
client.messages.stream()for streaming
Patterns:
# ✅ Correct
from anthropic import AsyncAnthropic
client = AsyncAnthropic(api_key=api_key)
async with client.messages.stream(
model="claude-sonnet-4-20250514",
max_tokens=4096,
system="You are a helpful assistant.",
messages=[{"role": "user", "content": task}],
) as stream:
async for text in stream.text_stream:
yield text
# ❌ Wrong — system prompt as message
messages=[{"role": "system", "content": "..."}] # Anthropic doesn't use this
OpenRouter (via OpenAI-compatible API)
Rules:
- Use
AsyncOpenAIwithbase_url="https://openrouter.ai/api/v1" - Set
HTTP-RefererandX-Titleheaders for proper attribution - Model names use provider prefix:
"openai/gpt-4o","anthropic/claude-sonnet-4-20250514"
Ollama
Rules:
- Use
AsyncOpenAIwithbase_url="http://host.docker.internal:11434/v1"(from inside Docker) - No API key needed — pass
api_key="ollama"(placeholder) - Token usage may not be reported — handle
Noneusage gracefully - Model pull must happen before first use
Cryptography (cryptography library)
Rules:
- Use
Fernetfor symmetric encryption of API keys - Generate the encryption key ONCE and store in
~/.agent-lab/.key - NEVER log or print decrypted API keys
- NEVER commit the key file to git
Patterns:
from cryptography.fernet import Fernet
# Generate key (once, on first run)
key = Fernet.generate_key()
# Encrypt
f = Fernet(key)
encrypted = f.encrypt(api_key.encode())
# Decrypt
decrypted = f.decrypt(encrypted).decode()
Frontend — React 18+ with TypeScript
Vite
Rules:
- Config in
vite.config.ts, NOTwebpack.config.js - Use
import.meta.env.VITE_*for environment variables, NOTprocess.env - Proxy API requests to backend in dev via
server.proxyin vite config
Patterns:
// vite.config.ts
export default defineConfig({
server: {
proxy: {
'/api': 'http://localhost:8000',
'/ws': { target: 'ws://localhost:8000', ws: true },
},
},
});
shadcn/ui
Rules:
- Components are copied into your project (not imported from a package) — they live in
src/components/ui/ - Install components via CLI:
npx shadcn@latest add button(NOTnpm install) - shadcn uses Radix primitives under the hood — refer to Radix docs for advanced behavior
- Use the
cn()utility fromlib/utils.tsfor conditional class merging (it wrapsclsx+tailwind-merge) - Do NOT override shadcn component files directly — create wrapper components instead
Patterns:
// ✅ Correct — wrapper component
import { Button } from "@/components/ui/button";
export function PrimaryButton({ children, ...props }) {
return <Button variant="default" size="lg" {...props}>{children}</Button>;
}
// ❌ Wrong — editing src/components/ui/button.tsx directly
Tailwind CSS
Rules:
- Configure in
tailwind.config.ts(TypeScript), NOT.js - Use CSS variables for theming (shadcn pattern):
hsl(var(--primary)) - Use
@applysparingly — prefer utility classes in JSX - Dark mode via
classstrategy (notmedia)
React Patterns
Rules:
- Use functional components with hooks — NO class components
- Use
React.Context+ custom hooks for state management, NOT Redux - WebSocket connections go in a custom hook (
useWebSocket) with cleanup inuseEffectreturn - Use
useCallbackanduseMemofor expensive operations passed as props - File upload: use native
<input type="file">wrapped in a styled drop zone, NOT a third-party library
Patterns:
// ✅ Correct — WebSocket hook with cleanup
function useWebSocket(url: string) {
const [messages, setMessages] = useState<string[]>([]);
useEffect(() => {
const ws = new WebSocket(url);
ws.onmessage = (e) => setMessages(prev => [...prev, e.data]);
return () => ws.close(); // cleanup
}, [url]);
return messages;
}
Docker Compose
Rules:
- Use
docker-compose.ymlv3.8+ syntax - Backend mounts Docker socket:
/var/run/docker.sock:/var/run/docker.sock - Use named volumes for persistent data, NOT bind mounts for the database
- Frontend dev server proxies to backend — no direct backend access from browser
- Set
restart: unless-stoppedfor production,restart: "no"for dev - Use
healthcheckon backend service before frontend depends on it
General Project Rules
- All backend code is async — never use synchronous I/O in route handlers
- All database operations go through SQLAlchemy async sessions — no raw
sqlite3calls - API keys are NEVER logged, printed, or included in error messages
- Every Docker container created must be cleaned up — use try/finally
- Cost tracking is mandatory for every LLM call — track tokens and compute cost
- WebSocket messages use JSON format with a
typefield for message discrimination - File uploads are validated — check file size, type, and sanitize names before storage
- All user-facing errors return structured JSON —
{"error": "message", "detail": "..."}
Environment & Shell — PowerShell (Windows)
Rules:
- NEVER use
&&for command chaining — most versions of PowerShell (including the user's) do not support it and will throw aParserError. - Use
;(semicolon) for unconditional sequential execution. - Use
$?;for conditional execution (equivalent to&&) if the previous command SUCCESS is mandatory. - Prefer separate
run_commandcalls for multi-step operations to improve error visibility and reliability.
Patterns:
# ✅ Correct
git add .; git commit -m "update"
# ❌ Wrong
git add . && git commit -m "update"