What you’ll build and where the risk actually is

This project builds a support agent that classifies inbound tickets, answers from a local policy set, calls order and refund tools, keeps session context, and escalates risky actions to a human. The no-framework baseline is the core of this ai agent project tutorial 2026.

The artifact is one Python package: an email/ticket intake function that runs guardrails, classifies the issue, retrieves an answer from a local policy set, optionally calls lookup_order or issue_refund, tracks conversation state, and writes an escalation row when the request is risky. The risk in support agents is almost never “the model can’t answer.” It’s the model inventing an order status, refunding money it shouldn’t, or leaking a credit card number into a prompt. So the stack front-loads control: Python 3.11+, the openai SDK for chat, SQLite for escalations, JSONL files for traces, and FastAPI for the HTTP surface. Everything after this section is a primitive you could delete and rebuild in an afternoon.

Environment setup and repository layout

Environment setup is a virtualenv plus five pip installs, with configuration read from .env through a Pydantic Settings class, per the Pydantic settings docs. The repository layout below keeps runtime state — the SQLite database and trace files — out of source control and inside writable volumes, while the policy file is versioned with the code.

python3.11 -m venv .venv && source .venv/bin/activate
pip install openai python-dotenv pydantic-settings fastapi uvicorn pydantic
pip freeze > requirements.txt
support-agent/
├── .env                  # OPENAI_API_KEY, MODEL_NAME, MAX_MEMORY_CHARS
├── app/
│   ├── config.py
│   ├── baseline.py       # raw chat loop
│   ├── tools.py
│   ├── guardrails.py
│   ├── memory.py
│   ├── handoff.py
│   ├── tracing.py
│   ├── agent.py          # full pipeline
│   └── api.py
├── policies/refunds.md
├── data/escalations.db
└── traces/tickets.jsonl
# app/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    openai_api_key: str
    model_name: str = "gpt-4o-mini"
    max_memory_chars: int = 6000
    max_tool_rounds: int = 5

    model_config = SettingsConfigDict(env_file=".env")

settings = Settings()

Set OPENAI_API_KEY, MODEL_NAME, and MAX_MEMORY_CHARS in .env; the settings object reads them at import time.

No-framework baseline: a raw chat loop

The baseline is a while loop around client.chat.completions.create with three policy lines in the system prompt, appended messages, and a quit command, using the OpenAI chat completions API. This is the whole agent before any tools exist, and it already answers policy questions correctly.

# app/baseline.py
from openai import OpenAI
from app.config import settings

client = OpenAI(api_key=settings.openai_api_key)

SYSTEM = """You are a customer support agent.
1. Never invent order data. If you do not have it, say so.
2. Always get an order ID (ORD-1000 format) before discussing a refund.
3. Escalate abuse or policy exceptions to a human; do not improvise.
Answer from the supplied policy text only."""
POLICY = open("policies/refunds.md").read()
messages = [{"role": "system", "content": SYSTEM + "\n\nPOLICY\n" + POLICY}]

def classify(text: str) -> str:
    """Single completion returning one of: refund, shipping, account, other."""
    r = client.chat.completions.create(
        model=settings.model_name,
        messages=[*messages,
                  {"role": "user", "content": f"Classify as refund|shipping|account|other: {text}"}],
        temperature=0,
    )
    return r.choices[0].message.content.strip().lower()

def chat(user_text: str) -> str:
    messages.append({"role": "user", "content": user_text})
    r = client.chat.completions.create(model=settings.model_name, messages=messages)
    reply = r.choices[0].message.content
    messages.append({"role": "assistant", "content": reply})
    return reply

if __name__ == "__main__":
    print("support agent (ctrl-d or 'quit' to exit)")
    while True:
        try:
            text = input("you> ").strip()
        except (EOFError, KeyboardInterrupt):
            break
        if not text or text.lower() in {"quit", "exit"}:
            break
        print("bot>", chat(text))

Classification and chat share one message list so ticket history survives a multi-turn exchange.

Adding tools with function calling

Tool calling here is two local functions, JSON schemas passed in the OpenAI tools parameter, and a loop that appends a tool message keyed by tool_call_id until the model stops asking, hard-capped at max_tool_rounds to prevent runaway loops. The function-tools pattern is documented in the OpenAI tools guide.

# app/tools.py + execution loop
import json, sqlite3
from openai import OpenAI
from app.config import settings

