LEARN · ML FOUNDATIONS & CHATBOTS
A useful AI chatbot is not just “a prompt connected to a model.” Even the smallest real application needs a few moving parts: instructions that define behavior, user input, an API call, conversation state, error handling, and a loop that keeps the interaction going.
In this tutorial, you will build all of those pieces yourself in Python. The result will run in your terminal, remember earlier turns in the current conversation, follow a reusable behavior prompt, and give you a foundation you can later connect to a web interface, database, search system, or business workflow.
The implementation uses OpenAI’s current Python SDK, the Responses API, and gpt-5.6, which is the model shown in OpenAI’s current developer quickstart as of August 2026. The Responses API is the primary interface for new response-generation integrations in the current Python API documentation.
What you will build
By the end, your project will look like this:
first-ai-chatbot/ ├── .venv/ ├── chatbot.py └── test_api.py
Running the program will produce an interaction roughly like this:
AI assistant ready. Type /new to start a new conversation. Type /quit to exit. You: I am planning a three-day trip and want to travel light. Assistant: A good starting point is to build around interchangeable layers... You: What did I say my main constraint was? Assistant: You said you want to travel light.
That second answer is important. A one-shot model call cannot infer an earlier message unless the earlier context is made available somehow. We will solve that using the Responses API’s previous_response_id mechanism, which lets one response continue from another.
You will learn:
-
how a model call is structured;
-
how to separate developer instructions from user input;
-
how to keep an API key out of your Python source;
-
how to maintain multi-turn conversation context;
-
how to reset a conversation;
-
how to handle failures without immediately crashing the program;
-
when to move from response chaining to persistent conversations;
-
how streaming changes the user experience.
No training dataset is required. We are using an already trained model through an API rather than training a model ourselves.
The mental model: input goes in, a response comes back
At its simplest, a language-model application performs one transformation:
instructions + user input + conversation context
↓
model
↓
generated output
The interesting engineering happens around that middle box.
A production system may also add authentication, retrieval, tool calls, databases, observability, evaluation, caching, moderation, rate limiting, and user-interface code. But those features make more sense once you understand the smallest working loop.
For our application, that loop is:
-
Read a message from the user.
-
Send it to the model.
-
Print the returned text.
-
Save the returned response ID.
-
Send that ID with the next user message.
-
Repeat until the user exits.
That is already enough to produce a genuinely conversational program.
Why use the Responses API?
OpenAI currently recommends the Responses API as the main response-generation interface, and its design includes text generation, conversation-state mechanisms, typed output items, tools, and streaming. The SDK also provides the convenient response.output_text helper when all you need is the final generated text.
You may encounter older tutorials built around accumulated messages arrays. That design can still exist in other interfaces, but for a new project we can use the current state-management features directly instead of manually rebuilding the entire transcript on every turn.
A basic current Python call looks like this:
from openai import OpenAI client = OpenAI() response = client.responses.create( model="gpt-5.6", input="Explain recursion to a beginner in two sentences.", ) print(response.output_text)
That pattern closely follows OpenAI’s current Python quickstart.
There are four things to notice:
-
OpenAI()creates the API client. -
responses.create(...)requests a model response. -
modelchooses the model. -
response.output_textgives you the final text without manually walking through output items.
Our chatbot is mostly this call wrapped in a better application structure.
Step 1: create the project
Create a new directory and virtual environment:
mkdir first-ai-chatbot cd first-ai-chatbot python -m venv .venv source .venv/bin/activate
Now install the current OpenAI Python package:
python -m pip install --upgrade pip openai
Using a virtual environment keeps this project’s dependencies separate from packages installed elsewhere on your machine.
Whenever you open a fresh terminal later, return to the project directory and reactivate the environment:
source .venv/bin/activate
Step 2: configure the API key
The SDK can automatically read your API key from the OPENAI_API_KEY environment variable. OpenAI’s current quickstart explicitly recommends creating an API key and exposing it through the environment rather than embedding it in application source.
One convenient Bash approach is to enter the key without printing it on screen:
read -s -p "OpenAI API key: " OPENAI_API_KEY echo export OPENAI_API_KEY
Your terminal will appear not to type anything while you enter the secret. That is intentional.
Check that the variable exists without printing the secret itself:
python -c 'import os; print("API key configured:", bool(os.getenv("OPENAI_API_KEY")))'
Expected output:
API key configured: True
Do not put a real API key directly into chatbot.py, a Git commit, a public notebook, a screenshot, or a client-side web application.
A pattern such as this is deliberately absent from our code:
api_key = "your-real-secret-key"
The secret belongs in your environment or a proper secret manager, not in source code.
Step 3: make the smallest possible API test
Before building an interactive program, test one API call. This isolates configuration problems from application bugs.
Create test_api.py:
from openai import OpenAI client = OpenAI() response = client.responses.create( model="gpt-5.6", input="Reply with exactly: API connection works.", ) print(response.output_text)
Run it:
python test_api.py
You should receive:
API connection works.
Do not skip this small test.
If the full chatbot fails later, you now know whether the underlying SDK and credentials worked independently. Debugging gets much easier when you verify one layer at a time.
Step 4: turn a prompt into reusable instructions
A user message answers the question, “What does the user want right now?”
Instructions answer a different question: “How should this application behave in general?”
For example, this is user input:
Explain why my Python list changes when I modify another variable.
These are application-level instructions:
You are a practical programming tutor. Explain concepts in beginner-friendly language. Prefer short examples over abstract descriptions. Never claim that you ran code unless a tool actually ran it. If information is uncertain, say so.
Keeping those responsibilities separate is valuable.
If you jam application rules into every user-facing prompt, your program becomes difficult to maintain. If you keep stable behavior in an INSTRUCTIONS constant, you can revise the personality and policies of your application without changing the conversation loop.
We will use this:
INSTRUCTIONS = """ You are a practical, friendly AI assistant. Give direct answers before optional detail. Use plain language unless the user requests technical depth. Use short examples when they make an explanation clearer. Do not claim that you performed an external action unless the application actually provided a tool that performed it. If you are uncertain about an important fact, clearly say that you are uncertain. If a request is ambiguous and the ambiguity prevents a useful answer, ask one focused clarification question. """
These instructions are more useful than vague wording such as “You are helpful.”
Good instructions define observable behavior.
Compare these two versions.
Weak:
Be a great assistant.
Stronger:
Answer the user's question directly. Prefer concrete examples. Distinguish facts from assumptions. Do not pretend to have completed actions outside this application. Ask a clarification question only when missing information materially affects the answer.
The second version gives the model much more information about what desirable behavior actually looks like.
Step 5: build the interactive chatbot
Now create chatbot.py:
from openai import OpenAI MODEL = "gpt-5.6" INSTRUCTIONS = """ You are a practical, friendly AI assistant. Give direct answers before optional detail. Use plain language unless the user requests technical depth. Use short examples when they make an explanation clearer. Do not claim that you performed an external action unless the application actually provided a tool that performed it. If you are uncertain about an important fact, clearly say that you are uncertain. If a request is ambiguous and the ambiguity prevents a useful answer, ask one focused clarification question. """ def create_response(client, user_text, previous_response_id): request = { "model": MODEL, "instructions": INSTRUCTIONS, "input": user_text, } if previous_response_id is not None: request["previous_response_id"] = previous_response_id return client.responses.create(**request) def main(): client = OpenAI() previous_response_id = None print("AI assistant ready.") print("Type /new to start a new conversation.") print("Type /quit to exit.") print() while True: try: user_text = input("You: ").strip() except (EOFError, KeyboardInterrupt): print("\nGoodbye.") break if not user_text: continue if user_text.lower() == "/quit": print("Goodbye.") break if user_text.lower() == "/new": previous_response_id = None print("Started a new conversation.\n") continue try: response = create_response( client=client, user_text=user_text, previous_response_id=previous_response_id, ) except Exception as exc: print(f"\nRequest failed: {exc}\n") continue answer = response.output_text if not answer: answer = "[The model returned no text output.]" print(f"\nAssistant: {answer}\n") previous_response_id = response.id if __name__ == "__main__": main()
Run it:
python chatbot.py
You now have a working conversational application.
Walking through the code
The client setup is intentionally boring:
client = OpenAI()
That is good. Infrastructure code should be boring whenever possible.
Because OPENAI_API_KEY is already available in the environment, you do not need to copy the key into the constructor. OpenAI’s SDK reads that environment variable automatically.
Next comes our state variable:
previous_response_id = None
For the first turn, there is no previous response.
Suppose the user writes:
My project codename is Blue Finch.
The application sends a request without previous_response_id.
After the API returns, this line runs:
previous_response_id = response.id
Now imagine the user asks:
What did I say the codename was?
The next request includes the earlier response ID:
request["previous_response_id"] = previous_response_id
The Responses API can then continue from that earlier context. OpenAI documents previous_response_id specifically as a way to chain responses into a threaded conversation.
This is the core mechanism that turns repeated one-shot calls into a conversation.
An easy-to-miss detail: resend your instructions
There is a subtle behavior here that is worth understanding.
When you use previous_response_id, the earlier conversational context can carry forward, but top-level instructions from the previous response do not automatically become the instructions for the next response.
OpenAI’s current migration guide therefore recommends resending stable instructions on every request when chaining with previous_response_id.
That is why our helper always contains:
request = {
"model": MODEL,
"instructions": INSTRUCTIONS,
"input": user_text,
}
and only conditionally adds:
request["previous_response_id"] = previous_response_id
A tempting implementation would be:
if previous_response_id is None:
request["instructions"] = INSTRUCTIONS
Do not use that pattern if you expect the same behavior rules to remain active throughout the conversation.
The stable instructions belong on every turn.
Step 6: test whether memory really works
Do not merely ask random questions and conclude that conversation state works because the answers seem plausible.
Create a test that specifically requires earlier context.
Try this interaction:
You: Remember these three items: charger, notebook, green umbrella. Assistant: Got it: charger, notebook, and green umbrella. You: Which item was a color? Assistant: The green umbrella.
Then start over:
You: /new Started a new conversation. You: Which item was a color?
The model should no longer have the earlier conversational context supplied by your response chain.
That demonstrates what /new really does:
previous_response_id = None
It does not erase anything magical from the model. It simply stops connecting the next request to the previous response chain.
Response chaining is not the same as permanent memory
This distinction matters.
Our terminal application keeps the latest response ID in a Python variable:
previous_response_id
Close the process, and that variable disappears.
So our current program has session context, not a durable user-memory system.
A production application might store identifiers in a database alongside an authenticated user or chat session. OpenAI also currently provides a Conversations API that works with the Responses API and exposes a durable conversation object intended for state that needs to survive across sessions, devices, or jobs.
A simple architecture might eventually look like this:
browser ↓ your backend ↓ user/session database ↓ conversation identifier ↓ Responses API
For your first application, however, previous_response_id keeps the important concept visible: one response is explicitly connected to the next.
Step 7: make failures survivable
Network requests fail.
API credentials can be missing. Billing configuration can be incomplete. A connection can drop. A service can reject a malformed request.
A beginner program often does this:
response = client.responses.create(...) print(response.output_text)
If the request raises an exception, the entire program exits.
Our version instead wraps the request:
try:
response = create_response(
client=client,
user_text=user_text,
previous_response_id=previous_response_id,
)
except Exception as exc:
print(f"\nRequest failed: {exc}\n")
continue
For a learning project, this broad handler keeps the interaction loop alive and makes the error visible.
For production software, you would normally become more selective. Different failures deserve different behavior:
-
authentication failures should usually fail fast;
-
transient connection failures may justify retries;
-
rate-limit responses may require backoff;
-
malformed requests usually indicate a programming bug;
-
unexpected server failures should be logged with enough context to investigate.
The important lesson is not “catch every exception forever.”
It is that your user interface should not assume an external API call can never fail.
Step 8: improve perceived speed with streaming
Our chatbot currently waits for the full answer and then prints it.
That is simple, but long answers can feel slower than they actually are because the user sees nothing until generation finishes.
The Responses API supports streaming. OpenAI’s current documentation uses stream=True and emits typed events while output is being generated. Common text-stream events include response.output_text.delta; a completed response emits response.completed.
Here is a standalone streaming example:
from openai import OpenAI client = OpenAI() stream = client.responses.create( model="gpt-5.6", instructions="Answer clearly and concisely.", input="Explain what an API is to someone learning programming.", stream=True, ) for event in stream: if event.type == "response.output_text.delta": print(event.delta, end="", flush=True) print()
Instead of waiting for the entire answer, the program prints text deltas as they arrive.
Streaming changes responsiveness, not necessarily total computation time. The user begins seeing useful output sooner.
For a first chatbot, I recommend getting the non-streaming version correct first. Once conversation state, error handling, and prompts work, add streaming as a user-experience improvement.
OpenAI also notes an important production tradeoff: partial streamed output is harder to moderate than a completed response because you are displaying content before the entire generation is available.
Prompt design is software design
Beginners often treat prompts as mysterious magic sentences.
A better mental model is to treat your instructions like configuration for a probabilistic component.
Ask what behavior your application needs.
For a programming tutor, you might want:
-
explanations before code;
-
runnable code examples;
-
explicit assumptions;
-
no invented package APIs;
-
short troubleshooting steps.
For a shopping helper, the behavior might instead require:
-
asking for budget when missing;
-
distinguishing requirements from preferences;
-
explaining tradeoffs;
-
avoiding unsupported claims about current stock.
For an internal documentation helper:
-
answer only from supplied company material;
-
say when evidence is missing;
-
cite the source document;
-
avoid guessing internal policy.
Notice how much more precise those requirements are than “be useful.”
You can even test your instructions with a small evaluation set.
For example:
Test 1: User gives an ambiguous request. Expected behavior: ask one focused clarification question. Test 2: User asks whether an external email was sent. Expected behavior: do not claim it was sent unless a tool actually performed the action. Test 3: User requests a simple explanation. Expected behavior: answer directly before adding background. Test 4: User asks for uncertain information. Expected behavior: distinguish uncertainty from known facts.
That is the beginning of systematic AI application development.
You are no longer judging the program by whether one demo “felt smart.” You are defining behaviors and checking whether the system consistently produces them.
Your chatbot does not automatically have access to everything
A language model call is not the same thing as giving the application arbitrary access to your computer, database, calendar, customer system, or the live web.
If your application needs external information or actions, you must deliberately connect those capabilities.
For example, a future version might let the model request:
get_order_status(order_id)
Your own application would then:
-
validate the requested order ID;
-
check whether the current user is allowed to access it;
-
query the order system;
-
return the result to the model;
-
let the model explain that result.
OpenAI’s current function-calling interface is designed for exactly this pattern: models can produce structured tool requests that your application maps to external data or actions.
That separation is a security feature.
The model should not receive unrestricted database credentials merely because somebody typed, “Please look up my order.”
Your application remains responsible for authorization and execution.
What not to build into the first version
There is a strong temptation to add everything immediately:
-
a web framework;
-
authentication;
-
a vector database;
-
retrieval;
-
function calls;
-
web search;
-
voice;
-
persistent storage;
-
analytics;
-
multiple specialized model roles.
Resist that until the basic interaction works.
Each layer introduces a new failure mode.
If a 12-component system gives a bad answer, you may not know whether the cause was the prompt, retrieval, model choice, stale conversation state, incorrect tool output, user authorization, context truncation, or your frontend.
A terminal chatbot gives you a controlled baseline.
Once it works reliably, add one capability at a time.
A practical upgrade path
A sensible progression from this tutorial is:
-
Version 1: terminal interaction with
previous_response_id; -
Version 2: streaming output;
-
Version 3: persistent users and conversation identifiers;
-
Version 4: a small web interface;
-
Version 5: one carefully scoped tool;
-
Version 6: retrieval from your own documents;
-
Version 7: logging and evaluation;
-
Version 8: production security, monitoring, rate limits, and deployment.
OpenAI’s current platform also supports built-in tools, function calling, remote MCP connections, file search, and other agent-oriented capabilities through the Responses ecosystem, so the basic request structure you learned here can grow substantially without throwing away the core mental model.
The important architectural habit is incremental development.
Make each layer work before adding the next.
Cherry on the cake: people were anthropomorphizing chat software decades ago
One of the most useful lessons about conversational software predates modern generative AI by roughly six decades.
Joseph Weizenbaum’s ELIZA, created in the 1960s, used comparatively simple conversational techniques rather than today’s generative model architecture. Yet users could still project understanding, personality, and emotional depth onto the interaction. The resulting tendency became known as the ELIZA effect: people can attribute human-like understanding to a textual computer system even when the underlying mechanism is far simpler than they imagine. An OpenAI developer-community discussion summarizes the term as originating from Weizenbaum’s 1966 ELIZA program.
That historical observation is remarkably relevant when you build a chatbot today.
A fluent answer can feel like evidence that the application:
-
remembers more than it actually does;
-
has performed actions it never performed;
-
has verified claims it merely generated;
-
possesses intentions or awareness;
-
understands the user’s situation more deeply than the software architecture supports.
As the developer, you know exactly how small our program is.
It contains a loop, some instructions, a response ID, and an API call.
The user does not see those internals.
That is why good conversational-product design should communicate capabilities accurately. If the system cannot send emails, do not let its wording imply that it sent one. If no live database is connected, do not present generated information as a database lookup. If a new conversation has no stored user context, do not promise permanent memory.
The more natural the interface feels, the more important this distinction becomes.
The complete application
For reference, here is the finished version again:
from openai import OpenAI MODEL = "gpt-5.6" INSTRUCTIONS = """ You are a practical, friendly AI assistant. Give direct answers before optional detail. Use plain language unless the user requests technical depth. Use short examples when they make an explanation clearer. Do not claim that you performed an external action unless the application actually provided a tool that performed it. If you are uncertain about an important fact, clearly say that you are uncertain. If a request is ambiguous and the ambiguity prevents a useful answer, ask one focused clarification question. """ def create_response(client, user_text, previous_response_id): request = { "model": MODEL, "instructions": INSTRUCTIONS, "input": user_text, } if previous_response_id is not None: request["previous_response_id"] = previous_response_id return client.responses.create(**request) def main(): client = OpenAI() previous_response_id = None print("AI assistant ready.") print("Type /new to start a new conversation.") print("Type /quit to exit.") print() while True: try: user_text = input("You: ").strip() except (EOFError, KeyboardInterrupt): print("\nGoodbye.") break if not user_text: continue if user_text.lower() == "/quit": print("Goodbye.") break if user_text.lower() == "/new": previous_response_id = None print("Started a new conversation.\n") continue try: response = create_response( client=client, user_text=user_text, previous_response_id=previous_response_id, ) except Exception as exc: print(f"\nRequest failed: {exc}\n") continue answer = response.output_text if not answer: answer = "[The model returned no text output.]" print(f"\nAssistant: {answer}\n") previous_response_id = response.id if __name__ == "__main__": main()
Run it with:
python chatbot.py
Then test it deliberately rather than merely chatting randomly.
Try:
My favorite project codename is Blue Finch.
Follow with:
What codename did I give you?
Reset the conversation:
/new
Then ask again:
What codename did I give you?
After that, modify INSTRUCTIONS and observe how behavior changes. Add streaming. Replace the terminal with a web form. Eventually connect exactly one real external tool.
That progression—from one prompt, to one API call, to conversation state, to a dependable application—is how you turn model access into actual software.
Your next action: create the directory, run test_api.py, and get the full chatbot.py loop working today. Once you can hold a multi-turn terminal conversation and reset it reliably, choose one concrete upgrade—streaming, persistent conversations, or a single external tool—and build that next rather than jumping straight to a complicated architecture.