Agentic AI Engineering with LangChain / Lab 12
Module 12 · Deep Agents

Lab 12 · The Northwind research analyst

Build a deep agent that plans, delegates research to a subagent, follows a skill, obeys its AGENTS.md memory and writes a customer health report to a virtual filesystem.

45 minAdvancedAzure OpenAIcreate_deep_agentsubagentsskillsAGENTS.mdCompositeBackendRubricMiddleware

What you will build

A research analyst for Northwind Cloud Customer Success. Ask it for a health report on ACCT-1042 and it plans the work with a todo list, sends a researcher subagent to dig through the knowledge base, scores the account with the rules in a skill, writes the report to /reports/ in its own virtual filesystem, and grades the result against a rubric before it stops.

This is the first lab where the agent does long-horizon work, so it is also the first where context management decides whether it succeeds.

What you will learn

  • How create_deep_agent adds filesystem tools, subagents, skills and memory on top of create_agent
  • How a CompositeBackend mixes read-only files on disk with scratch space in thread state
  • Why a subagent's description decides whether the parent delegates
  • How skills load in three layers (progressive disclosure) and why AGENTS.md costs tokens on every call
  • How RubricMiddleware makes the agent grade and revise its own output
i

One run makes 10 to 20 model calls (planner, subagent, rubric grader) and takes one to three minutes. That is normal for a deep agent: it is doing real multi-step work.

1

Check your prerequisites

You need three things on your machine and three values from your instructor.

  • Python 3.11, 3.12 or 3.13. Check with the command below. On Windows, install it from python.org and tick Add python.exe to PATH.
  • A code editor. VS Code with the Python extension works well.
  • A terminal. PowerShell on Windows, Terminal on macOS or Linux.
PowerShell
python --version
i

From your instructor you get an Azure OpenAI endpoint, an API key and a deployment name. Keep the key private: never paste it into code or share it in chat.

2

Create the project folder and virtual environment

Create a new folder for this lab, open it in a terminal, and create a virtual environment. A virtual environment is a private copy of Python for this project, so the libraries you install here never clash with other projects.

PowerShell
mkdir C:\labs\lab-12-research-analyst
cd C:\labs\lab-12-research-analyst
python -m venv .venv
.venv\Scripts\Activate.ps1
✓

Keep lab folders on a short path such as C:\labs. Deep folders, for example inside OneDrive or Desktop, can exceed the Windows path limit. Some libraries then fail to load with The filename or extension is too long. OneDrive syncing can also lock files the labs write.

When activation works, your prompt starts with (.venv). Activate it again every time you open a new terminal for this lab. Open the folder in VS Code with code . if you like.

!

PowerShell says running scripts is disabled on this system? Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, answer Y, and activate again.

This is what your folder will look like at the end of the lab:

lab-12-research-analyst/ ├── skills/ │ └── report-writing/ │ ├── assets/ │ │ └── report-template.md │ ├── references/ │ │ └── health-scoring.md │ ├── scripts/ │ │ └── band.py │ └── SKILL.md ├── .env ├── AGENTS.md ├── model_setup.py ├── requirements.txt └── research_analyst.py
3

Install the libraries

Create a file named requirements.txt in your lab folder with the content below. The versions are pinned to the ones this lab was tested with.

requirements.txt
langchain==1.4.2
langchain-core==1.6.4
langchain-openai==1.6.2
python-dotenv==1.2.3
deepagents==0.7.18
langgraph==1.2.11

deepagents is LangChain's agent harness. It pulls in a few provider packages of its own (Anthropic, Google), so the install takes a little longer than usual. You still use only your Azure OpenAI deployment.

Then install everything into your virtual environment. It takes a minute or two.

PowerShell
python -m pip install --upgrade pip
pip install -r requirements.txt
4

Add your Azure OpenAI settings

Create a file named .env (the name starts with a dot) in your lab folder. Paste the template and replace the three placeholders with the values from your instructor.

.env
# Azure OpenAI settings. Your instructor gives you these three values.
AZURE_OPENAI_ENDPOINT=https://<your-resource>.openai.azure.com/openai/v1
AZURE_OPENAI_API_KEY=<paste-your-key-here>
AZURE_OPENAI_DEPLOYMENT=<your-chat-deployment-name>

