Skip to main content

Build an AI Lead Qualification Agent with Claude and Python — Part 2: Create a Production-Grade Python Project with uv and FastAPI

· 30 min read
Jagdish Kumawat
Founder @ Dewiride

Before the agent can read a single inquiry, it needs a proper home. In this part you build the project the way production teams do: one command creates it, every package version is locked, your Claude API key is loaded safely from a file Git never sees, and a running FastAPI app answers its first request.

This is Part 2 of a 7-part series.

  1. Part 1: Overview, Architecture and Your Claude API Key
  2. Part 2: Create a Production-Grade Python Project with uv and FastAPI (you are here)
  3. Part 3: Design the Lead Data Model with Pydantic
  4. Part 4: Qualify Your First Lead with Claude Structured Outputs
  5. Part 5: Score and Prioritize Leads You Can Explain
  6. Part 6: Handle Errors and Log Safely
  7. Part 7: Test the Lead Qualifier with pytest

There is no AI in this part yet, and that is on purpose. Everything later parts add, from the data model to the Claude call, plugs into the structure you create today. Every command and output on this page is real, captured while building the project.

What You Will Build​

By the end of this part you will have this project on your computer:

dewiride-lead-qualifier/
├── .env your API key and settings (never committed)
├── .env.example a template of every setting, without secrets
├── .gitignore files Git must ignore
├── .python-version the Python version the project uses (3.14)
├── .venv/ the project's private Python environment
├── README.md
├── pyproject.toml the project's name, packages and tool settings
├── uv.lock the exact version of every package
└── src/
└── lead_qualifier/
├── __init__.py the package and its version number
├── config.py loads the settings from .env
├── main.py creates the FastAPI app
└── api/
├── __init__.py
└── health.py the GET /health endpoint

You will also have an API running at http://127.0.0.1:8000 that answers {"status":"ok"} at /health, with an interactive test page at /docs.

Why a Production-Grade Structure?​

You could put everything in one file and start coding. That works until the second developer joins, a package update breaks something, or a secret ends up in Git. Each piece below prevents one of those problems:

PieceWhat it doesWhy it matters
pyproject.tomlDescribes the project: its name, Python version and packagesOne standard file that every Python tool understands
uv.lockRecords the exact version of every package, including the ones your packages needEveryone, on every computer, installs exactly the same code
src/ layoutKeeps your code in src/lead_qualifier/ instead of the project rootCode runs as an installed package, so it behaves the same in tests, on your machine and on a server
Settings moduleReads every setting in one place and checks it at startupA missing API key stops the app immediately with a clear message, not in the middle of a customer's request
.env + .env.exampleReal values in .env (ignored by Git), a template in .env.example (committed)Secrets never reach Git, yet everyone knows which settings exist
App factory + health checkOne function builds the app, and /health reports that it is upEasy to test, and hosting platforms use health checks to know your service is alive
RuffFormats the code and catches common mistakesConsistent, readable code without arguing about style

What This Part Costs​

Nothing. This part uses only free tools on your own computer and never calls the Claude API.

Prerequisites​

Finish Part 1 first. You need:

  • uv and Python 3.14. Check them with uv --version and uv run --python 3.14 python --version.
  • VS Code with the Python and Ruff extensions, or another editor.
  • Your Claude API key, copied somewhere safe. You paste it into the project in Step 7.

Every command on this page works the same in Terminal (macOS), PowerShell (Windows) and Linux shells, unless a step shows a tab for each.

Step 1: Create the Project with uv​

Open a terminal in the folder where you keep your code, for example a source folder in your home folder. Then run:

Terminal
uv init --package --name lead-qualifier --python 3.14 dewiride-lead-qualifier
Output
Initialized project `lead-qualifier` at `/Users/you/source/dewiride-lead-qualifier`

The path in your output shows your own folder. Here is what each part of the command does:

Part of the commandWhat it does
uv initCreates a new Python project
--packageSets the project up as a proper, installable package with a src/ folder, rather than a loose script
--name lead-qualifierThe project's name. Python code imports it as lead_qualifier, because import names cannot contain dashes.
--python 3.14The Python version the project uses
dewiride-lead-qualifierThe folder to create