client = OpenAI(api_key=settings.openai_api_key)
DB = sqlite3.connect("data/escalations.db", check_same_thread=False)
DB.execute("CREATE TABLE IF NOT EXISTS orders (id TEXT PRIMARY KEY, status TEXT, total_cents INTEGER)")
DB.execute("CREATE TABLE IF NOT EXISTS refunds (order_id TEXT, amount_cents INTEGER, ts TEXT)")

def lookup_order(order_id: str) -> dict:
    row = DB.execute("SELECT status, total_cents FROM orders WHERE id=?",
                     (order_id,)).fetchone()
    if not row:
        return {"found": False, "order_id": order_id}
    return {"found": True, "order_id": order_id, "status": row[0], "total_cents": row[1]}

def issue_refund(order_id: str, amount_cents: int) -> dict:
    total = DB.execute("SELECT total_cents FROM orders WHERE id=?",
                       (order_id,)).fetchone()
    if not total or amount_cents > total[0]:
        return {"ok": False, "reason": "amount exceeds order total"}
    DB.execute("INSERT INTO refunds(order_id, amount_cents, ts) VALUES (?,?,datetime('now'))",
               (order_id, amount_cents))
    DB.commit()
    return {"ok": True, "order_id": order_id, "amount_cents": amount_cents}

TOOLS = [
    {"type": "function", "function": {
        "name": "lookup_order",
        "description": "Fetch order status and total by order ID.",
        "parameters": {"type": "object", "properties": {
            "order_id": {"type": "string", "description": "e.g. ORD-1042"}},
            "required": ["order_id"]}}},
    {"type": "function", "function": {
        "name": "issue_refund",
        "description": "Refund an order in cents. Requires a valid order ID.",
        "parameters": {"type": "object", "properties": {
            "order_id": {"type": "string"},
            "amount_cents": {"type": "integer"}},
            "required": ["order_id", "amount_cents"]}}},
]
DISPATCH = {"lookup_order": lookup_order, "issue_refund": issue_refund}

def run_with_tools(messages: list) -> str:
    for _ in range(settings.max_tool_rounds):
        resp = client.chat.completions.create(
            model=settings.model_name, messages=messages, tools=TOOLS)
        msg = resp.choices[0].message
        if not msg.tool_calls:
            messages.append({"role": "assistant", "content": msg.content})
            return msg.content
        messages.append({"role": "assistant", "content": msg.content or "",
                         "tool_calls": [tc.model_dump() for tc in msg.tool_calls]})
        for tc in msg.tool_calls:
            args = json.loads(tc.function.arguments or "{}")
            try:
                result = DISPATCH[tc.function.name](**args)
            except Exception as exc:
                result = {"error": type(exc).__name__, "detail": str(exc)}
            messages.append({"role": "tool", "tool_call_id": tc.id,
                             "content": json.dumps(result)})
    messages.append({"role": "assistant",
                     "content": "I could not resolve this in the tool budget; "
                                "escalating to a human."})
    return "escalated"

Anthropic uses the same control flow with a different surface: client tools are declared in the top-level tools parameter, the model returns tool_use blocks, and execution happens client-side in a tool_result loop (Anthropic tool use docs). Keep the two invocation styles distinct — an OpenAI tool_calls message is not an Anthropic tool_use block.

Guardrails before the model acts

Three input guardrails run before any model call, and failure is fail-closed: return a blocked reason and never invoke the API. This mirrors the documented OpenAI Agents SDK guardrails design — configurable input validation, parallel execution, and the ability to block or raise.

# app/guardrails.py
import re
from dataclasses import dataclass

EMAIL = re.compile(r"^[\w.+-]+@[\w-]+\.[\w.-]+$")
ORDER = re.compile(r"\bORD-\d{4,8}\b")
CARD  = re.compile(r"(?<!\d)(?:\d[ -]?){13,19}(?!\d)")

@dataclass
class GuardResult:
    ok: bool
    name: str
    reason: str = ""

def email_guard(text: str) -> GuardResult:
    m = re.search(r"[\w.+-]+@[\w-]+\.[\w-]+", text)
    if not m or not EMAIL.match(m.group(0)):
        return GuardResult(False, "EmailGuardrail", "no valid contact email")
    return GuardResult(True, "EmailGuardrail")

def order_id_guard(text: str) -> GuardResult:
    if not ORDER.search(text) and "refund" in text.lower():
        return GuardResult(False, "OrderIDGuardrail", "refund request without ORD id")
    return GuardResult(True, "OrderIDGuardrail")