# Only used when your endpoint does NOT end in /openai/v1.
OPENAI_API_VERSION=2025-04-01-preview
  • AZURE_OPENAI_ENDPOINT is your resource URL. If it ends in /openai/v1, keep it that way. If it is just https://<resource>.openai.azure.com/, paste that; the helper in the next step handles both.
  • AZURE_OPENAI_API_KEY is your secret key.
  • AZURE_OPENAI_DEPLOYMENT is the deployment name, for example gpt-5-mini. On Azure you call a deployment, not a model name.
!

Never commit .env to Git or share it. If you use Git, add a .gitignore file containing .env and .venv/.

5

Create the Azure OpenAI helper

Every lab talks to Azure OpenAI through one small helper file. Create model_setup.py and add the blocks below one after another. You write it once per lab folder; the lab code then only needs from model_setup import get_model.

Block 1Imports, .env loading and a console fix

load_dotenv() reads your .env file and puts every line into environment variables, so your key never appears in code.

The loop below it switches the console to UTF-8. Windows terminals default to an older encoding and crash when a model prints curly quotes or dashes.

model_setup.py (add to the end of the file)
"""Azure OpenAI model setup shared by every lab.

Import it in lab code as:  from model_setup import get_model, get_embeddings
"""

import asyncio
import os
import sys

from dotenv import load_dotenv
from langchain_core.embeddings import Embeddings
from langchain_core.language_models import BaseChatModel

# Read AZURE_OPENAI_* values from the .env file next to your lab code.
load_dotenv()

# Windows consoles default to cp1252 and crash on the curly quotes models love to print.
for _stream in (sys.stdout, sys.stderr):
    if hasattr(_stream, "reconfigure"):
        _stream.reconfigure(encoding="utf-8", errors="replace")

Block 2Read settings safely

_setting() fetches a value from the environment and stops with a friendly message if it is missing, instead of a long stack trace.

_uses_v1_api() checks the shape of your endpoint. Azure offers two:

  • https://<resource>.openai.azure.com/openai/v1 is the newer OpenAI-compatible API.
  • https://<resource>.openai.azure.com/ is the classic API that needs an api_version.
model_setup.py (add to the end of the file)
def _setting(name: str) -> str:
    """Return a required setting from .env, or stop with a readable message."""
    value = os.getenv(name, "").strip()
    if not value:
        sys.exit(f"Missing {name} in your .env file. Open .env and fill it in.")
    return value


def _uses_v1_api(endpoint: str) -> bool:
    """True for the OpenAI-compatible Azure endpoint that ends in /openai/v1."""
    return endpoint.rstrip("/").endswith("/openai/v1")

Block 3Build the chat model

get_model() returns a LangChain chat model connected to your deployment.

  • With a /openai/v1 endpoint it uses ChatOpenAI with base_url. On Azure the deployment name goes in model.
  • With a classic endpoint it uses AzureChatOpenAI with azure_deployment and api_version.

Either way the lab code just calls get_model() and never cares which one it got.

model_setup.py (add to the end of the file)
def get_model(deployment: str | None = None, **kwargs) -> BaseChatModel:
    """Return a chat model for your Azure OpenAI deployment."""
    endpoint = _setting("AZURE_OPENAI_ENDPOINT")
    api_key = _setting("AZURE_OPENAI_API_KEY")
    name = deployment or _setting("AZURE_OPENAI_DEPLOYMENT")

    if _uses_v1_api(endpoint):
        from langchain_openai import ChatOpenAI

        return ChatOpenAI(model=name, base_url=endpoint, api_key=api_key, **kwargs)

    from langchain_openai import AzureChatOpenAI

    return AzureChatOpenAI(
        azure_deployment=name,
        azure_endpoint=endpoint,
        api_key=api_key,
        api_version=os.getenv("OPENAI_API_VERSION", "2025-04-01-preview"),
        **kwargs,
    )

Block 4Build the embeddings model

get_embeddings() does the same for embeddings, which turn text into vectors for search. Only the retrieval labs use it, but keeping one helper for every lab is simpler.

model_setup.py (add to the end of the file)
def get_embeddings(deployment: str | None = None) -> Embeddings:
    """Return an embeddings model for your Azure OpenAI embeddings deployment."""
    endpoint = _setting("AZURE_OPENAI_ENDPOINT")
    api_key = _setting("AZURE_OPENAI_API_KEY")
    name = deployment or _setting("AZURE_OPENAI_EMBEDDING_DEPLOYMENT")

    if _uses_v1_api(endpoint):
        from langchain_openai import OpenAIEmbeddings

        return OpenAIEmbeddings(model=name, base_url=endpoint, api_key=api_key)

    from langchain_openai import AzureOpenAIEmbeddings

    return AzureOpenAIEmbeddings(
        azure_deployment=name,
        azure_endpoint=endpoint,
        api_key=api_key,
        api_version=os.getenv("OPENAI_API_VERSION", "2025-04-01-preview"),
    )