The folder name and the project name differ on purpose. The folder carries the product's full name, while the code uses the short, generic lead_qualifier. If you later rename the product for a client, the code does not have to change.

uv also turned the folder into a Git repository, so the project's history is tracked from the start.

Move into the new folder. Every command from now on runs from here, the project root:

Terminal
cd dewiride-lead-qualifier

Step 2: Open the Project in VS Code​

Terminal
code .

The . means "this folder". If your terminal says code is not recognized, open VS Code yourself and use File → Open Folder… to pick dewiride-lead-qualifier. On macOS you can also enable the command: open the Command Palette with Cmd+Shift+P and run Shell Command: Install 'code' command in PATH.

Then open VS Code's own terminal with Terminal → New Terminal. It starts in the project root, so you can run the rest of this part's commands there.

Step 3: Look Around the Generated Files​

uv created five files. The most important is pyproject.toml:

dewiride-lead-qualifier/pyproject.toml
[project]
name = "lead-qualifier"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Your Name", email = "you@example.com" }
]
requires-python = ">=3.14"
dependencies = []

[project.scripts]
lead-qualifier = "lead_qualifier:main"

[build-system]
requires = ["uv_build>=0.12.22,<0.13.0"]
build-backend = "uv_build"
SectionWhat it means
[project]The project's identity: name, version, a description and the authors. uv copies your name and email from your Git settings.
requires-pythonThe oldest Python version the project accepts
dependenciesThe packages your code needs. Empty for now; uv fills it in Step 4.
[project.scripts]A sample command-line command. An API does not need one, so you remove it in Step 6.
[build-system]How to turn the project into an installable package. uv_build is uv's own builder. The version numbers match your uv.

The other files:

  • .python-version holds one line, 3.14. uv reads it to pick the right Python.
  • src/lead_qualifier/__init__.py marks the folder as a Python package. For now it holds a sample main() function that you replace in Step 9.
  • .gitignore lists files Git should ignore, such as Python's cache folders and the .venv environment. You add one important line to it in Step 7.
  • README.md is empty for now. Part 7 fills it in.

Step 4: Add the Packages the App Needs​