def credit_card_block(text: str) -> GuardResult:
    if CARD.search(text):
        return GuardResult(False, "CreditCardBlockGuardrail", "payment instrument detected")
    return GuardResult(True, "CreditCardBlockGuardrail")

GUARDRAILS = [credit_card_block, order_id_guard, email_guard]

def run_guardrails(text: str) -> GuardResult:
    for g in GUARDRAILS:            # parallelise with threads if inputs are long
        res = g(text)
        if not res.ok:
            return res              # fail closed: no model call
    return GuardResult(True, "all")

CreditCardBlockGuardrail runs first because it is the highest-severity block; EmailGuardrail runs last since a missing address is recoverable inside the conversation.

Short-term session memory

SessionMemory is a deque of typed messages trimmed by MAX_MESSAGES or MAX_MEMORY_CHARS, whichever limit is hit first, dropping oldest-first while preserving the system prompt. This is explicitly sliding-window memory, not vector retrieval and not RAG; embeddings-based recall would be a separate subsystem entirely.

# app/memory.py
from collections import deque
from dataclasses import dataclass, field

from app.config import settings

@dataclass
class Msg:
    role: str            # system | user | assistant | tool
    content: str
    tool_call_id: str | None = None

@dataclass
class SessionMemory:
    max_messages: int = 40
    max_chars: int = settings.max_memory_chars
    items: deque[Msg] = field(default_factory=deque)

    def add(self, msg: Msg) -> None:
        self.items.append(msg)
        self._trim()

    def _trim(self) -> None:
        while len(self.items) > self.max_messages:
            self._drop_oldest()
        while sum(len(m.content) for m in self.items) > self.max_chars and len(self.items) > 2:
            self._drop_oldest()

    def _drop_oldest(self) -> None:
        for m in list(self.items):          # never drop the system prompt
            if m.role != "system":
                self.items.remove(m)
                return

    def as_api(self) -> list[dict]:
        return [{"role": m.role, "content": m.content,
                 **({"tool_call_id": m.tool_call_id} if m.tool_call_id else {})}
                for m in self.items]

Trimming drops oldest-first but never the system prompt or the current turn, so a long ticket thread degrades gracefully instead of hitting the context limit.

Human handoff and escalation

NeedsHumanPolicy is four deterministic rules: refunds above the configured threshold (refund_threshold_cents, default 10,000 cents), payment-instrument requests, abuse signals, and more than two failed tool attempts. On escalation the loop stops, writes a row to a SQLite escalations table with ticket_id, reason, and user_email, and returns a human-queue message to the customer.

# app/handoff.py
import re, sqlite3, uuid

conn = sqlite3.connect("data/escalations.db", check_same_thread=False)
conn.execute("""CREATE TABLE IF NOT EXISTS escalations (
    ticket_id TEXT PRIMARY KEY, reason TEXT, user_email TEXT, created_at TEXT
              DEFAULT CURRENT_TIMESTAMP)""")

ABUSE = re.compile(r"\b(idiot|lawsuit|chargeback|fraud|lawyer)\b", re.I)

class NeedsHumanPolicy:
    def __init__(self, refund_threshold_cents: int = 10_000, max_tool_failures: int = 2):
        self.refund_threshold_cents = refund_threshold_cents
        self.max_tool_failures = max_tool_failures

    def evaluate(self, text: str, refund_cents: int, tool_failures: int) -> str | None:
        if refund_cents > self.refund_threshold_cents:
            return "refund_above_100_usd"
        if re.search(r"\b(credit card|card number|cvv|iban)\b", text, re.I):
            return "payment_instrument_request"
        if ABUSE.search(text):
            return "abuse_signal"
        if tool_failures > self.max_tool_failures:
            return "tool_loop_exhausted"
        return None

def escalate(reason: str, user_email: str) -> str:
    ticket_id = "ESC-" + uuid.uuid4().hex[:8].upper()
    conn.execute("INSERT INTO escalations(ticket_id, reason, user_email) VALUES (?,?,?)",
                 (ticket_id, reason, user_email))
    conn.commit()
    return (f"I've escalated this to a human specialist (ticket {ticket_id}). "
            "They'll reply to your email within one business day.")

Escalation is a decision about action, not tone: the agent can still answer questions, but evaluate returning a reason is the signal for the calling pipeline to route the ticket to the human queue instead of executing further refunds.

Observability and tracing

