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.

What was run, and what was not
Every program in this chapter was run (Python 3.14) and the output shown is the real output. No real model is called anywhere in this chapter: this machine has no API keys, and the whole course is built so that you can develop and test a bot without one, using a scripted stand-in called a 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:

from bot.providers import Message, Provider async def one_shot(provider: Provider, text: str) -> str: """A bare model call: no memory, every question stands alone.""" return await provider.reply([{"role": "user", "content": text}]) class Bot: """A provider plus a conversation: it remembers what was said, per session.""" def __init__(self, provider: Provider) -> None: self.provider = provider self.histories: dict[str, list[Message]] = {} async def handle(self, session_id: str, text: str) -> str: history = self.histories.setdefault(session_id, []) history.append({"role": "user", "content": text}) answer = await self.provider.reply(history) history.append({"role": "assistant", "content": answer}) return answer
one_shot: messages sent per call = [1, 1] Bot: messages sent per call = [1, 3] second call roles: ['user', 'assistant', 'user']

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:

PartThe question it answersBuilt in
Channel adapterWhere do messages come from and go to? (terminal, web page, Telegram, Discord, Slack)Chapters 8-10; a terminal version below
Conversation managerWho is talking, what has been said, and how much of it do we still send?Chapter 4; the simplest version is Bot above
ProviderWhich model writes the reply, and how do we call it?Chapters 2-3
StoreWhat survives a restart: histories, settings, usage?Chapter 4 (SQLite)
PolicyWhat 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

user types "What is my name?" | v [ channel adapter ] turns a platform event into (session_id, text) | v [ policy: before ] moderation, rate limit, budget check (chapters 5-7) | v [ conversation ] load history, add the new message, trim it (chapter 4) | v [ provider ] send messages, get a reply or an error (chapter 2) | v [ policy: after ] output checks, cost accounting (chapters 5-7) | v [ conversation ] save the reply to the history and the store | v [ channel adapter ] turns the text into a platform message | v user sees the answer

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:

chatbot/ ├── bot/ │ ├── __init__.py │ ├── core.py # Bot, one_shot │ ├── providers.py # Message, Provider, EchoProvider, FakeProvider │ └── channels/ │ ├── __init__.py │ └── terminal.py # the keyboard channel ├── tests/ │ ├── __init__.py │ └── test_core.py └── run_terminal.py

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:

from typing import Protocol Message = dict[str, str] # {"role": "user" | "assistant", "content": "..."} class Provider(Protocol): """Anything that can turn a conversation into the next reply.""" async def reply(self, messages: list[Message]) -> str: ... class EchoProvider: """The dumbest possible provider: repeats the last thing the user said.""" async def reply(self, messages: list[Message]) -> str: return "You said: " + messages[-1]["content"] class FakeProvider: """A scripted stand-in for a model. It records what it was sent.""" def __init__(self, script: list[str] | None = None) -> None: self.script = list(script or []) self.calls: list[list[Message]] = [] async def reply(self, messages: list[Message]) -> str: self.calls.append(list(messages)) if self.script: return self.script.pop(0) return f"(fake) I was sent {len(messages)} message(s); the last was: {messages[-1]['content']!r}"

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:

import asyncio from bot.core import Bot async def run_terminal(bot: Bot, session_id: str = "terminal") -> None: """Read lines from the keyboard, send each to the bot, print the answer.""" while True: try: line = await asyncio.to_thread(input, "you> ") except EOFError: break if line.strip().lower() in {"quit", "exit"}: break if not line.strip(): continue print("bot>", await bot.handle(session_id, line))

And the entry point, which picks a provider by name so you can swap it from the command line:

import asyncio import sys from bot.channels.terminal import run_terminal from bot.core import Bot from bot.providers import EchoProvider, FakeProvider PROVIDERS = {"echo": EchoProvider, "fake": FakeProvider} if __name__ == "__main__": name = sys.argv[1] if len(sys.argv) > 1 else "echo" asyncio.run(run_terminal(Bot(PROVIDERS[name]())))
$ printf 'hello\nwhat did I just say\nquit\n' | py run_terminal.py echo you> bot> You said: hello you> bot> You said: what did I just say you> $ printf 'My name is Ada\nWhat is my name?\nexit\n' | py run_terminal.py fake you> bot> (fake) I was sent 1 message(s); the last was: 'My name is Ada' you> bot> (fake) I was sent 3 message(s); the last was: 'What is my name?' you>

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:

import unittest from bot.core import Bot, one_shot from bot.providers import FakeProvider class CoreTests(unittest.IsolatedAsyncioTestCase): async def test_one_shot_sends_only_the_question(self): fake = FakeProvider() await one_shot(fake, "My name is Ada") await one_shot(fake, "What is my name?") self.assertEqual([len(c) for c in fake.calls], [1, 1]) async def test_bot_sends_the_whole_conversation(self): fake = FakeProvider() bot = Bot(fake) await bot.handle("a", "My name is Ada") await bot.handle("a", "What is my name?") self.assertEqual([len(c) for c in fake.calls], [1, 3]) self.assertEqual([m["role"] for m in fake.calls[1]], ["user", "assistant", "user"]) async def test_sessions_are_separate(self): fake = FakeProvider() bot = Bot(fake) await bot.handle("a", "hello") await bot.handle("b", "hello") self.assertEqual([len(c) for c in fake.calls], [1, 1]) async def test_script_is_used_in_order(self): bot = Bot(FakeProvider(["one", "two"])) self.assertEqual(await bot.handle("a", "x"), "one") self.assertEqual(await bot.handle("a", "y"), "two")
$ py -m unittest discover -s tests -t . -v test_bot_sends_the_whole_conversation ... ok test_one_shot_sends_only_the_question ... ok test_script_is_used_in_order ... ok test_sessions_are_separate ... ok Ran 4 tests in 0.193s OK
A mistake I made while building this
My first test run failed with 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

Exercise 1

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.

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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?

📄 View solution

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
  • Provider is a typing.Protocol with async reply(messages) -> str; chapter 2 replaces this sketch with a fuller contract
  • FakeProvider records every list of messages it is sent: assert on fake.calls to test what your bot sends the model
  • Run the tests with py -m unittest discover -s tests -t . (needs tests/__init__.py)
  • Project lives outside the website folders; one file per part, grown chapter by chapter
Coming next
Build a Chatbot: From API Call to Deployed Bot 2 replaces the three-line provider sketch with a real interface: messages in; text, token usage and normalised errors out. It then adds three implementations (Claude, any OpenAI-compatible API such as xAI's Grok, and a local server), a far more useful FakeProvider, and contract tests every provider must pass.