Block 5A Windows fix for async code

use_stable_event_loop() matters only for the async MCP lab. The default Windows event loop can crash on exit after async network sessions close. On macOS and Linux it does nothing.

model_setup.py (add to the end of the file)
def use_stable_event_loop() -> None:
    """On Windows, use the Selector event loop so async MCP code exits cleanly."""
    if sys.platform == "win32":
        asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())

Check that the helper can reach your deployment with a one-line test:

PowerShell
python -c "from model_setup import get_model; print(get_model().invoke('Say hello in 5 words').text)"

You should see a short greeting. If you see an error instead, check the troubleshooting step at the end.

6

Download the lab files

The agent reads two kinds of project files from disk: an operating memory file and a skill. Download both and put them in your lab folder:

  • Save AGENTS.md directly in the lab folder, next to where research_analyst.py will go.
  • Unzip skills.zip into the lab folder. It contains a skills folder, so you end up with skills/report-writing/SKILL.md (not skills/skills/...). Check the folder tree at the top of this guide.

Open both files and skim them. They are plain Markdown, and they are the whole point of this lab:

  • AGENTS.md is memory. It is injected into the system prompt at start, every time, so it holds short, durable rules: cite the account id first, ISO dates, never invent a metric.
  • skills/report-writing/SKILL.md is a skill. Only its frontmatter name and description go into the prompt at start. The agent reads the body with read_file when it decides the skill applies, and opens references/health-scoring.md and assets/report-template.md only because the body points at them. That is progressive disclosure: ten skills cost ten short descriptions, not ten documents.
7

Write the research analyst

A plain tool-calling loop fails on long tasks in four ways: the context fills with raw tool output, there is no plan, one context does all the work, and quality drifts. A deep agent adds a fix for each: a virtual filesystem, write_todos, subagents, and skills plus rubrics. create_deep_agent is still create_agent underneath, with a curated middleware stack.

Create an empty file named research_analyst.py in your lab folder. Then copy each block below into it, in order, one after another.

Block 1Imports and the lab folder

  • create_deep_agent builds the harness; RubricMiddleware adds self-grading; FilesystemPermission restricts file access.
  • The three backends decide where the agent's files really live.
  • TodoListMiddleware provides the write_todos planning tool.

MODULE_DIR is the folder this file sits in, so AGENTS.md and skills/ are found no matter where you start Python from.

research_analyst.py (add to the end of the file)
"""Lab 12: the Northwind research analyst, a deep agent that writes customer health reports.

Run:  python research_analyst.py
"""

from pathlib import Path
from typing import Any

from deepagents import FilesystemPermission, RubricMiddleware, create_deep_agent
from deepagents.backends import CompositeBackend, FilesystemBackend, StateBackend
from langchain.agents.middleware import TodoListMiddleware
from langchain.tools import tool
from langchain_core.language_models import BaseChatModel

from model_setup import get_model

# The lab folder: AGENTS.md and skills/ live next to this file.
MODULE_DIR = Path(__file__).resolve().parent

Block 2A pretend knowledge base

Two accounts stand in for a real CRM. ACCT-1042 is deliberately in trouble (usage down, tickets up 62 %, two P1 incidents, no exec sponsor) so the report has something to say.

research_analyst.py (add to the end of the file)
ACCOUNTS: dict[str, str] = {
    "ACCT-1042": (
        "Contoso Metals. Plan: Enterprise, 120 seats, 96,000 USD ARR, renews 2026-11-30. "
        "Weekly active users 49 of 120 seats (down from 78 last quarter). "
        "Support tickets last 90d: 34 (up 62%). P1 incidents: 2. NPS: 6. "
        "Exec sponsor left in July; no replacement named."
    ),
    "ACCT-2210": (
        "Fabrikam Logistics. Plan: Growth, 40 seats, 28,800 USD ARR, renews 2027-03-15. "
        "Weekly active users 33 of 40 seats (flat). Support tickets last 90d: 6 (down 25%). "
        "P1 incidents: 0. NPS: 9. Exec sponsor: VP Ops, engaged."
    ),
}