TraceLogger emits one JSONL line per step, with one trace per ticket and one span per model call, tool call, guardrail decision, or escalation. The mental model is the OpenAI Agents SDK’s built-in tracing, whose default UI is the OpenAI Traces Dashboard.

# app/tracing.py
import json, time, re, uuid
from contextlib import contextmanager
from pathlib import Path

CARDISH = re.compile(r"(?<!\d)(?:\d[ -]?){13,19}(?!\d)")

def redact(text: str) -> str:
    return CARDISH.sub("[REDACTED-PAN]", text)[:500]

class TraceLogger:
    def __init__(self, path: str = "traces/tickets.jsonl"):
        self.path = Path(path); self.path.parent.mkdir(exist_ok=True)

    def start_ticket(self, ticket_id: str | None = None) -> str:
        return ticket_id or "TR-" + uuid.uuid4().hex[:10]

    def span(self, trace_id: str, ticket_id: str, step: str, inputs: dict):
        t0 = time.perf_counter()

        @contextmanager
        def ctx():
            exc_info = None
            try:
                yield
            except Exception as e:
                exc_info = e
                raise
            finally:
                self.emit(trace_id, ticket_id, step, t0, inputs, exc_info)

        return ctx()

    def emit(self, trace_id, ticket_id, step, t0, inputs, exc):
        rec = {"trace_id": trace_id, "ticket_id": ticket_id, "step": step,
               "duration_ms": round((time.perf_counter() - t0) * 1000, 2),
               "inputs": {k: redact(str(v)) for k, v in inputs.items()},
               "error": None if exc is None else str(exc)}
        with self.path.open("a") as fh:
            fh.write(json.dumps(rec) + "\n")

trace = TraceLogger()

Every span carries the same trace_id, so one ticket’s guardrail block, model call, and escalation reconstruct as a single ordered timeline.

Deployment as a small HTTP service

FastAPI exposes POST /support/tickets with Pydantic request and response models returning status, needs_human, answer, and trace_id, plus a health check and a non-root Docker image on python:3.11-slim, following the FastAPI application pattern. The smoke-test commands below verify build, run, and end-to-end ticket flow.

# app/api.py
from fastapi import FastAPI
from pydantic import BaseModel, Field
from app.agent import handle_ticket

app = FastAPI(title="support-agent")

class TicketIn(BaseModel):
    email: str = Field(pattern=r"^[\w.+-]+@[\w-]+\.[\w.-]+$")
    subject: str
    body: str

class TicketOut(BaseModel):
    status: str
    needs_human: bool
    answer: str
    trace_id: str

@app.post("/support/tickets", response_model=TicketOut)
def create_ticket(t: TicketIn) -> TicketOut:
    return TicketOut(**handle_ticket(t.email, t.subject, t.body))

@app.get("/healthz")
def healthz() -> dict:
    return {"ok": True}
FROM python:3.11-slim
WORKDIR /srv
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY policies ./policies
RUN useradd -m agent && mkdir -p data traces && chown -R agent data traces
USER agent
EXPOSE 8000
CMD ["uvicorn", "app.api:app", "--host", "0.0.0.0", "--port", "8000"]
docker build -t support-agent .
docker run --env-file .env -p 8000:8000 support-agent
curl -s localhost:8000/healthz
curl -s localhost:8000/support/tickets -H 'content-type: application/json' \
  -d '{"email":"[email protected]","subject":"missing parcel","body":"order ORD-1042 never arrived"}'

Framework comparison: OpenAI Agents SDK, LangGraph, Google ADK, Pydantic AI, and raw API

The five paths differ in how much of the previous eight build sections they hand you: raw SDK gives none, the OpenAI Agents SDK gives agents, guardrails, handoffs and tracing, LangGraph gives typed state and checkpointing, Google ADK gives sessions and deployment targets, and Pydantic AI gives typed output.

Framework decision table

Framework / path GitHub stars (2026-10-09) Core abstraction Built-in tools, guardrails, tracing Best fit
No-framework raw OpenAI SDK N/A chat completions loop you own none — you build all three tiny loops, minimal dependencies
OpenAI Agents SDK 29,926 Agent + Runner + handoffs tools, parallel guardrails, tracing on OpenAI Traces Dashboard first-party OpenAI with least glue
LangGraph 42,931 StateGraph runtime tool edges, checkpointer persistence, state inspection typed state and conditional graphs
Google ADK 21,756 LlmAgent + Session/state function tools, sessions, sub_agents Vertex AI Agent Engine, Cloud Run, GKE
Pydantic AI 20,499 type-safe Agent(model, system_prompt) decorator tools, typed structured output model-agnostic, schema-critical replies