Terminal
uv add fastapi "uvicorn[standard]" pydantic-settings anthropic
Output
Using CPython 3.14.8
Creating virtual environment at: .venv
Resolved 30 packages in 17ms
Building lead-qualifier @ file:///Users/you/source/dewiride-lead-qualifier
Built lead-qualifier @ file:///Users/you/source/dewiride-lead-qualifier
Prepared 1 package in 3ms
Installed 29 packages in 45ms
+ annotated-doc==0.0.5
+ annotated-types==0.8.0
+ anthropic==1.13.0
+ anyio==4.15.1
+ click==8.5.0
+ docstring-parser==0.18.0
+ fastapi==0.143.0
+ h11==0.16.0
+ httpcore2==2.13.1
+ httptools==0.9.0
+ httpx2==2.13.1
+ idna==3.20
+ jiter==0.17.0
+ lead-qualifier==0.1.0 (from file:///Users/you/source/dewiride-lead-qualifier)
+ opentelemetry-api==1.45.1
+ pydantic==2.14.1
+ pydantic-core==2.50.1
+ pydantic-settings==2.15.0
+ python-dotenv==1.2.4
+ pyyaml==6.0.3
+ sniffio==1.3.1
+ starlette==1.8.0
+ truststore==0.10.5
+ typing-extensions==4.16.0
+ typing-inspection==0.4.4
+ uvicorn==0.54.0
+ uvloop==0.23.0
+ watchfiles==1.3.0
+ websockets==17.2

Your timings, and possibly some version numbers, will differ. You asked for four packages:

PackageWhat it is for
fastapiThe web framework that turns Python functions into API endpoints
uvicorn[standard]The web server that runs a FastAPI app. [standard] adds its faster optional parts, such as automatic reloading.
pydantic-settingsReads settings from environment variables and .env files, and checks them
anthropicAnthropic's official Python SDK for calling Claude (used from Part 4)

uv installed 29 because those four need others, which need others in turn. You never manage those by hand. uv also did three things for you:

  1. It created .venv/, a private Python environment for this project only. Packages installed here cannot clash with other projects on your computer.

  2. It added the four packages to pyproject.toml, sorted alphabetically:

    dewiride-lead-qualifier/pyproject.toml (dependencies)
    dependencies = [
    "anthropic>=1.13.0",
    "fastapi>=0.143.0",
    "pydantic-settings>=2.15.0",
    "uvicorn[standard]>=0.54.0",
    ]

    >=1.13.0 means "this version or newer".

  3. It created uv.lock, which records the exact version and a checksum of all 29 packages. Commit this file. Anyone who clones the project gets exactly what you tested, even years later.

Why not fastapi[standard]?

FastAPI's own guide installs fastapi[standard], a bundle of optional extras. In FastAPI 0.143, the same uv add command with fastapi[standard] installed 64 packages here, including tools for FastAPI's own cloud deployment service, error tracking and telemetry that this project never uses. Installing FastAPI and Uvicorn directly gives you the same web server with 29 packages. Fewer packages means less to download, less to keep updated and less to trust. That habit pays off on every production project.

VS Code shows red underlines under the imports?

VS Code needs to use the new .venv environment. Open the Command Palette (Ctrl+Shift+P, or Cmd+Shift+P on macOS), run Python: Select Interpreter, and choose the entry whose path contains .venv.

Step 5: Add Ruff as a Development Tool​

Some tools are only for you while you write code: they never run in production. uv keeps them in a separate dev group:

Terminal
uv add --dev ruff
Output
Resolved 31 packages in 3.81s
Building lead-qualifier @ file:///Users/you/source/dewiride-lead-qualifier
Built lead-qualifier @ file:///Users/you/source/dewiride-lead-qualifier
Downloading ruff (8.8MiB)
Downloaded ruff
Prepared 2 packages in 1.94s
Uninstalled 1 package in 1ms
Installed 2 packages in 3ms
~ lead-qualifier==0.1.0 (from file:///Users/you/source/dewiride-lead-qualifier)
+ ruff==0.17.0

Ruff formats your code and points out common mistakes, such as unused imports. uv recorded it in a new section of pyproject.toml:

dewiride-lead-qualifier/pyproject.toml (dev group)
[dependency-groups]
dev = [
"ruff>=0.17.0",
]

Step 6: Tidy Up pyproject.toml​

Open pyproject.toml and make three changes:

  1. Replace the placeholder description with one that says what the project does.
  2. Delete the [project.scripts] section (both lines). The app runs as a web server, so it needs no command of its own.
  3. Add Ruff's settings at the end of the file:
dewiride-lead-qualifier/pyproject.toml (add at the end)
[tool.ruff]
line-length = 100

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]

line-length = 100 lets lines run a little longer than Python's traditional 79 characters. select turns on five sets of rules:

CodeRulesCatches, for example
EpycodestyleLayout problems
FPyflakesUnused imports, undefined names
IisortImports in the wrong order
Bflake8-bugbearCommon bug patterns
UPpyupgradeOld syntax that newer Python replaces

The complete file now looks like this. Your authors line shows your own name:

dewiride-lead-qualifier/pyproject.toml
[project]
name = "lead-qualifier"
version = "0.1.0"
description = "Qualify incoming sales inquiries with Claude: extract the details, classify the intent, score the lead and draft a reply."
readme = "README.md"
authors = [
{ name = "Your Name", email = "you@example.com" }
]
requires-python = ">=3.14"
dependencies = [
"anthropic>=1.13.0",
"fastapi>=0.143.0",
"pydantic-settings>=2.15.0",
"uvicorn[standard]>=0.54.0",
]

[build-system]
requires = ["uv_build>=0.12.22,<0.13.0"]
build-backend = "uv_build"

[dependency-groups]
dev = [
"ruff>=0.17.0",
]

[tool.ruff]
line-length = 100

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]