Block 3Two system prompts

The parent prompt lists the workflow: plan, delegate, follow the skill, save to /reports/. It also forbids inventing metrics.

The researcher prompt is for the subagent. A subagent returns exactly one message to its parent, so the prompt says precisely what that message contains (a compact findings block) and what to leave out (preamble, recommendations).

research_analyst.py (add to the end of the file)
SYSTEM_PROMPT = """You are the Northwind Cloud research analyst. You write customer
health reports for CSMs preparing a renewal call.

1. Plan the task with write_todos before you start.
2. Delegate the digging to the `researcher` subagent so raw tool output never fills
   your own context.
3. Follow the report-writing skill for structure, scoring and tone.
4. Save the finished report to /reports/<account>.md and tell the CSM where it is.

Never state a metric the research did not return: write `not available` and raise it
as an action instead.
"""

RESEARCHER_PROMPT = """You dig into one account with search_kb and return a single
compact findings block: plan, ARR, seats, usage trend, ticket volume and trend, P1
count, NPS, renewal date and any relationship risk. No preamble, no recommendations.
Write `not available` for anything the knowledge base does not have.
"""

Block 4The rubric

The grader sees only the finished report, not the run. It cannot re-run your tools, so every criterion must be checkable by reading the report alone. "The account id appears in the first sentence" works; "is accurate" does not. The rubric is passed at invoke time, not to the constructor, so one agent can be held to a different bar per request.

research_analyst.py (add to the end of the file)
REPORT_RUBRIC = """Grade the health report. Every criterion must pass.

- The account id appears in the first sentence.
- A health band (Green / Amber / Red) is stated with the inputs that produced it.
- Missing metrics read `not available` rather than a guess.
- The report ends with at least two actions, each with an owner and a due horizon.
- Money is formatted `96,000 USD` and dates are ISO (`2026-11-30`).
"""

Block 5The one tool you write

A case-insensitive lookup. An unknown id returns a helpful message listing the known ids instead of raising, so the model can recover. Only the researcher subagent gets this tool.

research_analyst.py (add to the end of the file)
@tool
def search_kb(account_id: str) -> str:
    """Look up a Northwind account in the customer knowledge base.

    Use whenever you need facts about an account: plan, ARR, seat count, usage trend,
    support ticket volume, P1 incidents, NPS or renewal date. Takes an id like ACCT-1042.
    """
    record = ACCOUNTS.get(account_id.strip().upper())
    if record:
        return record
    return f"No account found with id {account_id}. Known accounts: {', '.join(sorted(ACCOUNTS))}."

Block 6Where the files live: a CompositeBackend

The filesystem tools (ls, read_file, write_file, ...) are an interface; the backend decides what is behind each path:

  • Everything by default goes to StateBackend: files live in the conversation state and vanish with it. Good for /research/ notes and /reports/.
  • /skills/ maps to the real skills folder on disk.
  • /memories/ maps to your lab folder, so /memories/AGENTS.md is the real file.

FilesystemBackend gives the model real file access, and your lab folder also holds .env with your API key. The permission rules are checked top to bottom and the first match wins: the agent may read AGENTS.md, and nothing else under /memories/, and may never write there.

research_analyst.py (add to the end of the file)
# The skill and AGENTS.md are real files on disk; everything the agent writes stays
# in thread state and disappears with the conversation.
BACKEND = CompositeBackend(
    default=StateBackend(),
    routes={
        "/skills/": FilesystemBackend(root_dir=str(MODULE_DIR / "skills"), virtual_mode=True),
        "/memories/": FilesystemBackend(root_dir=str(MODULE_DIR), virtual_mode=True),
    },
)

# /memories/ is your lab folder, which also holds .env with your API key.
# Rules are checked in order and the first match wins: the agent may read AGENTS.md
# and nothing else there, and may never write there.
PERMISSIONS = [
    FilesystemPermission(operations=["read"], paths=["/memories/AGENTS.md"], mode="allow"),
    FilesystemPermission(operations=["read", "write"], paths=["/memories/**"], mode="deny"),
]

Block 7The researcher subagent

A subagent is a dict: name, description, system prompt and tools. The parent calls it through the built-in task tool. The subagent runs in a fresh context, makes as many calls as it needs, and hands back one message, so the parent's context grows by two entries however messy the research was.

The description is the only thing the parent reads when deciding whether to delegate. If your agent never delegates, fix this text first.