Three neighbours sit outside this five-way decision: openai/swarm at 22,045 stars is educational and superseded by the production-ready Agents SDK upgrade; the MCP python-sdk at 24,518 stars is a protocol layer — MCP is an open standard client-host-server architecture for connecting AI apps to external systems — not a framework; and crewAI at 59,481 stars sits outside this build-versus-orchestrate question.

Raw SDK quick start

from openai import OpenAI
client = OpenAI()
msgs = [{"role": "system", "content": "Answer only from policy."}]
msgs.append({"role": "user", "content": "Where is ORD-1042?"})
r = client.chat.completions.create(model="gpt-4o-mini", messages=msgs)
msgs.append({"role": "assistant", "content": r.choices[0].message.content})

OpenAI Agents SDK quick start (agents docs, repo)

from agents import Agent, Runner, function_tool

@function_tool
def lookup_order(order_id: str) -> dict:
    return {"order_id": order_id, "status": "shipped"}

agent = Agent(
    name="Support",
    instructions="Never invent order data; escalate refunds over $100.",
    tools=[lookup_order],
)
result = Runner.run_sync(agent, "Where is ORD-1042?")
print(result.final_output)

LangGraph quick start (low-level concepts, persistence, repo)

from typing import TypedDict, Annotated
from langgraph.graph import StateGraph, END
from langgraph.graph.message import add_messages
from langchain_core.messages import HumanMessage

class S(TypedDict):
    messages: Annotated[list, add_messages]

def call_model(state: S) -> S:
    return {"messages": [HumanMessage(content="policy answer")]}

def route(state: S) -> str:
    return "tools" if "ORD-" in state["messages"][-1].content else END

g = StateGraph(S)
g.add_node("chatbot", call_model)
g.add_node("tools", lambda s: s)
g.set_entry_point("chatbot")
g.add_conditional_edges("chatbot", route, {"tools": "tools", END: END})
g.add_edge("tools", "chatbot")
app = g.compile()   # add checkpointer=... for persistence

Google ADK quick start (LlmAgent, function tools, sessions/state, repo)

from google.adk.agents import LlmAgent

def lookup_order(order_id: str) -> dict:
    """Look up an order by ID."""
    return {"order_id": order_id, "status": "shipped"}

support_agent = LlmAgent(
    model="gemini-2.0-flash",
    name="support_agent",
    instruction="Never invent order data; escalate refunds over $100.",
    tools=[lookup_order],
)
# Run with an InMemorySessionService via Runner; deploy to Vertex AI
# Agent Engine, Cloud Run, or GKE (see ai.google.dev/adk/docs/deploy).

Pydantic AI quick start (agents, tools, repo)

from pydantic import BaseModel
from pydantic_ai import Agent

class Reply(BaseModel):
    needs_human: bool
    answer: str

agent = Agent("openai:gpt-4o-mini",
              system_prompt="Never invent order data; escalate refunds over $100.",
              output_type=Reply)

@agent.tool_plain
def lookup_order(order_id: str) -> str:
    return f"{order_id}: shipped"

result = agent.run_sync("Order ORD-1042 never arrived")
print(result.output.needs_human, result.output.answer)

Choosing the right stack for this project

Five decision rules follow, each paired with a team profile, covering the no-framework path and the four frameworks in the decision table. Pick with the failure mode you would rather debug, not with star counts, and note that every rule assumes the no-framework baseline from this tutorial already exists and runs.

  • No-framework raw SDK — best for small loops and minimal dependencies. If your team is three engineers who want zero abstractions between them and the API, pick raw SDK.
  • OpenAI Agents SDK — first-party handoffs, guardrails, and tracing. If your team is already on OpenAI and wants Runner.run with guardrails running in parallel, pick the Agents SDK.
  • LangGraph — a low-level orchestration framework and runtime with StateGraph, typed state, nodes, conditional edges, and a checkpointer; the get-started tutorial is the shortest path in, and Klarna, Uber, and J.P. Morgan are cited LangGraph users. If your team models workflows as explicit state machines with resumable checkpoints, pick LangGraph.
  • Google ADK — a code-first Python toolkit with LlmAgent, tools, Session/state management, and sub_agents, deployable to Vertex AI Agent Engine, Cloud Run, or GKE (deployment docs). If your team ships on Google Cloud infrastructure, pick ADK.
  • Pydantic AI — type-safe, model-agnostic Agent(model, system_prompt) with decorator tools and typed structured output. If your team’s contracts are validated schemas the model must satisfy, pick Pydantic AI.

