Your First Call — client.responses.create() and response.output_text
The Smallest Complete Program
Create first_call.py in the project you set up in Lessons 2 and 3:
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI()
response = client.responses.create(
model="gpt-5.6-luna",
input="Write a one-sentence bedtime story about a unicorn.",
)
print(response.output_text)
Run it:
python first_call.py
You should see a single sentence of generated text. That call cost a fraction of a cent — roughly fifteen input tokens and thirty output tokens, which on gpt-5.6-luna is well under one hundredth of a cent.
Nine lines, and every one of them is doing something worth understanding.
Line by Line
from dotenv import load_dotenv
from openai import OpenAI
Two imports: the .env loader from Lesson 3, and the client class. Note that OpenAI is capitalised as a class name while the package is lowercase openai — a distinction that causes a lot of ImportError confusion. The package is openai; the class inside it is OpenAI.
load_dotenv()
Reads .env and copies its values into os.environ. This must run before the next line. The client reads the environment at construction time, so a load_dotenv() placed after OpenAI() has no effect on a client that already failed.
client = OpenAI()
Constructs the client. With no arguments it reads OPENAI_API_KEY from the environment, sets https://api.openai.com/v1 as the base URL, and prepares an HTTP connection pool with the default two retries and ten-minute timeout.
Nothing has been sent yet. Constructing a client makes no network request — it is pure local setup. If this line raises, the problem is your key, not your connectivity.
response = client.responses.create(
model="gpt-5.6-luna",
input="Write a one-sentence bedtime story about a unicorn.",
)
This is the request. client.responses is the namespace for the Responses API and .create() maps to POST /v1/responses. This is the pattern across the whole SDK: client.<resource>.<action>() corresponds to an HTTP method and path, which is what makes the HTTP reference documentation directly usable from Python.
model names which model runs. It is required — there is no default, deliberately, because a silent default would mean a silent price. gpt-5.6-luna is chosen here because it is the cheapest model in the current line-up at $0.20 per million input tokens; you will run hundreds of throwaway calls in this course and there is no reason to pay fifty times more for them.
input is what you want the model to respond to. In its simplest form it is a plain string, which the API treats as a single message from the user.
This line blocks until the response arrives — typically one to several seconds. Under the hood the SDK serialises your arguments to JSON, attaches the Authorization header, sends the request, and parses the reply into a typed object.
print(response.output_text)
output_text is a convenience property that walks the response's structured output and concatenates the text it finds. For a simple text request it is exactly what you want. It is also a shortcut with real limits, which the rest of this lesson covers.
What Actually Came Back
response is not a string. Print the whole object and you will see a structure. A cleaner way to inspect it:
print(response.model_dump_json(indent=2))
model_dump_json() comes from Pydantic, the validation library the SDK uses. It serialises the object back to JSON, and it is the single most useful debugging tool in this course — whenever you are unsure what a response contains, dump it.
The shape is roughly this:
{
"id": "resp_abc123...",
"object": "response",
"created_at": 1757145600,
"status": "completed",
"model": "gpt-5.6-luna",
"output": [
{
"id": "msg_def456...",
"type": "message",
"role": "assistant",
"status": "completed",
"content": [
{
"type": "output_text",
"text": "Under a blanket of starlight, the little unicorn...",
"annotations": []
}
]
}
],
"usage": {
"input_tokens": 15,
"input_tokens_details": { "cached_tokens": 0 },
"output_tokens": 31,
"output_tokens_details": { "reasoning_tokens": 0 },
"total_tokens": 46
}
}
The fields you will use constantly:
| Field | Type | What it is for |
|---|---|---|
id | string | Unique ID, prefixed resp_. Used with previous_response_id to chain turns (Unit 4) and to retrieve a stored response later |
status | string | completed, incomplete, failed, or in_progress. Check this — see below |
model | string | The exact model that served the request, which may be more specific than what you asked for |
output | array | The real content: a list of typed items |
usage | object | Token counts. This is what you are billed on |
output Is an Array, and That Matters
The most consequential thing to understand about the Responses API is that output is a list of typed items, not a string.
For the simple case there is one item, of type message, whose content is a list containing one part of type output_text. Walking it manually:
for item in response.output:
if item.type == "message":
for part in item.content:
if part.type == "output_text":
print(part.text)
That is precisely what output_text does for you — which raises the obvious question of why you would ever write it out.
Because the array stops containing exactly one message the moment you do anything interesting:
- Reasoning models emit a
reasoningitem alongside the message. - Function calling produces
function_callitems when the model wants you to run one of your tools. In that case there may be no text at all, andoutput_textis an empty string even though the request completely succeeded. - Built-in tools like web search add items describing the searches performed.
- Annotations — citations, file references — live on the content part, not in the concatenated text, so
output_textsilently discards them.
The rule to carry forward: output_text is the right tool when you asked for text and expect text. Read the array whenever tools, reasoning, structured outputs or citations are involved. Unit 2, Lesson 3 dissects the array properly; Unit 8 depends entirely on reading it.
The Two Forms of input
A string is shorthand. The full form is a list of message objects:
response = client.responses.create(
model="gpt-5.6-luna",
input=[
{"role": "user", "content": "What is the capital of France?"}
],
)
These two calls are equivalent — the API expands a bare string into exactly this. The list form exists because it can express things a string cannot:
response = client.responses.create(
model="gpt-5.6-luna",
input=[
{"role": "user", "content": "What is the capital of France?"},
{"role": "assistant", "content": "The capital of France is Paris."},
{"role": "user", "content": "What is its population?"},
],
)
Here the model can resolve "its" because the earlier turns were included in the request. This is the mechanism behind every chatbot: you resend the history every time. The model did not remember the first exchange; you told it again. Unit 4 builds this out properly, including the ways the platform can hold the history for you.
The list form is also how you send images and files, by giving content a list of typed parts rather than a string:
input=[
{
"role": "user",
"content": [
{"type": "input_text", "text": "What is in this image?"},
{"type": "input_image", "image_url": "https://example.com/photo.jpg"},
],
}
]
Note the naming convention, which is easy to get wrong: parts you send are input_text and input_image; parts the model returns are output_text. The direction is baked into the type name. Unit 7 covers multimodal input in full.
Separating Rules from Content with instructions
Standing rules about how to respond belong in instructions, not mixed into input:
response = client.responses.create(
model="gpt-5.6-luna",
instructions="You are a terse assistant. Answer in one sentence, no preamble.",
input="Explain what an API key is.",
)
instructions is a top-level parameter that the model treats as higher-priority guidance than the user's message. Two practical reasons to use it rather than prepending the same text to input:
- It survives conversation chaining. When you chain turns with
previous_response_id,instructionsfrom the previous response are not carried over — you supply them fresh on each call, which means they are never buried under a growing history. - It separates concerns. Rules that belong to your application ("always answer in JSON", "never give medical advice") stay distinct from content that comes from a user. That separation is also a security boundary, because it makes it clearer which text you control and which text you do not. Unit 3, Lesson 1 goes into where rules belong and why; Unit 8, Lesson 5 covers why the distinction matters for untrusted input.
Controlling Length and Randomness
Two more parameters you will reach for immediately.
max_output_tokens caps how many tokens the model may generate:
response = client.responses.create(
model="gpt-5.6-luna",
input="Explain how HTTP works.",
max_output_tokens=150,
)
This is a hard budget cap, not a length instruction. The model does not aim for 150 tokens and finish gracefully; it generates until it hits the cap and then stops, potentially mid-sentence. Two consequences:
- Use it to bound cost and latency, because output tokens are the expensive direction — five to six times the input rate on every current model.
- To actually get short answers, ask for them in
instructions("answer in two sentences"). Usemax_output_tokensas the safety net that stops a runaway generation, not as the mechanism for brevity.
When the cap is hit, response.status is "incomplete" rather than "completed", and response.incomplete_details.reason says "max_output_tokens". This is exactly why checking status matters:
if response.status == "incomplete":
print("Truncated:", response.incomplete_details.reason)
Code that reads output_text without checking status will happily print a half-finished sentence and treat it as a complete answer. In a pipeline that parses the output, that becomes a confusing downstream failure whose real cause is three steps upstream.
temperature controls randomness, on a scale from 0 to 2:
response = client.responses.create(
model="gpt-5.6-luna",
input="Give me a name for a coffee shop.",
temperature=0.2,
)
Lower values make the model favour the most probable next token, producing more consistent and more predictable output. Higher values flatten the distribution, producing more variety. Rough guidance: 0–0.3 for extraction, classification and anything you will parse; 0.7–1.0 for creative writing and brainstorming; above 1.0 rarely useful outside experimentation.
Two caveats worth knowing before you rely on it. Low temperature is not determinism — it reduces variation but does not eliminate it, so temperature=0 still does not guarantee identical output across calls. And reasoning models generally do not accept temperature, because their generation is controlled differently; passing it can raise a BadRequestError. Unit 3, Lesson 4 covers reasoning models and reasoning_effort as the equivalent control.
Handling Errors on the First Call
Your first run is the most likely one to fail, and the failure mode is informative. This version fails usefully:
import openai
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI()
try:
response = client.responses.create(
model="gpt-5.6-luna",
input="Write a one-sentence bedtime story about a unicorn.",
)
print(response.output_text)
except openai.AuthenticationError:
print("Key rejected. Check .env, and that load_dotenv() runs first.")
except openai.RateLimitError as e:
print("Rate limited, or out of credit. Check your usage page.")
print(e)
except openai.BadRequestError as e:
print("The request was malformed — often a wrong model name.")
print(e)
except openai.APIConnectionError:
print("Could not reach the API. Check your network and any proxy.")
Each of these is a different layer failing, and the distinction tells you where to look:
AuthenticationError(401) — the request reached OpenAI and the key was rejected. Nothing to do with your code. Run thecheck_key.pyscript from Lesson 3.RateLimitError(429) — either too many requests too fast, or no credit remaining. On a first call it is almost always the latter; the message distinguishes them. Retrying an exhausted balance never succeeds, so this is one case where retries actively hurt.BadRequestError(400) — the server understood the request and refused it. On a first call, a mistyped or deprecated model name is the overwhelmingly likely cause. Check the current models page rather than trusting a name from a tutorial.APIConnectionError— the request never arrived. Network, DNS, firewall, corporate proxy, or the HTTPX2 certificate issue from Lesson 2.
All of these inherit from openai.APIError, so except openai.APIError catches everything from the API layer while still letting genuine Python bugs surface as themselves. That is usually the right broad catch — catching bare Exception around an API call hides your own TypeErrors and makes debugging much harder.
Reading Usage and Cost
Every response reports what it consumed:
usage = response.usage
print(f"Input: {usage.input_tokens}")
print(f"Output: {usage.output_tokens}")
print(f"Total: {usage.total_tokens}")
You can turn that directly into money:
INPUT_PRICE_PER_M = 0.20 # gpt-5.6-luna, USD per 1M input tokens
OUTPUT_PRICE_PER_M = 1.20 # USD per 1M output tokens
cost = (
usage.input_tokens / 1_000_000 * INPUT_PRICE_PER_M
+ usage.output_tokens / 1_000_000 * OUTPUT_PRICE_PER_M
)
print(f"Cost: ${cost:.8f}")
Run this once and the number is startlingly small — a fraction of a cent. Run it inside a loop over ten thousand documents with a large model and it stops being small. Building the habit of printing cost while developing is the cheapest possible way to notice that a prompt has quietly tripled in size. Lesson 5 covers billing, credits and cost control properly, including the cached_tokens and reasoning_tokens details in the usage object.
Common Mistakes
Treating response as a string. print(response) dumps the whole object; response.upper() raises AttributeError. The text is at response.output_text. Why it happens: every other "call an AI" example the reader has seen returns a string. Prevention: remember that a response is a structure with text inside it, and use model_dump_json() when unsure.
Using Chat Completions field names. response.choices[0].message.content is the Chat Completions shape and raises AttributeError on a Responses object. Enormous amounts of existing code and tutorial content use it, so you will meet it constantly. Prevention: check which endpoint an example calls — client.chat.completions.create versus client.responses.create — before copying its response handling.
Assuming output_text is always populated. It is empty when the model returns a function call instead of text, and it silently drops annotations. Prevention: check response.status and, once tools are involved, iterate response.output.
Not checking status. A truncated answer looks like a complete one. Prevention: check for "incomplete" in any code that does more than print.
Creating a client inside a loop. Each construction builds a new connection pool, discarding the benefit of connection reuse. Prevention: one client at module level, reused.
Hardcoding the model name everywhere. Models get deprecated; then you are editing thirty call sites. Prevention: a module-level constant or an environment variable — the config.py pattern from Lesson 3 already handles this.
Leaving a request inside a notebook cell you re-run. Every re-execution is a new billed request. Prevention: keep API calls in cells you run deliberately, and cache the response in a variable while you iterate on the formatting.
A More Complete First Script
Putting the lesson together:
import os
import openai
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.6-luna")
INPUT_PRICE_PER_M = 0.20
OUTPUT_PRICE_PER_M = 1.20
client = OpenAI()
def ask(question: str, max_tokens: int = 300) -> str:
"""Send one question and return the model's text answer."""
response = client.responses.create(
model=MODEL,
instructions="You are a precise technical assistant. Be concise.",
input=question,
max_output_tokens=max_tokens,
)
if response.status == "incomplete":
print(f"[warning] truncated: {response.incomplete_details.reason}")
usage = response.usage
cost = (
usage.input_tokens / 1_000_000 * INPUT_PRICE_PER_M
+ usage.output_tokens / 1_000_000 * OUTPUT_PRICE_PER_M
)
print(
f"[{MODEL}] in={usage.input_tokens} "
f"out={usage.output_tokens} cost=${cost:.6f}"
)
return response.output_text
if __name__ == "__main__":
try:
print(ask("What is the difference between HTTP and HTTPS?"))
except openai.APIError as e:
print(f"API request failed: {type(e).__name__}: {e}")
Every element here is deliberate:
- The model comes from configuration with a cheap default, so switching is one environment variable rather than an edit.
instructionscarries the standing rules, separate from the question, so the two never blur together.max_output_tokensis a budget cap with an explicit truncation warning, so a cut-off answer is visible rather than silent.- Usage and cost print on every call, making the price of a prompt change immediately obvious during development.
openai.APIErrorcatches API-layer failures without swallowing genuine Python bugs.if __name__ == "__main__":means the file can be imported by later lessons without firing a request as a side effect — which, unlike in ordinary Python, would cost money.
Run this a few times, change the model, change the question, watch the token counts move. Getting an intuition for how prompt length maps to token count and cost — before you write anything that loops — is worth more than any single API feature you will learn in the next unit.
Other Parameters You Will Meet Immediately
Beyond model, input, instructions, max_output_tokens and temperature, a handful of parameters appear often enough to introduce now.
store controls whether OpenAI retains the response on its servers. Retained responses can be fetched again by ID and, more importantly, are what allow previous_response_id to reconstruct a conversation without you resending the history. The Responses API has historically defaulted to storing — check the current default in the API reference rather than assuming, because it has direct privacy implications.
response = client.responses.create(
model="gpt-5.6-luna",
input="Summarise this internal document...",
store=False,
)
Set store=False when you are sending data that should not be retained — customer records, health information, anything under a data-processing agreement that forbids it. The trade-off is concrete: with store=False you cannot retrieve the response later and you cannot chain from it with previous_response_id, so you must manage conversation history yourself. That is a reasonable price for not leaving regulated data on someone else's servers, and Unit 4 shows the manual-history approach that works either way.
metadata attaches up to a small set of key–value pairs to the response for your own bookkeeping:
response = client.responses.create(
model="gpt-5.6-luna",
input="...",
metadata={"user_id": "u_1042", "feature": "summariser"},
)
These are stored with the response and returned on retrieval. They are how you later answer "which feature generated this" or "show me everything this user triggered" when investigating a bad output. Do not put personal data in them — they are not encrypted-at-your-key storage, just labels.
top_p is the other sampling control, an alternative to temperature. It restricts sampling to the smallest set of tokens whose probabilities sum to top_p. The standard advice is to adjust one or the other, not both, because they interact in ways that are hard to reason about. Start with temperature; reach for top_p only if you have a specific reason.
stream=True turns the call into an iterator of events instead of a single blocking result, and previous_response_id chains a call onto a previous one. Both are single parameters that change the shape of what you get back substantially, which is why they get their own units — Unit 5 and Unit 4 respectively.
The Same Call, Asynchronously
Everything above has a direct async equivalent. Import AsyncOpenAI instead, and await the call:
import asyncio
from dotenv import load_dotenv
from openai import AsyncOpenAI
load_dotenv()
client = AsyncOpenAI()
async def main() -> None:
response = await client.responses.create(
model="gpt-5.6-luna",
input="Explain disestablishmentarianism to a smart five year old.",
)
print(response.output_text)
asyncio.run(main())
The parameters, the response object and the error classes are identical — only the client class and the await change.
Why does this matter, given the sync version is simpler? Because an API call spends almost all of its time waiting on the network, not computing. A synchronous call blocks your entire program during that wait. If you need to summarise fifty documents, the synchronous version takes fifty times one call's latency; the async version can have all fifty in flight at once and finishes in roughly the time of the slowest one.
The rule of thumb: use the synchronous client for scripts and for learning; use the async client when you have many independent calls, or when you are inside an async web framework such as FastAPI, where a blocking call would stall the event loop and degrade every concurrent request. Unit 12, Lesson 6 covers concurrency properly, including how to avoid hitting rate limits when you fan out.
Working with Stored Responses
When a response has been stored, you can fetch it again by ID:
original = client.responses.create(
model="gpt-5.6-luna",
input="Name three primary colours.",
)
fetched = client.responses.retrieve(original.id)
print(fetched.output_text)
Retrieval costs nothing — no model runs, so there are no tokens to bill. This is genuinely useful in three situations: debugging a bad output after the fact, auditing what your application produced, and building a UI that reloads a past conversation without re-generating anything.
Deleting is equally direct:
client.responses.delete(response.id)
Deletion matters for data-retention policy. If you store responses so that chaining works, but your policy says user content is removed after thirty days, a scheduled job deleting by ID is how you honour that. Note the ordering constraint: deleting a response breaks any chain that used it as a previous_response_id.
Per-Call Timeouts and Retries
The client-level timeout and max_retries from Lesson 1 can be overridden for one call without building a second client:
# A long, complex request: allow more time, fewer retries
response = client.with_options(timeout=120.0, max_retries=1).responses.create(
model="gpt-6-astra",
input=very_long_document,
)
with_options() returns a temporary view of the client with those settings applied; the original client is unchanged. This is the right tool when most of your calls are fast and one is not — a bulk summarisation of a hundred-page document deserves a longer timeout than an autocomplete suggestion, and setting the client's global timeout to the slowest case would make every fast call hang for minutes when something goes wrong.
One caution on retries and cost: a retried request is a new billed request. If the first attempt generated tokens before failing, you pay for those tokens and then pay again for the retry. Setting max_retries high on an expensive model is a way to multiply a bill during an outage. Unit 12, Lesson 2 covers where retries help, where they do not, and how backoff should be configured.
What This Lesson Deliberately Left Out
Three things you might expect and will meet shortly:
- Conversation memory. Each call above is independent; the model has no idea what you asked previously. Unit 4.
- Streaming. The call blocks until the entire response is generated. For anything a user is waiting on, you want tokens as they arrive. Unit 5.
- Guaranteed output shape.
output_textis free-form text. Parsing it with string operations is fragile; JSON schema and Pydantic models make the shape a contract. Unit 6.
Each of those is a parameter or two away, and each is built on exactly the call you have just made.
Experiments Worth Running Before Moving On
Each of these takes a minute and teaches something the text alone cannot.
Send the same prompt three times and compare. The wording will differ even though nothing about your request changed. Now add temperature=0 and repeat. The outputs get much closer — and, if you look carefully, still not always identical. This is the non-determinism from Lesson 1, observed rather than described.
Ask a question that requires memory. Send "What is the capital of France?", then in a separate call send "What is its population?". The second call has no idea what "its" refers to and will say so or guess. Now put both turns in a single input list, as shown earlier, and watch it resolve correctly. That contrast is the entire motivation for Unit 4.
Set max_output_tokens=20 on a question that needs a paragraph. Print response.status and response.incomplete_details.reason. Seeing "incomplete" and "max_output_tokens" once makes truncation something you recognise instantly rather than something you debug for twenty minutes later.
Deliberately misspell the model name. You get a BadRequestError with a message naming the problem. Deliberately break the key in .env. You get an AuthenticationError. Producing these on purpose, when you know the cause, is how you learn to read them when you do not.
Compare token counts across prompt styles. Send the same question as a bare string, then with a 200-word instructions block. Compare usage.input_tokens. The difference is what your standing instructions cost on every single call — a number that matters enormously once you have traffic, and one that most people never look at.