research_analyst.py (add to the end of the file)
# The parent reads `description` when it decides whether to delegate. Write it for that
# reader: the subagent is surfaced to the parent as the `task` tool.
RESEARCHER: dict[str, Any] = {
    "name": "researcher",
    "description": (
        "Digs into ONE account and returns a compact findings block. Use it for every "
        "account you need facts about."
    ),
    "system_prompt": RESEARCHER_PROMPT,
    "tools": [search_kb],
}

Block 8Assemble the deep agent

  • model: always pass one. With model=None, create_deep_agent falls back to a deprecated Anthropic default and fails with a confusing auth error.
  • skills=["/skills/"]: where to find skill folders (through the backend).
  • memory=["/memories/AGENTS.md"]: files injected into the system prompt.
  • TodoListMiddleware(): planning has been opt-in since deepagents 0.7, so without it there is no write_todos tool.
  • RubricMiddleware(max_iterations=2): after the agent finishes, a grader checks the rubric and, if a criterion fails, sends the agent back to revise, at most twice. on_evaluation lets you watch each verdict.
research_analyst.py (add to the end of the file)
def build_agent(
    model: BaseChatModel | None = None,
    todos: bool = True,
    rubric: bool = False,
    on_evaluation: Any = None,
) -> Any:
    """Build the analyst deep agent. Pass a model to override the Azure OpenAI default."""
    model = model or get_model()
    middleware: list[Any] = [TodoListMiddleware()] if todos else []
    if rubric:
        middleware.append(RubricMiddleware(model=model, max_iterations=2, on_evaluation=on_evaluation))
    return create_deep_agent(
        # Always pass a model: create_deep_agent(model=None) falls back to a deprecated
        # hard-coded Anthropic default.
        model=model,
        tools=[search_kb],
        system_prompt=SYSTEM_PROMPT,
        subagents=[RESEARCHER],
        skills=["/skills/"],
        memory=["/memories/AGENTS.md"],
        permissions=PERMISSIONS,
        backend=BACKEND,
        middleware=middleware,
        name="northwind-research-analyst",
    )

Block 9Run it and show the work

  • The rubric key goes into the invoke input next to messages.
  • recursion_limit: 100: the default of 25 steps is not enough for a planner with a subagent.

Then the demo prints four things: the parent's tool calls (look for a single task call), the todo list left in state, the grader's verdict per iteration, and the virtual files, including the full report the agent wrote. Nothing was written to your disk: result["files"] is thread state.

research_analyst.py (add to the end of the file)
if __name__ == "__main__":
    evaluations: list[dict[str, Any]] = []
    agent = build_agent(rubric=True, on_evaluation=evaluations.append)

    print("Writing the health report for ACCT-1042 (this takes a minute or two)...\n")
    result = agent.invoke(
        {
            "messages": [{"role": "user", "content": "Health report for ACCT-1042, please."}],
            "rubric": REPORT_RUBRIC,
        },
        {"recursion_limit": 100},  # 25 is not enough for a planner with a subagent
    )

    # 1. What the parent agent did. The researcher's search_kb calls happened inside
    #    its own run, so they do not appear here: only the single `task` call does.
    calls = [call for message in result["messages"] for call in getattr(message, "tool_calls", [])]
    print("Parent tool calls:")
    for call in calls:
        args = call["args"]
        target = args.get("file_path") or args.get("subagent_type") or args.get("path") or ""
        if args.get("pattern"):
            target = f"{args['pattern']!r} in {target or '/'}"
        print(f"  {call['name']:<12} {target}")

    # 2. The plan the agent kept in state with write_todos.
    print("\nPlan (write_todos):")
    for todo in result.get("todos", []):
        print(f"  [{todo['status']}] {todo['content']}")

    # 3. The rubric grader's verdict for each attempt.
    print("\nRubric grading:")
    for evaluation in evaluations:
        print(f"  iteration {evaluation['iteration']}: {evaluation['result']}")
        for criterion in evaluation.get("criteria", []):
            print(f"    [{'pass' if criterion['passed'] else 'FAIL'}] {criterion['name']}")

    # 4. The virtual filesystem: files the agent wrote into thread state.
    files: dict[str, Any] = result.get("files", {})
    print("\nVirtual files:", ", ".join(sorted(files)) or "none")
    reports = sorted(path for path in files if path.startswith("/reports/"))
    if reports:
        print(f"Report saved to: {reports[-1]}\n")
        content = files[reports[-1]]["content"]
        print("-" * 60)
        print(("\n".join(content) if isinstance(content, list) else content).strip())
        print("-" * 60)
    else:
        print("The agent did not save a report under /reports/. Run it again or check the prompt.")

    print("\nAgent's final message:")
    print(result["messages"][-1].text)
