Skip to content
Module 4 · API & Business LogicIntermediate5 min read · 1,047 words

Lesson 13: AI Agents

"An agent is a system prompt, a set of allowed tools, and a model — nothing more mysterious than that."

The Formula, Grounded in the Actual Model

Agent = system_prompt + enabled_tools + language/tone + (implicitly) the model configured in .env

Every field on models.Agent (see Lesson 06) maps directly onto this:

python
class Agent(Base):
    name = Column(String(100), nullable=False)
    type = Column(String(50), nullable=False)
    description = Column(Text, nullable=True)
    system_prompt = Column(Text, nullable=False)
    language = Column(String(50), default="English")
    tone = Column(String(50), default="Professional")
    enabled_tools = Column(Text, default="")

type is a plain, unconstrained strschemas.AgentCreate.type: str has no Literal restricting it to a fixed set of values. An agent's "type" is whatever string the creator typed; nothing in the backend branches on it. It's a label for the frontend, not a behavioral switch.

What Makes an Agent "Do" Anything

An Agent row, by itself, is inert data. It only becomes active when a router endpoint reads its system_prompt, language, tone, and enabled_tools and passes them into a service function:

  • POST /{agent_id}/chatgenerate_agent_response(system_prompt=agent.system_prompt, ...) (Lesson 08a).
  • POST /{agent_id}/email/analyze → gated by enabled_tools, calls analyze_email().
  • POST /{agent_id}/workflows/email → gated by enabled_tools, calls three functions from multi_agent_email_service.py.
  • POST /{agent_id}/documents/ask → gated by enabled_tools, calls the LangGraph corrective RAG pipeline.

There's no Agent.run() method, no agent class hierarchy, no polymorphism — "the agent" is just a row of configuration that gets threaded through stateless service functions on every request.

The Tool Registry

Four tool names appear, as string literals, across the codebase:

Tool nameChecked inUnlocks
email_analyzerrouters/agents.pyPOST /{id}/email/analyze, POST /{id}/workflows/email
task_creatorrouters/agents.pyWhether action items become Task rows (in both the simple analyze endpoint and the multi-agent workflow)
reply_generatorrouters/agents.pyWhether the multi-agent workflow calls a dedicated run_reply_agent, or falls back to the analysis step's own suggested_reply
document_searchrouters/documents.pyPOST /{id}/documents/search, POST /{id}/documents/ask

This "registry" isn't a table, an enum, or a constants module — it's four string literals that happen to be spelled identically everywhere they're checked and set. There's no central place a new tool is "registered"; adding a fifth tool means picking a new string, writing an if agent_has_tool(agent, "your_new_tool"): check somewhere, and remembering to set it on enabled_tools when creating agents that should have it. A typo in either the check or the value silently disables a tool with no error — agent_has_tool returns False for an unrecognized string, it never raises.

The Gate, In Full

python
def agent_has_tool(agent: models.Agent, tool_name: str) -> bool:
    if not agent.enabled_tools:
        return False
    tools = [tool.strip() for tool in agent.enabled_tools.split(",")]
    return tool_name in tools

Defined identically (copy-pasted) in both routers/agents.py and routers/documents.py — see Lesson 09b for that duplication. Every endpoint that requires a tool follows the same shape:

python
if not agent_has_tool(agent, "email_analyzer"):
    raise HTTPException(status_code=403, detail="This agent does not have email_analyzer tool enabled")

403 Forbidden, not 404 — the agent exists, the request is well-formed, but this particular agent isn't configured to use this capability. This is the closest thing this backend has to an authorization model (see Lesson 25: Security — it's agent-capability scoping, not user authorization; there's still no concept of "which human is allowed to call this agent" at all).

"Tool Calling" — What This Codebase Does Not Do

If you've used OpenAI's native function-calling / tool-calling API (tools=[...] passed to chat.completions.create, with the model choosing to invoke a named function), this codebase doesn't use it, anywhere. Every client.chat.completions.create(...) call across openai_service.py and multi_agent_email_service.py omits the tools parameter entirely. What this codebase calls a "tool" (email_analyzer, task_creator, reply_generator, document_search) is a backend-side authorization flag, checked in Python before a hardcoded function is called — not something the model decides to invoke. See Lesson 19: Prompt Engineering & Tool Calling for the full comparison and why this distinction matters if you're evaluating this codebase against "does it use function calling?"

Agents Are Stateless Between Requests

Nothing about an Agent row changes as a side effect of chatting with it — no running memory, no fine-tuning, no self-modifying prompt. Every conversation turn re-sends the full system_prompt from the database. Continuity comes entirely from the Message history stored in SQL, not from anything living inside "the agent" itself — see Lesson 20: Memory & Context Management.

Common Mistakes

  • Assuming type restricts behavior. It doesn't — it's an unconstrained label.
  • Assuming a missing tool check raises an error you'll notice during development. A misspelled tool name in enabled_tools just silently makes agent_has_tool return False — no exception, no log line, just a 403 the next time someone tries to use it.
  • Assuming this backend does OpenAI-native tool/function calling. It doesn't — see above.

Exercises

  1. Create an agent with enabled_tools = "email_analyzer, task_creator" (note the space after the comma) and confirm agent_has_tool still returns True for "task_creator". Now try enabled_tools = "Email_Analyzer" and explain why the check fails.
  2. Design a fifth tool, sentiment_flagger, that scans incoming chat messages and tags the AgentRun if the sentiment is negative. Where would you add the gate check, and what new field (if any) would you need on AgentRun?
  3. Sketch what it would take to migrate enabled_tools from a comma-separated string column to a proper agent_tools join table. What breaks in agent_has_tool and every caller of it?

Key Takeaways

🎯 An "agent" is configuration data (system_prompt, language, tone, enabled_tools), not a class or a runtime object. 🎯 enabled_tools is a single comma-separated string — the tool "registry" is four repeated string literals, not a real registry. 🎯 Tool gating returns 403, and is the closest thing to authorization this backend has — it scopes capabilities per agent, not per human user. 🎯 This codebase does not use OpenAI's native function-calling API — "tool calling" here means a backend-side flag check, not model-driven invocation.

Next Steps

👉 Lesson 14: RAG Pipeline →


Module 4 · API & Business Logic