Anatomy of a Bot
Build a Chatbot: From API Call to Deployed Bot
Chapter 1 · What a Chatbot Is
You can call a language model from a dozen lines of code, and it will answer. That is not a chatbot. A chatbot remembers the conversation, speaks in a consistent voice, stays within limits, lives somewhere people can reach it (a web page, Telegram, Slack) and keeps running when you are not watching. This course builds all of that, one layer at a time, around a model call that you will treat as a replaceable part. This first chapter draws the map and builds the smallest working version of it.
FakeProvider. Statements about how real model APIs behave come from
their official documentation, and are labelled as such. The model call itself is covered in Advanced AI Workflows & the Claude API 1; this course assumes it and does not repeat it.
A Model Call Is Not a Chatbot
The main thing to understand about chat APIs is that they are stateless. Anthropic's Messages API documentation describes itself as usable for "single queries or stateless
multi-turn conversations", and says you give the earlier turns in the messages parameter, from which the model "generates the next Message". OpenAI's conversation-state guide says
the same of its text requests: "each text generation request is independent and stateless", and offers three ways to supply continuity: pass the earlier messages yourself, chain responses
with a previous_response_id, or use a stored Conversations object. (For OpenAI, state can optionally live on their side, but you are still billed for the earlier tokens as input.)
In practice this means that if you send "My name is Ada" in one request and "What is my name?" in the next, the second request contains nothing about Ada, unless your code put it there. Remembering is your job. That single fact is why a chatbot is a program and not just an API key. To make it concrete without a real model, the code below has two functions that differ in only this respect, and a scripted provider that records what it is sent:
The output is what the fake provider was sent. With one_shot the second call carries one message, so a real model would have no way to know the name. With Bot it
carries three: the first question, the first answer, and the new question. A fake cannot prove how a real model behaves, but it proves exactly what your code sends, and that is the part you control.
The Five Parts a Bot Adds
Every chatbot in this course is built from the same five parts, and every later chapter fills in one of them:
| Part | The question it answers | Built in |
|---|---|---|
| Channel adapter | Where do messages come from and go to? (terminal, web page, Telegram, Discord, Slack) | Chapters 8-10; a terminal version below |
| Conversation manager | Who is talking, what has been said, and how much of it do we still send? | Chapter 4; the simplest version is Bot above |
| Provider | Which model writes the reply, and how do we call it? | Chapters 2-3 |
| Store | What survives a restart: histories, settings, usage? | Chapter 4 (SQLite) |
| Policy | What is the bot allowed to say and spend: persona, guardrails, moderation, budgets, rate limits? | Chapters 5-7 |
The design rule for the whole course is that the channel and the provider know nothing about each other. A channel hands the core a session id and some text and shows whatever text comes back; a provider takes messages and returns a reply. Everything else (memory, persona, limits, logging) lives in the middle. That is what lets one bot run in a terminal today, on Telegram next week, and against a different model the week after.
A Request's Journey
This chapter implements only the middle three boxes in their simplest form, which is why the program is so short. Later chapters add the missing boxes without changing the shape of the pipeline.
The Project Layout
All the code in this course lives in one small project, kept outside the website's own folders, and it grows chapter by chapter. This is the layout after this chapter:
You need only Python and its standard library for this chapter; later chapters add packages one at a time and say so. (Everything here was run on Python 3.14.0; the syntax used, such as list[str] | None, needs Python 3.10 or newer.) Create the folders and the
empty __init__.py files, then add the files below.
The Provider: Three Lines of Contract
A provider is anything with an async reply(messages) method that returns text. In Python we write that contract as a typing.Protocol, so any class with the right method
qualifies without inheriting from anything. Here are the contract and two tiny providers:
The FakeProvider is the most important class in the course. It returns scripted replies (or a description of what it received), and it keeps every list of messages it was sent in
self.calls, so a test can check exactly what the bot gave the model. Chapter 2 extends it with errors, delays and token counts, and replaces this deliberately minimal contract with a
fuller one. Treat this version as a first sketch.
The Terminal Channel
A channel only moves text. This one reads a line from the keyboard (in a thread, so it does not block the event loop that later channels will share) and prints the bot's answer:
And the entry point, which picks a provider by name so you can swap it from the command line:
Here the lines were piped in, so the prompts and answers run together on one line; typed at a keyboard they appear on separate lines. The second run shows the point of the chapter in one place: the bot, with no real model behind it, sent 1 message on the first turn and 3 on the second, because it kept the conversation.
Your First Tests
Because the fake records what it is sent, the behaviour above becomes four automated tests, using only the standard library's unittest:
ImportError: Start directory is not importable. unittest discover needs the tests folder to be a package, meaning it must contain an
__init__.py, when you pass -t . to set the project root. I had forgotten that file. The fix was adding the empty tests/__init__.py (it is in the layout above), and
the lesson is to create every __init__.py when you create the folders rather than when something fails.
Where This Is Going
What you have is a bot in the smallest sense: it keeps a conversation, it can be pointed at any provider, and it has a channel. It is also missing nearly everything that makes a bot usable. The history grows without limit and vanishes when the program stops. There is no personality, no limit on what it says or spends, no way to survive a model outage, and nothing outside a terminal. Each of those is one of the next eleven chapters, and the plan for the finished bot is: a persona with memory, a spending budget, a web chat page and one chat platform, running as a service.
One scope note. This course is about everything around the model call, so it deliberately skips what Advanced AI Workflows & the Claude API already teaches: the API itself (chapter 1 there), system prompts (2), tool use (3), streaming (4), prompt caching (6), retrieval (8) and evaluation (9). Where a bot touches one of those, this course covers only the bot-specific part and links back.
Hands-On Exercises
Predict, then measure, how many messages the fake provider is sent on each of three turns by (a) Bot with one session, (b) one_shot, and (c) a Bot with two interleaved sessions (alice, bob, alice). Write the predictions down first, then run the code and compare.
Write a second channel, run_script(bot, lines), that needs no keyboard: it feeds a list of lines to the bot and returns the transcript as a list of (you, bot) pairs. Use it with a scripted FakeProvider to run a two-line conversation. Say why this channel will be useful for testing in later chapters.
Write a ReverseProvider that answers with the last user message reversed, and run it in a Bot without changing Bot or any channel. Then pass the bot an object that is not a provider at all and find out when Python complains. What does that tell you about what Protocol does and does not do at run time?
Chapter 1 Quick Reference
- Chat APIs are stateless: continuity is whatever messages your code sends (Anthropic's Messages API docs: "stateless multi-turn conversations"; OpenAI: "each text generation request is independent and stateless", with optional stored state)
- A bot = channel adapter + conversation manager + provider + store + policy
- Channels and providers never know about each other: the core hands the provider a list of messages and returns text to the channel
Provideris atyping.Protocolwithasync reply(messages) -> str; chapter 2 replaces this sketch with a fuller contractFakeProviderrecords every list of messages it is sent: assert onfake.callsto test what your bot sends the model- Run the tests with
py -m unittest discover -s tests -t .(needstests/__init__.py) - Project lives outside the website folders; one file per part, grown chapter by chapter
FakeProvider, and contract tests every provider must pass.