Step 7: Keep Your API Key Out of Git​

The project needs your Claude API key, and the key must never reach Git. Three small files make that automatic.

1. Tell Git to ignore .env. Add these two lines to the end of .gitignore:

dewiride-lead-qualifier/.gitignore (add at the end)

# Secrets: your real settings stay on your computer
.env

The complete file:

dewiride-lead-qualifier/.gitignore
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info

# Virtual environments
.venv

# Secrets: your real settings stay on your computer
.env

2. Create a template. Create a new file named .env.example in the project root. It lists every setting, without any secret, and it is committed so that everyone knows what to fill in:

dewiride-lead-qualifier/.env.example
# Copy this file to .env, then fill in your own values.
# .env is listed in .gitignore, so your real key never reaches Git.

# Required: your Claude API key (https://platform.claude.com/settings/keys)
ANTHROPIC_API_KEY=

# Optional: these are the defaults. Remove the # from a line to change it.
# LEAD_QUALIFIER_CLAUDE_MODEL=claude-opus-5-5
# LEAD_QUALIFIER_CLAUDE_EFFORT=low
# LEAD_QUALIFIER_CLAUDE_MAX_TOKENS=16000

3. Make your own copy. Copy the template to a file named .env:

Terminal
cp .env.example .env

Open .env in VS Code and paste your key straight after ANTHROPIC_API_KEY=, with no spaces and no quotes. Save the file. The first line should look like this, with your real key in place of sk-ant-…:

dewiride-lead-qualifier/.env (first setting)
ANTHROPIC_API_KEY=sk-ant-…