8

Run the analyst

PowerShell
python research_analyst.py

Your output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.

Expected output
Writing the health report for ACCT-1042 (this takes a minute or two)...

Parent tool calls:
  write_todos  
  task         researcher
  read_file    /skills/report-writing/SKILL.md
  read_file    references/health-scoring.md
  grep         'health-scoring' in /
  write_todos  
  write_file   /reports/ACCT-1042.md
  write_todos  

Plan (write_todos):
  [completed] Run researcher subagent to collect ACCT-1042 account facts (ARR, seats, usage trend, support volume, P1 incidents, NPS, renewal date, exec sponsor, last communication, recent tickets, billing, contract term, product modules, health_score).
  [completed] Draft the Northwind customer health report for ACCT-1042 and save to /reports/ACCT-1042.md.
  [in_progress] Notify CSM where the report is saved and list any actions required.

Rubric grading:
  iteration 0: satisfied
    [pass] The account id appears in the first sentence.
    [pass] A health band (Green / Amber / Red) is stated with the inputs that produced it.
    [pass] Missing metrics read `not available` rather than a guess.
    [pass] The report ends with at least two actions, each with an owner and a due horizon.
    [pass] Money is formatted `96,000 USD` and dates are ISO (`2026-11-30`).

Virtual files: /reports/ACCT-1042.md
Report saved to: /reports/ACCT-1042.md

------------------------------------------------------------
## Summary
ACCT-1042 (Contoso Metals) — RED health; renewal on 2026-11-30. Usage and engagement are falling, support volume is rising, NPS is low (6), and the executive sponsor left in July with no replacement named.

## Signals
- ARR: 96,000 USD — direction: not available (pull ARR trend).
- Seats: 120 — direction: not available.
- Weekly active users: 49 of 120 seats — down (was 78 last quarter).
- Usage trend (90d): down.
- Support tickets (90d): 34 — up 62%.
- P1 incidents (12mo): 2 — direction: not available.
- NPS: 6 — direction: not available.
- Executive sponsor: not available (left in July; no replacement named).
- Renewal date: 2026-11-30.

## Risks
- Missing executive sponsor → evidence: relationship_risk entry: "Exec sponsor left in July; no replacement named." (threatens renewal advocacy).
- Declining adoption → evidence: weekly active users down from 78 to 49; usage_trend_90d = down.
- Rising support burden and low satisfaction → evidence: 34 tickets in last 90d (up 62%), NPS 6, 2 P1s in last 12 months.
- Unknown commercial/engagement details → evidence: billing_status, contract_term_months, last_communication_date all returned not available (could block renewal logistics).

## Actions
1) Owner: CSM — This week: Reach out to Contoso executive team to identify/replace sponsor and schedule an executive alignment meeting. (High priority)
2) Owner: Solutions Engineer / CSM — This month: Launch a targeted adoption play (refresher training + success plan) for the 71 at-risk seats; propose a 30/60/90-day engagement plan.
3) Owner: Support Lead — This week: Triage the recent 34 tickets, produce root-cause summary, and propose mitigations to reduce P1 risk.
4) Owner: CSM / Researcher — This week: Pull missing KB fields: active_users_30d, recent_open_tickets, billing_status, contract_term_months, product_modules, executive_sponsor, last_communication_date, health_score. (Action text: "pull <metric>")
5) Owner: Finance Ops / CSM — This month: Confirm billing status and renewal terms; surface any payment or contract changes before 2026-11-30.

## Sources
- search_kb (researcher) findings for ACCT-1042: ARR 96,000 USD; seats 120; weekly active users 49 of 120 (down from 78 last quarter); usage_trend_90d = down; support_ticket_count_90d = 34 (up 62%); P1_incidents_12mo = 2; NPS = 6; renewal_date = 2026-11-30; relationship_risk = "Exec sponsor left in July; no replacement named." Fields listed as "not available" were not returned by search_kb and are flagged above for follow-up.
------------------------------------------------------------

Agent's final message:
I wrote the ACCT-1042 health report and saved it to /reports/ACCT-1042.md.