Troubleshooting common support-agent failures

Five failures cover the most common support-agent breakages, each with an exact fix pointing at a specific file and setting from this build. They are listed in the order they usually appear in a new deployment, from schema validation errors through memory tuning and trace-join failures in the logs.

  1. Invalid tool JSON schema — the model returns malformed arguments. Verify parameters is a JSON Schema object with type, properties, and a required array; a missing required array lets the model omit arguments, and those missing keys surface as TypeError inside the dispatch try/except in run_with_tools.
  2. Guardrail false-positive on Unicode emails — an address with a non-ASCII domain is blocked. Fix the extraction: re.UNICODE is already the default in Python 3, so the real problem is punctuation captured around the address — strip trailing punctuation from the extracted candidate and validate with re.fullmatch(EMAIL, candidate) instead of an anchored match on the whole ticket.
  3. Memory truncating too aggressively — early context vanishes after two turns. Raise MAX_MEMORY_CHARS in .env from 6000 toward 12000; the build’s _trim already spares system messages, so if policy context still disappears, exempt tool messages from trimming as well.
  4. Tool loop hitting max_tool_rounds without resolution — the model keeps calling lookup_order with a bad ID. Log each tool_calls payload, and after the second identical signature force NeedsHumanPolicy.evaluate(..., tool_failures=3) so the ticket escalates instead of burning rounds.
  5. Missing trace_id in logs — spans exist but can’t be joined. Inspect TraceLogger.emit events: trace_id must be captured from start_ticket() and passed into every span(...) call, including guardrail and escalation spans.

Extending the agent: next steps for production

Three extensions follow, in dependency order: move tools behind MCP, add an evaluation ticket set with pass/fail criteria, and add audit-log retention for guardrail blocks and escalations. Each extension builds directly on artifacts this project already creates, so none requires reworking the pipeline you just deployed.

The Bottom Line

The invariant sequence is raw baseline, tools, guardrails, memory, handoff, tracing, deployment, then framework choice — each layer assumes the previous one exists. Frameworks optimize primitives you can build yourself, and the decision table above marks the pick point once the baseline runs.

How This Guide Was Built

This guide is desk research on official documentation plus the GitHub REST API, fetched 2026-10-09. The project code follows documented APIs from the OpenAI, Anthropic, FastAPI, Pydantic, LangGraph, Google ADK, Pydantic AI, and MCP references linked inline throughout. No production deployment was performed, and no runtime claims are made about latency, cost, or accuracy.

FAQ

Do I need a framework to build a production support agent in 2026? No. The baseline in this tutorial is a raw openai SDK loop with hand-written guardrails, a deque for memory, and a deterministic escalation policy. A framework buys you packaged versions of those primitives, not capability you cannot assemble yourself; add one only after the no-framework version is boring to maintain.

Which framework should I choose for guardrails and human handoffs? The OpenAI Agents SDK, which is the production-ready upgrade of Swarm and provides Agents (LLM + instructions + tools), handoffs, guardrails that are configurable input validations running in parallel that can block or raise, and tracing whose default UI is the OpenAI Traces Dashboard, executed via Runner.run.

Why does the baseline use the OpenAI SDK instead of Anthropic? Because the control flow is identical and the surface differs: Anthropic declares client tools in the top-level tools parameter, returns tool_use blocks, and runs a client-side tool_result loop. Swap the SDK and you keep the guardrails, memory, escalation, and tracing unchanged.

Is the session memory a vector database or RAG? No. SessionMemory is sliding-window memory: a deque of typed messages trimmed by MAX_MESSAGES or MAX_MEMORY_CHARS. Retrieval over embeddings would be a different subsystem with its own index, latency, and failure modes, and none of them appear in this build.

When should I choose LangGraph over the OpenAI Agents SDK? Choose LangGraph when your agent is an explicit state machine — typed state, nodes, conditional edges, and a checkpointer for persistence, as described in its low-level concepts and persistence docs. Choose the Agents SDK when you want first-party OpenAI handoffs, parallel guardrails, and tracing with minimal glue.

← Back to all posts