The optional lines stay commented out (they start with #), so the app uses its defaults: Claude Opus 5.5, low effort and up to 16,000 output tokens per lead. Part 1 explains why those are sensible.

.env holds a secret

Never commit .env, share it, or paste its contents anywhere. If you think the key has leaked, delete it in the Claude Console and create a new one.

Step 8: Load the Settings with pydantic-settings​

Now the code that reads .env. Create src/lead_qualifier/config.py and build it up in four pieces.

The imports:

src/lead_qualifier/config.py
"""Application settings, read from environment variables and the .env file."""

from functools import lru_cache
from typing import Literal

from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict

The settings class and where it reads from. Add below the imports:

src/lead_qualifier/config.py (add below the imports)
class Settings(BaseSettings):
"""Every setting the app needs, with a safe default wherever one exists."""

model_config = SettingsConfigDict(
env_prefix="LEAD_QUALIFIER_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)

A class that inherits from BaseSettings fills its fields from environment variables and from the .env file. When both set the same value, the environment variable wins. model_config controls how:

  • env_file=".env" reads the .env file in the folder you start the app from, which is the project root.
  • extra="ignore" skips anything in .env that the class doesn't define, instead of failing.
  • env_prefix="LEAD_QUALIFIER_" means each setting must start with LEAD_QUALIFIER_ in .env or the environment. For example, the field claude_effort is read from LEAD_QUALIFIER_CLAUDE_EFFORT.

The prefix is there because of a real problem found while building this part. The first version used plain names like CLAUDE_EFFORT. But the terminal we tested in already had an environment variable with exactly that name, set to max: tools such as Claude Code set it in the terminals they run. Environment variables win over .env, so the app quietly switched to the slowest, most expensive effort level, and nothing in .env showed it. A prefix unique to your app prevents that whole class of surprise.

The settings themselves. Add them inside the class, below model_config:

src/lead_qualifier/config.py (add inside the class)
anthropic_api_key: SecretStr = Field(
validation_alias="ANTHROPIC_API_KEY",
description="Your Claude API key from the Claude Console. Required.",
)
claude_model: str = Field(
default="claude-opus-5-5",
description="The Claude model that analyzes each lead.",
)
claude_effort: Literal["low", "medium", "high", "xhigh", "max"] = Field(
default="low",
description="How much Claude thinks before answering. Higher is slower and costs more.",
)
claude_max_tokens: int = Field(
default=16000,
gt=0,
description="The most tokens Claude may write for one lead.",
)

Each line is a field: a name, a type, and usually a default.

  • anthropic_api_key has no default, so it is required. If it is missing, the app refuses to start.
    • validation_alias="ANTHROPIC_API_KEY" reads it from the standard name every Anthropic tool uses, without the prefix.
    • Its type, SecretStr, hides the value: printing the settings shows ********** instead of the key, so it cannot leak into a log by accident.
  • claude_effort uses Literal[...], so only those five words are accepted. A typo like LEAD_QUALIFIER_CLAUDE_EFFORT=lowe stops the app with an error instead of being silently used.
  • claude_max_tokens must be greater than 0 (gt=0).

The model, effort and token settings are used in Part 4, when the app first calls Claude. Defining them now keeps every setting in one place.

One shared copy. Add this at the end of the file, outside the class:

src/lead_qualifier/config.py (add at the end)
@lru_cache
def get_settings() -> Settings:
"""Read the settings once, then reuse the same object everywhere."""
return Settings()

@lru_cache remembers the result of the first call. So .env is read once, and every part of the app gets the same Settings object.

The complete file:

src/lead_qualifier/config.py
"""Application settings, read from environment variables and the .env file."""

from functools import lru_cache
from typing import Literal

from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
"""Every setting the app needs, with a safe default wherever one exists."""

model_config = SettingsConfigDict(
env_prefix="LEAD_QUALIFIER_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)

anthropic_api_key: SecretStr = Field(
validation_alias="ANTHROPIC_API_KEY",
description="Your Claude API key from the Claude Console. Required.",
)
claude_model: str = Field(
default="claude-opus-5-5",
description="The Claude model that analyzes each lead.",
)
claude_effort: Literal["low", "medium", "high", "xhigh", "max"] = Field(
default="low",
description="How much Claude thinks before answering. Higher is slower and costs more.",
)
claude_max_tokens: int = Field(
default=16000,
gt=0,
description="The most tokens Claude may write for one lead.",
)


@lru_cache
def get_settings() -> Settings:
"""Read the settings once, then reuse the same object everywhere."""
return Settings()

Step 9: Give the Package a Version Number​

Replace the whole content of src/lead_qualifier/__init__.py, the sample main() function uv generated, with:

src/lead_qualifier/__init__.py
"""Dewiride Lead Qualifier: qualify incoming sales inquiries with Claude."""

from importlib.metadata import version

__version__ = version("lead-qualifier")

version("lead-qualifier") reads the version from pyproject.toml (0.1.0), so the number lives in exactly one place. The interactive docs page shows it in a moment.

Step 10: Add the Health Endpoint​

Endpoints live in their own folder, api/. Create the folder src/lead_qualifier/api/ and, inside it, a file named __init__.py with one line. It marks the folder as a Python package:

src/lead_qualifier/api/__init__.py
"""HTTP endpoints of the Lead Qualifier API."""

Next to it, create health.py. Start with the imports and a router, which groups related endpoints:

src/lead_qualifier/api/health.py
"""Health check: lets you, and any hosting platform, confirm the service is up."""

from typing import Literal

from fastapi import APIRouter
from pydantic import BaseModel

router = APIRouter(tags=["health"])

Add the shape of the answer. A Pydantic model describes exactly what the endpoint returns. Here that is one field whose only allowed value is "ok":

src/lead_qualifier/api/health.py (add below the router)
class HealthResponse(BaseModel):
"""The body returned by GET /health."""

status: Literal["ok"]

Then add the endpoint itself:

src/lead_qualifier/api/health.py (add at the end)
@router.get("/health", summary="Check that the service is running")
async def health() -> HealthResponse:
"""Return `ok` when the service is up. It never calls Claude, so it costs nothing."""
return HealthResponse(status="ok")

@router.get("/health") connects the function to GET /health requests. async def lets the server keep serving other requests while one waits, which will matter once requests wait seconds for Claude. The return type -> HealthResponse tells FastAPI what the response looks like, so it can check it and describe it in the docs.

The complete file:

src/lead_qualifier/api/health.py
"""Health check: lets you, and any hosting platform, confirm the service is up."""

from typing import Literal

from fastapi import APIRouter
from pydantic import BaseModel

router = APIRouter(tags=["health"])


class HealthResponse(BaseModel):
"""The body returned by GET /health."""

status: Literal["ok"]


@router.get("/health", summary="Check that the service is running")
async def health() -> HealthResponse:
"""Return `ok` when the service is up. It never calls Claude, so it costs nothing."""
return HealthResponse(status="ok")

Step 11: Create the FastAPI App​

The last file brings everything together. Create src/lead_qualifier/main.py and start with the imports:

src/lead_qualifier/main.py
"""The FastAPI application: builds the app and wires its parts together."""

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

from fastapi import FastAPI

from lead_qualifier import __version__
from lead_qualifier.api.health import router as health_router
from lead_qualifier.config import get_settings

Add the lifespan function. It holds code that should run once when the app starts and once when it stops:

src/lead_qualifier/main.py (add below the imports)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Code before `yield` runs once at startup; code after it runs once at shutdown."""
# Read the settings now, so a missing API key stops the app at startup,
# not halfway through a customer's request.
app.state.settings = get_settings()
yield

This is the fail fast principle: check everything you can before accepting the first request. app.state is a place to keep shared objects. Part 4 adds the Claude client here too.

Add the app factory, a function that builds and returns the configured app:

src/lead_qualifier/main.py (add below lifespan)
def create_app() -> FastAPI:
"""Build and configure the FastAPI application."""
app = FastAPI(
title="Dewiride Lead Qualifier",
summary="Qualify incoming sales inquiries with Claude.",
version=__version__,
lifespan=lifespan,
)
app.include_router(health_router)
return app


app = create_app()

title, summary and version appear on the docs page. include_router plugs in the health endpoint, and each new group of endpoints later is one more include_router line. The last line creates the app object the web server looks for.

The complete file:

A macOS-style code window titled src/lead_qualifier/main.py showing the lifespan function that loads the settings at startup, the create_app factory that builds the FastAPI app with its title, summary, version and health router, and the module-level app object

src/lead_qualifier/main.py
"""The FastAPI application: builds the app and wires its parts together."""

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

from fastapi import FastAPI

from lead_qualifier import __version__
from lead_qualifier.api.health import router as health_router
from lead_qualifier.config import get_settings


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Code before `yield` runs once at startup; code after it runs once at shutdown."""
# Read the settings now, so a missing API key stops the app at startup,
# not halfway through a customer's request.
app.state.settings = get_settings()
yield


def create_app() -> FastAPI:
"""Build and configure the FastAPI application."""
app = FastAPI(
title="Dewiride Lead Qualifier",
summary="Qualify incoming sales inquiries with Claude.",
version=__version__,
lifespan=lifespan,
)
app.include_router(health_router)
return app


app = create_app()

Step 12: Format and Check the Code with Ruff​

Let Ruff tidy and check everything:

Terminal
uv run ruff format
Output
Building lead-qualifier @ file:///Users/you/source/dewiride-lead-qualifier
Built lead-qualifier @ file:///Users/you/source/dewiride-lead-qualifier
Uninstalled 1 package in 1ms
Installed 1 package in 1ms
6 files left unchanged

uv run runs a command inside the project's environment. First it checks the environment is up to date. Because you edited pyproject.toml, it rebuilt the project before running Ruff. Ruff looked at the five Python files and pyproject.toml. If you typed the code exactly as shown, nothing needed reformatting.

Terminal
uv run ruff check
Output
All checks passed!

Run these two commands whenever you finish a change. Ruff's VS Code extension also shows its findings as you type.

Step 13: Run the API​

Start the web server:

Terminal
uv run uvicorn lead_qualifier.main:app --reload
Output
INFO: Will watch for changes in these directories: ['/Users/you/source/dewiride-lead-qualifier']
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: Started reloader process [83946] using WatchFiles
INFO: Started server process [83948]
INFO: Waiting for application startup.
INFO: Application startup complete.
Part of the commandWhat it does
uvicornThe web server you installed in Step 4
lead_qualifier.main:appWhere the app is: the app object in the lead_qualifier.main module, that is, src/lead_qualifier/main.py
--reloadRestart automatically whenever you save a file. Use it only while developing.

Application startup complete. means the lifespan function ran, so your settings were read and the API key was found. The process numbers in brackets will differ on your computer. The server keeps running in this terminal; open a second terminal for any other commands.

Step 14: Try the API in Your Browser​

FastAPI writes the documentation for your API automatically. Open http://127.0.0.1:8000/docs in your browser:

The Swagger UI page titled Dewiride Lead Qualifier with version 0.1.0 and OAS 3.1 badges, the summary Qualify incoming sales inquiries with Claude, a health section listing GET /health Check that the service is running, and a Schemas section listing HealthResponse

This page is called Swagger UI. Every endpoint appears here with the summary you wrote, and under Schemas you can see the HealthResponse model. The version badge, 0.1.0, comes from pyproject.toml through Step 9.

Try the endpoint:

  1. Select GET /health to expand it.
  2. Select Try it out.
  3. Select Execute.

The expanded GET /health endpoint after selecting Execute, showing the endpoint description, the curl command and request URL http://127.0.0.1:8000/health, and the server response: code 200 with the response body status ok and response headers including content-type application/json and server uvicorn

Swagger UI sent a real request to your server. The answer is code 200 (success) with the body {"status": "ok"}. In Part 4, the same page is how you will send a sales inquiry to Claude without writing any extra code.

You can also call the API from a terminal. Open a second terminal, because the first is busy running the server:

Terminal
curl http://127.0.0.1:8000/health
Output
{"status":"ok"}

On Windows, type curl.exe, not curl: in PowerShell, plain curl is a different command with different output. The server's terminal logs each request:

Output (server terminal)
INFO: 127.0.0.1:60836 - "GET /health HTTP/1.1" 200 OK

When you are done, stop the server with Ctrl+C in its terminal:

Output
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [83948]
INFO: Stopping reloader process [83946]

Step 15: Save Your Work with Git​

uv created a Git repository in Step 1. Before the first commit, check what Git is about to include:

Terminal
git status
Output
On branch master

No commits yet

Untracked files:
(use "git add <file>..." to include in what will be committed)
.env.example
.gitignore
.python-version
README.md
pyproject.toml
src/
uv.lock

nothing added to commit but untracked files present (use "git add" to track)

Look for what is missing: .env and .venv/ are not listed, because .gitignore hides them. Your API key cannot be committed by accident. Your branch may be called main instead of master, depending on your Git settings.

Commit the project:

Terminal
git add .
git commit -m "Create the project skeleton: settings, health check and FastAPI app"
Output
[master (root-commit) 6d17f65] Create the project skeleton: settings, health check and FastAPI app
11 files changed, 856 insertions(+)
create mode 100644 .env.example
create mode 100644 .gitignore
create mode 100644 .python-version
create mode 100644 README.md
create mode 100644 pyproject.toml
create mode 100644 src/lead_qualifier/__init__.py
create mode 100644 src/lead_qualifier/api/__init__.py
create mode 100644 src/lead_qualifier/api/health.py
create mode 100644 src/lead_qualifier/config.py
create mode 100644 src/lead_qualifier/main.py
create mode 100644 uv.lock

The code after root-commit is your commit's ID, so yours will differ. If Git asks who you are, set your name and email once with git config --global user.name "Your Name" and git config --global user.email "you@example.com", then commit again.

Your Project Now Looks Like This​

dewiride-lead-qualifier/
├── .env (ignored by Git)
├── .env.example
├── .gitignore
├── .python-version
├── .venv/ (ignored by Git)
├── README.md
├── pyproject.toml
├── uv.lock
└── src/
└── lead_qualifier/
├── __init__.py
├── config.py
├── main.py
└── api/
├── __init__.py
└── health.py

Common Mistakes​

MistakeWhat happensHow to avoid it
Running commands outside the project rootuv cannot find pyproject.toml, or the app cannot find .envcd dewiride-lead-qualifier first, and use VS Code's terminal, which starts there
Installing packages with pip installThe package is not recorded in pyproject.toml or uv.lock, so it is missing on the next machineAlways use uv add (or uv add --dev for developer tools)
Committing .envYour API key is published with your codeAdd .env to .gitignore before the first commit, and check git status
Wrapping the key in quotes or adding spaces in .envThe key is read wrongly, and Claude rejects it in Part 4Write ANTHROPIC_API_KEY=sk-ant-… with nothing around the key
Naming settings without a prefixAn environment variable from another tool silently overrides your settingPrefix every app setting, as with LEAD_QUALIFIER_ here
Editing uv.lock by handuv may refuse to use it, or install unexpected versionsLet uv manage it. Change versions with uv add or uv lock --upgrade-package.

Troubleshooting​

ProblemLikely causeFix
Startup fails with ValidationError: 1 validation error for Settings / ANTHROPIC_API_KEY / Field required, then Application startup failed. Exiting.No .env in the folder you started from, or the key line is emptyCreate .env from .env.example in the project root, paste your key, and start the server from the project root
No module named 'lead_qualifier'The command ran outside the project's environmentStart everything with uv run … from the project root
An error saying the address or port is already in useAnother program, or another copy of the server, already uses port 8000Stop the other copy with Ctrl+C, or add --port 8001 to the command and open http://127.0.0.1:8001/docs instead
VS Code underlines imports in redVS Code is not using the project's .venvRun Python: Select Interpreter and choose the entry in .venv
curl in PowerShell prints something strangePowerShell's curl is a different commandUse curl.exe
A setting in .env seems to be ignoredAn environment variable with the same name wins over .envCheck for the variable in your terminal (echo $LEAD_QUALIFIER_CLAUDE_EFFORT on macOS/Linux, $env:LEAD_QUALIFIER_CLAUDE_EFFORT in PowerShell)

Frequently Asked Questions​

Why uv instead of pip and venv?​

uv does the work of several older tools at once. It installs Python, creates the environment, installs packages, records exact versions in a lock file and runs commands, and it does all of this very quickly. pip plus venv can do some of it, but has no lock file built in, so two people can end up with different versions.

Why put the code in a src folder?​

With a src layout, Python can import your code only after it is installed into the environment, which uv add and uv run do for you. Tests, the web server and your editor therefore all use the code the same way it will run in production. It also stops you importing files by accident just because they happen to sit in the current folder.

Should I commit uv.lock and .env.example?​

Yes, both. uv.lock makes every install identical, and .env.example documents the settings. Only .env, which holds real secrets, stays out of Git.

Why uvicorn instead of fastapi dev?​

fastapi dev comes with the larger fastapi[standard] bundle, which this project skips (see the tip in Step 4). Uvicorn is the server underneath it either way, and the same command, without --reload, runs the app in production.

Can I use another editor, such as PyCharm?​

Yes. Every step uses the terminal, so it works in any editor. Point your editor at the .venv folder so it finds the installed packages.

Where is the AI?​

In Part 4. First, Part 3 designs the exact shape of the data Claude will return.

Conclusion​

You built the foundation of the Lead Qualifier the way a production team would:

  • a uv project with a src layout, a pinned Python version and a lock file,
  • four lean dependencies, plus Ruff as a development tool,
  • settings loaded from .env, with a required, hidden API key and an app-specific prefix,
  • a FastAPI app factory that checks its settings at startup,
  • a /health endpoint, tested in Swagger UI and with curl,
  • a first Git commit that does not contain your secrets.

← Previous: Part 1: Overview, Architecture and Your Claude API Key

Next → Part 3: Design the Lead Data Model with Pydantic. You will design the exact JSON shape Claude must return, and see why every field description matters.

Additional Resources​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.