Quick summary for the renewal call:
- Account: ACCT-1042 (Contoso Metals)
- Health band: RED
- Renewal date: 2026-11-30
- Key concerns: falling usage (weekly active users 49/120, down from 78), rising support tickets (34 in 90d, up 62%), low NPS (6), and no named executive sponsor after July departure.
- Critical actions: re-establish executive sponsor (this week), run targeted adoption play and training (this month), triage support ticket root causes (this week), and pull missing KB fields (this week): active_users_30d, recent_open_tickets, billing_status, contract_term_months, product_modules, executive_sponsor, last_communication_date, health_score.

Report location: /reports/ACCT-1042.md

If you want I can:
- Send a templated outreach email to Contoso execs for the sponsor request.
- Open the KB pulls and try to fetch the missing fields now. Which would you prefer?

Read the output top to bottom:

  • Parent tool calls. write_todos first (the plan), then read_file of /skills/report-writing/SKILL.md and its reference files (progressive disclosure), a single task researcher call (delegation), and write_file to /reports/. There is no search_kb in the parent's list: that happened inside the subagent's own context. You may also see a read_file of a relative path such as references/health-scoring.md followed by a grep: the model tried the path as written in SKILL.md, got an error, and searched for the file instead.
  • Plan. The todo list the agent kept in state, all completed.
  • Rubric grading. One line per grader pass. satisfied on iteration 0 means the first draft passed every criterion. If a criterion fails, the agent revises and you see an iteration 1 (at most two, because of max_iterations=2).
  • The report. The five sections from the skill, a Red band for ACCT-1042 with the inputs that produced it, ISO dates and money as 96,000 USD from AGENTS.md, and numbered actions with owners.

The exact tool order and wording change from run to run.

9

Check your work

You are done when

  • The parent's tool calls include task researcher and no search_kb.
  • The agent read /skills/report-writing/SKILL.md before writing the report.
  • A report was saved under /reports/, and its first sentence names ACCT-1042.
  • The report states a health band with its inputs and ends with at least two actions, each with an owner and a due horizon.
  • Every number in the report appears in the ACCT-1042 knowledge-base record.

Troubleshooting

GraphRecursionError: Recursion limit of 25 reached

Pass {"recursion_limit": 100} as the second argument to agent.invoke. A planner with a subagent easily needs more than 25 steps.

The agent never reads the skill, or reports /skills/ is empty

Check the folder layout: the file must be skills/report-writing/SKILL.md next to research_analyst.py. If you unzipped into a new folder you may have skills/skills/...; move it up one level.

The agent never calls task

Improve the subagent's description. That text, not the system prompt, is what the parent reads when deciding whether to delegate.

Authentication error that mentions Anthropic

create_deep_agent was called without a model. Pass model=get_model() (or the model you built) explicitly.

The run is slow or costs more than expected

Each rubric iteration is another full revision plus a grading call. Run with build_agent(rubric=False) while you work on prompts, then turn the rubric back on.

401 Unauthorized or 'Access denied due to invalid subscription key'

The key in .env is wrong or has spaces around it. Copy it again from your instructor, with no quotes.

404 'DeploymentNotFound' or 'The API deployment for this resource does not exist'

AZURE_OPENAI_DEPLOYMENT must be the deployment name exactly as your instructor gave it, and the endpoint must be the same resource.

429 Too Many Requests

The shared deployment is busy. Wait 30 seconds and run again.

ModuleNotFoundError: No module named 'model_setup' (or another package)

Run the command from inside your lab folder, with the virtual environment activated. Reinstall with pip install -r requirements.txt if a library is missing.

'python' is not recognized, or the wrong Python runs

Activate the virtual environment first. On Windows you can also try py -3.11 instead of python when creating the venv.

Go further

  • Ask for ACCT-2210 and check that the band comes out Green with the same structure.
  • Run with build_agent(todos=False) and confirm write_todos disappears from the tool calls.
  • Add a second subagent (competitor-research) and watch how the parent chooses between them.
  • Swap the default route to a StoreBackend so notes survive across threads, and see what changes for a second conversation about the same account.
  • Add interrupt_on={"write_file": True} and approve each write. Useful when the filesystem is real.
  • Replace the dict knowledge base with the Module 06 vector store and compare report quality.

Stuck?

Download the finished project and compare it with yours. It contains every file from this guide except .env and .venv, so you still add your own settings.