skills-ws
Version:
85 agent skills for AI coding assistants — marketing, growth, web3, dev, design & operations. Built for OpenClaw, Claude Code, Cursor, and Codex.
1,174 lines (938 loc) • 42.1 kB
Markdown
---
name: ai-agent-building
description: "Build production AI agents — LangGraph state machines, CrewAI teams, tool design, memory, RAG, MCP, multi-agent orchestration, evals, cost control, and safety. Use when building LangGraph/CrewAI agents, designing or validating tools, wiring RAG or MCP, adding human-in-the-loop, or running agent evals and safety reviews."
---
# AI Agent Building
## Agent Architecture Fundamentals
An AI agent is an LLM that can take actions. That's it. Everything else is engineering around that core loop:
```
Observe → Think → Act → Observe → Think → Act → ...
```
The complexity comes from: which actions? how to recover from failures? how to know when to stop? how to not bankrupt you on API calls?
---
## LangGraph: State Machine Agents
LangGraph is the production-grade choice for complex agents. It gives you explicit control flow, checkpointing, and human-in-the-loop — things you need in production but that simple chains don't offer.
### Basic Agent with Tool Calling
```python
# pip install langgraph langchain-openai langgraph-checkpoint-sqlite
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
# Define state
class AgentState(TypedDict):
messages: Annotated[list, add_messages]
# Define tools
@tool
def search_database(query: str) -> str:
"""Search the product database for items matching the query."""
# Real implementation here
return f"Found 3 products matching '{query}': Widget A ($10), Widget B ($20), Widget C ($30)"
@tool
def create_order(product_name: str, quantity: int) -> str:
"""Create an order for a product."""
order_id = f"ORD-{hash(product_name) % 10000:04d}"
return f"Order {order_id} created: {quantity}x {product_name}"
tools = [search_database, create_order]
model = ChatOpenAI(model="gpt-5", temperature=0).bind_tools(tools)
# Define nodes
def agent(state: AgentState) -> AgentState:
response = model.invoke(state["messages"])
return {"messages": [response]}
def should_continue(state: AgentState) -> str:
last_message = state["messages"][-1]
if last_message.tool_calls:
return "tools"
return END
# Build graph
graph = StateGraph(AgentState)
graph.add_node("agent", agent)
graph.add_node("tools", ToolNode(tools))
graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END})
graph.add_edge("tools", "agent")
app = graph.compile()
# Run
result = app.invoke({
"messages": [{"role": "user", "content": "Find me a widget under $15 and order 2 of them"}]
})
```
### Human-in-the-Loop with `interrupt()` and Checkpointing
The modern pattern (LangGraph 0.2.x+) uses the `interrupt()` function to pause *inside* a node and `Command(resume=...)` to feed a decision back. The value passed to `Command(resume=...)` becomes the return value of `interrupt()`, so you must **actually check it** before executing the side-effecting tool — never blindly continue into the tool node. Requires a checkpointer and a stable `thread_id`.
```python
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
from langgraph.types import interrupt, Command
from langgraph.checkpoint.sqlite import SqliteSaver # pip install langgraph-checkpoint-sqlite
# For pure in-memory dev use: from langgraph.checkpoint.memory import InMemorySaver
class AgentState(TypedDict):
messages: Annotated[list, add_messages]
def agent(state: AgentState) -> AgentState:
return {"messages": [model.invoke(state["messages"])]}
def route_after_agent(state: AgentState) -> str:
last = state["messages"][-1]
if not getattr(last, "tool_calls", None):
return END
# High-stakes tools go through approval; everything else runs directly.
if any(tc["name"] == "create_order" for tc in last.tool_calls):
return "approval"
return "tools"
def approval(state: AgentState) -> Command:
"""Pause and surface the pending order to a human. The resumed value is the decision."""
last = state["messages"][-1]
order_calls = [tc for tc in last.tool_calls if tc["name"] == "create_order"]
# interrupt() returns whatever the human passes via Command(resume=...)
decision = interrupt({
"action": "approve_order",
"orders": [tc["args"] for tc in order_calls],
"prompt": "Approve these orders? Reply {'approved': bool, 'reason': str}",
})
if not decision.get("approved"):
# Reject: feed a tool message back so the agent can apologize / replan.
# Do NOT fall through to the tools node.
from langchain_core.messages import ToolMessage
return Command(
goto="agent",
update={"messages": [
ToolMessage(
content=f"Order rejected by human: {decision.get('reason', 'no reason given')}",
tool_call_id=tc["id"],
) for tc in order_calls
]},
)
# Approved: now (and only now) proceed to execute the tool.
return Command(goto="tools")
graph = StateGraph(AgentState)
graph.add_node("agent", agent)
graph.add_node("tools", ToolNode(tools))
graph.add_node("approval", approval) # returns Command, so its targets are dynamic
graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", route_after_agent,
{"tools": "tools", "approval": "approval", END: END})
graph.add_edge("tools", "agent")
# Compile with a checkpointer — required for interrupt/resume.
with SqliteSaver.from_conn_string(":memory:") as checkpointer:
app = graph.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "order-123"}}
# First run stops at interrupt(); the payload appears under "__interrupt__".
result = app.invoke(
{"messages": [{"role": "user", "content": "Order 5 Widget As"}]},
config=config,
)
print(result["__interrupt__"]) # show the orders to the human / UI
# Human decides. Resume by passing the decision into interrupt() via Command(resume=...).
final = app.invoke(Command(resume={"approved": True}), config=config)
# To deny instead: app.invoke(Command(resume={"approved": False, "reason": "over budget"}), config=config)
```
> `interrupt()` replaces the old `interrupt_before=[...]` / `app.invoke(None, config)` resume idiom, which paused *before* a node but did not let you pass or inspect an approval value. Note `SqliteSaver.from_conn_string` is now a context manager; for persistence on disk use a file path instead of `":memory:"`.
### TypeScript LangGraph
```typescript
import { StateGraph, START, END, Annotation } from "@langchain/langgraph";
import { ChatOpenAI } from "@langchain/openai";
import { ToolNode } from "@langchain/langgraph/prebuilt";
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { BaseMessage, HumanMessage } from "@langchain/core/messages";
// State definition
const AgentState = Annotation.Root({
messages: Annotation<BaseMessage[]>({
reducer: (prev, next) => [...prev, ...next],
}),
});
// Tools
const searchTool = tool(
async ({ query }) => {
return `Results for "${query}": Product A, Product B`;
},
{
name: "search",
description: "Search the product database",
schema: z.object({ query: z.string() }),
}
);
const model = new ChatOpenAI({ model: "gpt-5", temperature: 0 }).bindTools([searchTool]);
// Nodes
async function agent(state: typeof AgentState.State) {
const response = await model.invoke(state.messages);
return { messages: [response] };
}
function shouldContinue(state: typeof AgentState.State) {
const lastMsg = state.messages[state.messages.length - 1];
if ("tool_calls" in lastMsg && lastMsg.tool_calls?.length) {
return "tools";
}
return END;
}
// Graph
const graph = new StateGraph(AgentState)
.addNode("agent", agent)
.addNode("tools", new ToolNode([searchTool]))
.addEdge(START, "agent")
.addConditionalEdges("agent", shouldContinue, { tools: "tools", [END]: END })
.addEdge("tools", "agent");
const app = graph.compile();
const result = await app.invoke({
messages: [new HumanMessage("Find products related to widgets")],
});
```
---
## CrewAI: Multi-Agent Teams
```python
# pip install crewai crewai-tools
from crewai import Agent, Task, Crew, Process
from crewai_tools import SerperDevTool, ScrapeWebsiteTool
# Define specialized agents
researcher = Agent(
role="Senior Research Analyst",
goal="Find comprehensive, accurate information about the given topic",
backstory="You're a seasoned researcher with 15 years of experience in market analysis.",
tools=[SerperDevTool(), ScrapeWebsiteTool()],
verbose=True,
allow_delegation=False,
llm="gpt-5",
)
writer = Agent(
role="Technical Writer",
goal="Create clear, engaging content based on research findings",
backstory="You're a technical writer who excels at making complex topics accessible.",
verbose=True,
llm="gpt-5",
)
editor = Agent(
role="Editor",
goal="Review and polish the content for accuracy, clarity, and engagement",
backstory="You're a meticulous editor with an eye for detail and factual accuracy.",
verbose=True,
llm="gpt-5",
)
# Define tasks
research_task = Task(
description="Research the current state of {topic}. Find key trends, statistics, and expert opinions.",
expected_output="A comprehensive research brief with key findings, statistics, and sources.",
agent=researcher,
)
writing_task = Task(
description="Write a 1500-word article based on the research brief.",
expected_output="A well-structured article with introduction, key sections, and conclusion.",
agent=writer,
context=[research_task], # Uses output from research
)
editing_task = Task(
description="Edit the article for clarity, accuracy, and engagement. Fix any factual errors.",
expected_output="A polished, publication-ready article.",
agent=editor,
context=[writing_task],
)
# Assemble crew
crew = Crew(
agents=[researcher, writer, editor],
tasks=[research_task, writing_task, editing_task],
process=Process.sequential, # or Process.hierarchical with a manager
verbose=True,
)
result = crew.kickoff(inputs={"topic": "AI agents in production"})
```
---
## Tool Design: Best Practices
### Error Recovery and Timeout Handling
```python
import asyncio
from functools import wraps
from langchain_core.tools import tool
def with_timeout(seconds: int = 30):
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
try:
return await asyncio.wait_for(func(*args, **kwargs), timeout=seconds)
except asyncio.TimeoutError:
return f"Error: Tool timed out after {seconds}s. Try a simpler query."
return wrapper
return decorator
def with_retry(max_retries: int = 3):
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
last_error = None
for attempt in range(max_retries):
try:
return await func(*args, **kwargs)
except Exception as e:
last_error = e
if attempt < max_retries - 1:
await asyncio.sleep(2 ** attempt)
return f"Error after {max_retries} retries: {str(last_error)}"
return wrapper
return decorator
@tool
@with_retry(3)
@with_timeout(30)
async def query_database(sql: str) -> str:
"""Run a read-only SELECT against the analytics warehouse and return rows.
Args:
sql: A single SELECT statement. No DML/DDL, no multiple statements.
"""
try:
validated = validate_readonly_sql(sql, allowed_tables={"orders", "products", "customers"})
except ValueError as e:
return f"Error: {e}"
# Defense in depth: the LLM-facing connection uses a DB role that only has
# SELECT on the allowed schema (see note below) AND a per-statement timeout.
rows = await ro_db.execute(validated, timeout_s=10) # ro_db = read-only-role pool
if len(rows) > 50:
return f"Query returned {len(rows)} rows (showing first 20):\n{format_rows(rows[:20])}"
return format_rows(rows)
```
**Why the old `"DROP" in sql.upper()` blocklist is not production-safe:** substring checks are trivially bypassed (`/*DROP*/`, `dr"||"op`, a column literally named `update_ts`), they still allow stacked statements (`SELECT 1; DELETE ...`), CTE-wrapped writes, `pg_sleep()`-style DoS, schema enumeration via `information_schema`/`pg_catalog`, and cross-tenant reads. **Allowlist with a real SQL parser instead of blocklisting.** Use `sqlglot` to parse to an AST, reject anything that isn't exactly one `SELECT`, and enforce table allowlist + tenant scoping:
```python
# pip install sqlglot
import sqlglot
from sqlglot import exp
def validate_readonly_sql(sql: str, allowed_tables: set[str], tenant_id: str | None = None) -> str:
statements = sqlglot.parse(sql, read="postgres")
if len(statements) != 1:
raise ValueError("Exactly one statement is allowed (no stacked queries).")
tree = statements[0]
# 1. Top level must be a pure SELECT (this also rejects INSERT/UPDATE/DELETE/DDL,
# and SELECT ... INTO / data-modifying CTEs at the root).
if not isinstance(tree, exp.Select):
raise ValueError("Only SELECT statements are allowed.")
# 2. No write expressions or unsafe constructs anywhere in the tree.
banned = (exp.Insert, exp.Update, exp.Delete, exp.Drop, exp.Alter,
exp.Create, exp.Command, exp.Merge, exp.Into, exp.Set)
if any(node for node in tree.walk() if isinstance(node, banned)):
raise ValueError("Query contains a forbidden write/DDL operation.")
# 3. Allowlist every referenced table; block catalog/schema probing.
for tbl in tree.find_all(exp.Table):
name = tbl.name.lower()
if tbl.db and tbl.db.lower() in ("information_schema", "pg_catalog"):
raise ValueError("System catalog access is not allowed.")
if name not in allowed_tables:
raise ValueError(f"Table '{name}' is not allowed.")
# 4. Force a hard row cap (LLMs forget LIMIT; large scans cost money / leak data).
if not tree.args.get("limit"):
tree = tree.limit(1000)
# 5. (Multi-tenant) inject a tenant filter so the agent can never read other tenants.
if tenant_id is not None:
tree = tree.where(exp.condition(f"tenant_id = {sqlglot.exp.Literal.string(tenant_id)}"))
return tree.sql(dialect="postgres")
```
Layer this with infrastructure controls — the validator is the inner ring, not the only ring:
- **Dedicated read-only role.** Run agent queries on a connection whose Postgres role has `SELECT` only, on a restricted schema/view: `GRANT SELECT ON orders, products, customers TO agent_ro;` and nothing else. Even a parser bypass then cannot write.
- **Statement timeout.** `SET statement_timeout = '10s'` on that role/session to kill `pg_sleep`-style or runaway scans.
- **Prefer views.** Expose curated, pre-joined, already tenant-scoped views (e.g. `agent_orders_v`) and allowlist only those — never base tables.
- **Parameterize the tenant id**; never string-format untrusted values into SQL elsewhere in your app.
### Tool Design Rules
1. **Clear descriptions** — the LLM reads them to decide when to use the tool
2. **Validate inputs** — never trust LLM-generated parameters
3. **Return errors as strings** — don't throw exceptions, let the agent recover
4. **Limit output size** — truncate large results, the context window is precious
5. **Make tools idempotent** where possible — agents retry
6. **Include examples in docstrings** — helps the LLM use tools correctly
---
## Memory Patterns
### Conversation Buffer with Sliding Window
```python
from langchain_core.messages import trim_messages
# Keep last N messages, but always keep the system message
trimmer = trim_messages(
max_tokens=4000,
strategy="last",
token_counter=model,
include_system=True,
allow_partial=False,
)
# In your agent node
def agent(state: AgentState) -> AgentState:
trimmed = trimmer.invoke(state["messages"])
response = model.invoke(trimmed)
return {"messages": [response]}
```
### Summary Memory
```python
from langchain_core.messages import SystemMessage
async def maybe_summarize(state: AgentState) -> AgentState:
messages = state["messages"]
if len(messages) < 20:
return state
# Summarize older messages, keep recent ones
old_messages = messages[1:-10] # Skip system, keep last 10
recent = messages[-10:]
summary = await model.ainvoke([
SystemMessage(content="Summarize this conversation concisely, preserving key facts and decisions:"),
*old_messages,
])
return {
"messages": [
messages[0], # System message
SystemMessage(content=f"Previous conversation summary: {summary.content}"),
*recent,
]
}
```
### Vector Store Memory (Long-term)
```python
from datetime import datetime, timezone
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
memory_store = Chroma(
collection_name="agent_memory",
embedding_function=embeddings,
persist_directory="./memory_db",
)
@tool
def recall_memory(query: str) -> str:
"""Search past conversations and learned facts for relevant information."""
docs = memory_store.similarity_search(query, k=5)
if not docs:
return "No relevant memories found."
return "\n\n".join([
f"[{doc.metadata.get('timestamp', 'unknown')}] {doc.page_content}"
for doc in docs
])
@tool
def store_memory(fact: str, category: str = "general") -> str:
"""Store an important fact or learning for future reference."""
memory_store.add_texts(
texts=[fact],
metadatas=[{
"category": category,
"timestamp": datetime.now(timezone.utc).isoformat(),
}],
)
return f"Stored: {fact}"
```
---
## RAG Pipeline: Production Patterns
### Chunking Strategies
```python
from langchain_text_splitters import RecursiveCharacterTextSplitter, Language
# For general documents
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200,
separators=["\n\n", "\n", ". ", " ", ""],
length_function=len,
)
# For code
code_splitter = RecursiveCharacterTextSplitter.from_language(
language=Language.PYTHON,
chunk_size=1500,
chunk_overlap=200,
)
# For markdown with structure preservation
markdown_splitter = RecursiveCharacterTextSplitter.from_language(
language=Language.MARKDOWN,
chunk_size=1000,
chunk_overlap=100,
)
```
### Hybrid Search (Vector + Keyword)
```python
from langchain_community.retrievers import BM25Retriever
from langchain.retrievers import EnsembleRetriever
# Vector search (semantic)
vector_retriever = vector_store.as_retriever(search_kwargs={"k": 5})
# Keyword search (BM25)
bm25_retriever = BM25Retriever.from_documents(documents, k=5)
# Combine with weights
hybrid_retriever = EnsembleRetriever(
retrievers=[vector_retriever, bm25_retriever],
weights=[0.6, 0.4], # Favor semantic, but keyword catches exact matches
)
```
### Reranking
```python
from langchain.retrievers import ContextualCompressionRetriever
from langchain_cohere import CohereRerank
# Retrieve broadly, then rerank for precision
reranker = CohereRerank(model="rerank-english-v3.0", top_n=3)
retriever = ContextualCompressionRetriever(
base_compressor=reranker,
base_retriever=hybrid_retriever, # Gets 20 candidates
)
# Usage: retriever.invoke("How do I configure CORS?")
# Returns top 3 most relevant chunks from the initial 20
```
### Citation Pattern
```python
from langchain_core.prompts import ChatPromptTemplate
RAG_PROMPT = ChatPromptTemplate.from_messages([
("system", """Answer the question based on the provided context.
Include citations using [1], [2] etc. referencing the source documents.
If the context doesn't contain the answer, say so — don't make things up.
Context:
{context}"""),
("human", "{question}"),
])
def format_docs_with_citations(docs):
formatted = []
for i, doc in enumerate(docs, 1):
source = doc.metadata.get("source", "unknown")
formatted.append(f"[{i}] (Source: {source})\n{doc.page_content}")
return "\n\n".join(formatted)
```
---
## Multi-Agent Patterns
### Supervisor Pattern
```python
import json
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain_core.messages import SystemMessage
class SupervisorState(TypedDict):
messages: Annotated[list, add_messages]
next_agent: str
from typing import Literal
from pydantic import BaseModel
class Route(BaseModel):
next: Literal["researcher", "coder", "writer", "FINISH"]
# with_structured_output guarantees a parsed Route — don't json.loads(content),
# which breaks the moment the model wraps JSON in prose or a code fence.
router_model = supervisor_model.with_structured_output(Route)
def supervisor(state: SupervisorState) -> SupervisorState:
"""Route to the appropriate specialist agent."""
decision = router_model.invoke([
SystemMessage(content="""You are a supervisor routing tasks to specialists:
- researcher: for finding information
- coder: for writing or reviewing code
- writer: for creating content
Pick the next worker, or FINISH when the task is complete."""),
*state["messages"],
])
return {"next_agent": decision.next}
def route(state: SupervisorState) -> str:
return state["next_agent"]
graph = StateGraph(SupervisorState)
graph.add_node("supervisor", supervisor)
graph.add_node("researcher", researcher_agent)
graph.add_node("coder", coder_agent)
graph.add_node("writer", writer_agent)
graph.add_edge(START, "supervisor")
graph.add_conditional_edges("supervisor", route, {
"researcher": "researcher",
"coder": "coder",
"writer": "writer",
"FINISH": END,
})
# All agents report back to supervisor
for agent in ["researcher", "coder", "writer"]:
graph.add_edge(agent, "supervisor")
app = graph.compile()
```
---
## Production Concerns
### Cost Tracking
```python
import tiktoken
from contextlib import contextmanager
class CostTracker:
# USD per 1M tokens (input/output). List prices as of Jun 2026 — these move often;
# treat as a starting point and re-check the official pricing pages, ideally generating
# this dict from a dated constants file in CI:
# OpenAI: https://openai.com/api/pricing
# Anthropic: https://platform.claude.com/docs/en/about-claude/pricing
PRICES = {
"gpt-5.5": {"input": 5.00, "output": 30.00}, # flagship
"gpt-5.4": {"input": 2.50, "output": 15.00}, # production workhorse
"gpt-5.1": {"input": 1.25, "output": 10.00},
"gpt-5": {"input": 1.25, "output": 10.00},
"o3": {"input": 2.00, "output": 8.00}, # reasoning
"claude-opus-4-8": {"input": 5.00, "output": 25.00},
"claude-sonnet-4-6": {"input": 3.00, "output": 15.00},
"claude-haiku-4-5": {"input": 1.00, "output": 5.00},
}
def __init__(self):
self.total_input_tokens = 0
self.total_output_tokens = 0
self.total_cost = 0.0
self.calls = []
def track(self, model: str, input_tokens: int, output_tokens: int):
prices = self.PRICES.get(model, {"input": 0, "output": 0})
cost = (input_tokens * prices["input"] + output_tokens * prices["output"]) / 1_000_000
self.total_input_tokens += input_tokens
self.total_output_tokens += output_tokens
self.total_cost += cost
self.calls.append({"model": model, "input": input_tokens, "output": output_tokens, "cost": cost})
def report(self) -> str:
return (
f"Total: {len(self.calls)} calls, "
f"{self.total_input_tokens} input + {self.total_output_tokens} output tokens, "
f"${self.total_cost:.4f}"
)
```
### Streaming Responses
```python
# LangGraph streaming (assumes `app` and HumanMessage from the Basic Agent setup above)
from langchain_core.messages import HumanMessage
async for event in app.astream_events(
{"messages": [HumanMessage(content="Hello")]},
version="v2",
):
if event["event"] == "on_chat_model_stream":
chunk = event["data"]["chunk"]
print(chunk.content, end="", flush=True)
elif event["event"] == "on_tool_start":
print(f"\n[Using tool: {event['name']}]")
```
### Fallback Models
```python
from langchain_openai import ChatOpenAI
from langchain_anthropic import ChatAnthropic
primary = ChatOpenAI(model="gpt-5", timeout=30)
fallback = ChatAnthropic(model="claude-sonnet-4-6", timeout=30)
model = primary.with_fallbacks([fallback])
# Automatically tries fallback if primary fails (cross-provider is the point —
# survives a single vendor's outage or rate-limit spike)
```
---
## Modern Agent Surfaces (2025-2026)
### Anthropic Memory Tool (public beta)
Lets Claude store and retrieve files across turns so long-running agents don't blow context. Operations: `view`, `create`, `str_replace`, `insert`, `delete`, `rename`. You implement the storage backend (a per-conversation `/memories/` directory on disk or object store) by handling `tool_use` blocks named `"memory"` and returning `tool_result` blocks.
```python
# Still public beta as of Jun 2026 — pass the memory tool + beta header.
# Verify the current tool-type version string and header at:
# https://platform.claude.com/docs/en/agents-and-tools/tool-use/memory-tool
response = client.beta.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
betas=["context-management-2025-06-27"], # current beta flag as of Jun 2026
tools=[{"type": "memory_20250818", "name": "memory"}], # confirm latest memory_* version in docs
messages=conversation,
)
```
Pair with **prompt caching** on a long system prompt so the agent's "personality + memory index" is cached across turns: cached input is billed at ~10% of the base input price (a ~90% discount). Combine with **tool-use context clearing** (same beta header) to drop stale tool results from the window automatically.
### OpenAI Responses API (March 2025)
Stateful successor to Chat Completions: tools, file/web/MCP, reasoning models, and conversation `store: true` for server-held state.
```python
# pip install openai
from openai import OpenAI
client = OpenAI()
resp = client.responses.create(
model="gpt-5",
input="Summarize the latest issues in repo X and open one for the worst.",
store=True,
reasoning={"effort": "medium"},
tools=[
{
"type": "mcp",
"server_label": "github",
"server_url": "https://mcp.github.com", # remote MCP server
"require_approval": "never",
},
],
)
print(resp.output_text)
```
The `mcp` tool type lets the model call any remote MCP server (Streamable HTTP) without you proxying every call. See the `mcp-client` skill for client patterns and `mcp-server-builder` for shipping your own.
---
## Safety: Prompt Injection Defense
### Input Validation
```python
import re
def sanitize_user_input(text: str) -> str:
"""Basic prompt injection defense."""
# Remove common injection patterns
suspicious_patterns = [
r"ignore (?:all )?(?:previous |prior |above )?instructions",
r"you are now",
r"new instructions:",
r"system prompt:",
r"</s>|<\|im_end\|>|<\|endoftext\|>",
]
for pattern in suspicious_patterns:
if re.search(pattern, text, re.IGNORECASE):
return "[Input contained suspicious patterns and was filtered]"
return text
```
### Output Validation
```python
from pydantic import BaseModel, field_validator
class AgentResponse(BaseModel):
answer: str
sources: list[str]
confidence: float
@field_validator("answer")
@classmethod
def no_system_leaks(cls, v: str) -> str:
forbidden = ["system prompt", "you are an AI", "as an AI language model"]
for phrase in forbidden:
if phrase.lower() in v.lower():
raise ValueError("Response contained forbidden content")
return v
@field_validator("confidence")
@classmethod
def valid_range(cls, v: float) -> float:
if not 0 <= v <= 1:
raise ValueError("Confidence must be between 0 and 1")
return v
```
---
## Evaluation
### LLM-as-Judge
```python
from langchain_core.messages import HumanMessage
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field
class Judgement(BaseModel):
accuracy: int = Field(ge=1, le=5, description="Does it match the reference?")
completeness: int = Field(ge=1, le=5, description="Does it cover all key points?")
clarity: int = Field(ge=1, le=5, description="Is it well-written and clear?")
reasoning: str
# Use a strong, separate judge model; structured output removes brittle json.loads parsing.
eval_model = ChatOpenAI(model="gpt-5", temperature=0).with_structured_output(Judgement)
EVAL_PROMPT = """Rate the AI response on a 1-5 scale for accuracy, completeness, and clarity.
Question: {question}
Response: {response}
Reference Answer: {reference}"""
async def evaluate_response(question: str, response: str, reference: str) -> Judgement:
return await eval_model.ainvoke(
EVAL_PROMPT.format(question=question, response=response, reference=reference)
)
# Run evaluation suite
async def run_eval_suite(agent, test_cases: list[dict]) -> dict:
results = []
for case in test_cases:
out = await agent.ainvoke({"messages": [HumanMessage(content=case["question"])]})
answer = out["messages"][-1].content
score = await evaluate_response(case["question"], answer, case["expected"])
results.append({"case": case["question"], "score": score})
n = len(results)
avg_accuracy = sum(r["score"].accuracy for r in results) / n
avg_completeness = sum(r["score"].completeness for r in results) / n
return {"results": results, "avg_accuracy": avg_accuracy, "avg_completeness": avg_completeness}
```
> **Bias note:** an LLM judge favors verbose, confident, same-family answers and is itself promptable. Calibrate against a human-labeled gold set, randomize answer order for pairwise comparisons, and never let a model grade its own output unchecked in CI.
### Regression Testing
```python
# tests/test_agent.py (pytest-asyncio; `agent` is your compiled app from above)
import pytest
from langchain_core.messages import HumanMessage
from my_agent import agent
REGRESSION_CASES = [
{
"input": "What's the refund policy?",
"must_contain": ["30 days", "full refund"],
"must_not_contain": ["no refunds"],
},
{
"input": "How do I cancel my subscription?",
"must_contain": ["settings", "billing"],
"must_use_tools": ["search_knowledge_base"],
},
]
@pytest.mark.parametrize("case", REGRESSION_CASES)
async def test_agent_regression(case):
result = await agent.ainvoke({"messages": [HumanMessage(content=case["input"])]})
answer = result["messages"][-1].content.lower()
for phrase in case.get("must_contain", []):
assert phrase.lower() in answer, f"Missing: {phrase}"
for phrase in case.get("must_not_contain", []):
assert phrase.lower() not in answer, f"Should not contain: {phrase}"
```
---
## Checklist: Production Agent
- [ ] Tools have clear descriptions, input validation, and error handling
- [ ] Timeouts on all tool calls and LLM invocations
- [ ] Cost tracking per conversation/user
- [ ] Fallback models configured
- [ ] Streaming for user-facing responses
- [ ] Conversation memory with size limits
- [ ] Prompt injection defense (input sanitization)
- [ ] Output validation (no system prompt leaks)
- [ ] Human-in-the-loop for high-stakes actions
- [ ] Checkpointing for long-running workflows
- [ ] Evaluation suite with regression tests
- [ ] Token usage monitoring and alerts
- [ ] Rate limiting per user
- [ ] Logging of all tool calls and responses
- [ ] Graceful degradation when tools fail
---
## MCP (Model Context Protocol) Integration
MCP is the standard for connecting agents to external tools. Instead of hardcoding tool implementations, agents connect to MCP servers that expose tools over a standardized protocol.
### Building an MCP Server
```typescript
// mcp-server.ts — expose tools for any MCP-compatible agent
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express from 'express';
const server = new McpServer({ name: 'my-tools', version: '1.0.0' });
// Register tools with typed parameters
server.tool('search_docs', 'Search internal documentation by query', {
query: { type: 'string', description: 'Search query' },
limit: { type: 'number', description: 'Max results (default 10)' },
}, async ({ query, limit = 10 }) => {
const results = await searchIndex(query, limit);
return {
content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
};
});
server.tool('create_ticket', 'Create a support ticket in Jira', {
title: { type: 'string', description: 'Ticket title' },
priority: { type: 'string', description: 'low | medium | high | critical' },
description: { type: 'string', description: 'Detailed description' },
}, async ({ title, priority, description }) => {
// Validate before acting — agents will pass garbage sometimes
if (!['low', 'medium', 'high', 'critical'].includes(priority)) {
throw new Error(`Invalid priority "${priority}". Must be: low, medium, high, critical`);
}
const ticket = await jira.createIssue({ summary: title, priority, description });
return {
content: [{ type: 'text', text: `Created ticket ${ticket.key}: ${ticket.self}` }],
};
});
// Streamable HTTP transport (replaces deprecated SSE transport)
const app = express();
app.use(express.json());
app.post('/mcp', async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless
});
await server.connect(transport);
await transport.handleRequest(req, res);
});
app.listen(3100, () => console.log('MCP server on :3100'));
```
### Connecting LangGraph to MCP Tools
Don't hand-roll an MCP client. Use the official `langchain-mcp-adapters`, which speaks **Streamable HTTP** (the transport the server above exposes at `/mcp`) and returns ready-to-use LangChain tools — handling schema conversion, sessions, and reconnects for you. The deprecated `sse_client` transport will not talk to a `StreamableHTTPServerTransport` server.
```python
# pip install langchain-mcp-adapters langgraph langchain-openai
import asyncio
from langchain_mcp_adapters.client import MultiServerMCPClient
from langgraph.prebuilt import create_react_agent
from langchain_openai import ChatOpenAI
async def main():
client = MultiServerMCPClient({
"my-tools": {
"transport": "streamable_http", # matches the server's /mcp endpoint
"url": "http://localhost:3100/mcp",
"headers": {"Authorization": "Bearer ${MCP_TOKEN}"}, # optional auth
},
# add more servers here; tools are merged into one list
})
tools = await client.get_tools() # list[BaseTool], names/schemas come from the server
agent = create_react_agent(ChatOpenAI(model="gpt-5", temperature=0), tools)
result = await agent.ainvoke(
{"messages": [{"role": "user", "content": "Search the docs for CORS config and open a ticket."}]}
)
print(result["messages"][-1].content)
asyncio.run(main())
```
`MultiServerMCPClient` is **stateless by default** — each tool call opens a fresh session and tears it down. For tools that need a persistent session (e.g. sampling, server-side state), wrap calls in `async with client.session("my-tools") as session:`. To call a remote MCP server directly from a frontier model without an adapter, use the provider's native MCP tool type (see the OpenAI Responses example above, and the `mcp-client` / `mcp-server-builder` sibling skills).
---
## Deployment: Containerized Agent
```dockerfile
# Dockerfile — production agent with health checks
FROM python:3.12-slim AS base
RUN pip install --no-cache-dir langgraph langchain-openai redis uvicorn fastapi
WORKDIR /app
COPY . .
# Non-root user
RUN useradd -m agent && chown -R agent:agent /app
USER agent
# python:3.12-slim has no curl — use a stdlib check (no extra packages, no shell deps)
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD ["python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health', timeout=4).status==200 else 1)"]
EXPOSE 8000
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]
```
```python
# server.py — FastAPI wrapper with streaming, cost tracking, rate limiting
import json, time, tiktoken
from collections import defaultdict
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import StreamingResponse
from langchain_core.messages import HumanMessage
from my_agent import agent # your compiled LangGraph app (see "Basic Agent" above)
MODEL = "gpt-5"
PRICE_IN, PRICE_OUT = 1.25, 10.00 # USD/1M tokens for MODEL — keep in sync with CostTracker.PRICES
app = FastAPI()
start_time = time.time()
try:
enc = tiktoken.encoding_for_model(MODEL)
except KeyError:
enc = tiktoken.get_encoding("o200k_base") # fallback for models tiktoken doesn't know yet
# In-memory rate limiter (use Redis in production)
request_counts: dict[str, list[float]] = defaultdict(list)
RATE_LIMIT = 20 # requests per minute
@app.middleware("http")
async def rate_limit(request: Request, call_next):
api_key = request.headers.get("x-api-key", "anonymous")
now = time.time()
request_counts[api_key] = [t for t in request_counts[api_key] if now - t < 60]
if len(request_counts[api_key]) >= RATE_LIMIT:
raise HTTPException(429, "Rate limit exceeded")
request_counts[api_key].append(now)
return await call_next(request)
@app.post("/chat")
async def chat(request: Request):
body = await request.json()
user_msg = body["message"]
api_key = request.headers.get("x-api-key")
# Token counting for cost tracking
input_tokens = len(enc.encode(user_msg))
async def stream():
total_output_tokens = 0
async for event in agent.astream_events(
{"messages": [HumanMessage(content=user_msg)]},
version="v2",
):
if event["event"] == "on_chat_model_stream":
chunk = event["data"]["chunk"].content
if chunk:
total_output_tokens += len(enc.encode(chunk))
yield f"data: {json.dumps({'text': chunk})}\n\n"
# Log cost using the model's own price (see PRICE_IN/PRICE_OUT above).
# Note: tiktoken counts only the raw text; it does NOT include tool-call
# args, system prompt, or reasoning tokens — for exact billing read
# usage_metadata off the final message instead of estimating here.
cost = (input_tokens * PRICE_IN + total_output_tokens * PRICE_OUT) / 1_000_000
yield f"data: {json.dumps({'done': True, 'tokens': {'in': input_tokens, 'out': total_output_tokens}, 'cost_usd': round(cost, 6)})}\n\n"
return StreamingResponse(stream(), media_type="text/event-stream")
@app.get("/health")
async def health():
return {"status": "ok", "model": MODEL, "uptime": time.time() - start_time}
```
---
## Cost Control
```python
# Cost-aware model routing — use cheap models when possible
from datetime import datetime, timezone
from langchain_openai import ChatOpenAI
class BudgetExceededError(Exception):
pass
# Prices in comments are USD/1M input tokens, list as of Jun 2026 — verify before relying on them.
MODELS = {
"fast": ChatOpenAI(model="gpt-5-nano", temperature=0), # cheapest tier — classification, routing
"smart": ChatOpenAI(model="gpt-5", temperature=0), # ~$1.25/1M in — general work
"reasoning": ChatOpenAI(model="o3"), # ~$2/1M in — multi-step logic/math (no temperature)
}
def select_model(task_type: str, input_length: int) -> str:
"""Route to cheapest model that can handle the task."""
if task_type == "classification" or input_length < 500:
return "fast"
if task_type in ("code_generation", "complex_reasoning"):
return "reasoning"
return "smart"
# Budget enforcement
class BudgetTracker:
def __init__(self, daily_limit_usd: float = 10.0):
self.daily_limit = daily_limit_usd
self.spent_today = 0.0
self.last_reset = datetime.now(timezone.utc).date()
def check_budget(self, estimated_cost: float) -> bool:
if datetime.now(timezone.utc).date() > self.last_reset:
self.spent_today = 0.0
self.last_reset = datetime.now(timezone.utc).date()
if self.spent_today + estimated_cost > self.daily_limit:
raise BudgetExceededError(f"Daily budget ${self.daily_limit} exceeded")
return True
def record_spend(self, cost: float):
self.spent_today += cost
```