Server-Side Conversation Memory
A Server-Side Shortcut for Conversation Memory
Lesson 2 gave you full, manual control over conversation state: you build the message list, you store it, you re-send it. That approach works everywhere and always will, but it does mean your code is responsible for every bit of bookkeeping. The Responses API offers a genuinely useful alternative for a specific situation — when you don't need to inspect or edit history yourself, and you're fine letting OpenAI's servers keep track of it for you: the previous_response_id parameter.
The idea is simple. Every response you get back from client.responses.create() has a unique id. If you pass that id as previous_response_id on your next call, the API reconstructs the entire conversation up to that point on its own, server-side, and continues from there — without you needing to resend the full message history yourself.
Setup
pip install openai python-dotenv
OPENAI_API_KEY=your_api_key_here
import os
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
The Basic Pattern
# First call — a normal, standalone request
first_response = client.responses.create(
model="gpt-5.6-luna",
input="My name is Marcus. I run a small bookstore and want help with inventory tracking.",
)
print("Response 1:", first_response.output_text)
print("Response 1 ID:", first_response.id)
# Second call — chained to the first via previous_response_id.
# Note we only send the NEW user message, not the full history.
second_response = client.responses.create(
model="gpt-5.6-luna",
previous_response_id=first_response.id,
input="What's my name, and what kind of business do I run?",
)
print("\nResponse 2:", second_response.output_text)
Expected Output
Response 1: Nice to meet you, Marcus! I'd be glad to help you think
through inventory tracking for your bookstore...
Response 1 ID: resp_68f0a1b2c3d4e5f6a7b8c9d0
Response 2: Your name is Marcus, and you run a small bookstore.
Notice what's different from Lesson 2's approach: the second call's input contains only the new question — "What's my name, and what kind of business do I run?" — with no manually re-constructed history at all. The model still answers correctly, because passing previous_response_id=first_response.id tells OpenAI's servers to look up everything from that first response (and, transitively, everything before it, if that first response was itself part of a chain) and treat it as if it were part of the same request.
Code Walkthrough
Every Response object returned by client.responses.create() includes an id field, a unique identifier like resp_68f0a1b2c3d4e5f6a7b8c9d0. By default, OpenAI stores this response server-side (governed by the store parameter, which defaults to True) — Lesson 1 already introduced this storage behavior, and this is exactly what it's for.
When you pass previous_response_id=first_response.id on the next call, the API looks up that stored response, reconstructs the full conversational context it represents, and treats your new input as the next turn continuing from there. Crucially, this chains — if you take second_response.id and use it as the previous_response_id for a third call, that third call has access to the entire chain going back to the very first response, not just the second one.
Extending the Chain Further
third_response = client.responses.create(
model="gpt-5.6-luna",
previous_response_id=second_response.id,
input="Can you suggest three inventory tracking approaches, given everything so far?",
)
print(third_response.output_text)
Each call in this chain only needs to reference the immediately preceding response's ID — you never need to manually walk back through the whole chain yourself, and you never need to hold the accumulated message history in your own application's memory at all. This is the core convenience previous_response_id provides: OpenAI's infrastructure does the reconstruction work that Lesson 2's Conversation class did manually with a growing Python list.
Tracking Just the Latest ID Instead of a Full History
Because chaining only requires the most recent response's ID, a class wrapping this pattern looks meaningfully simpler than Lesson 2's Conversation class — it doesn't need to store or grow a message list at all, just a single ID:
import os
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
class ChainedConversation:
def __init__(self, system_prompt: str, model: str = "gpt-5.6-luna"):
self.model = model
self.system_prompt = system_prompt
self.last_response_id: str | None = None
def send(self, user_text: str) -> str:
kwargs = {
"model": self.model,
"input": user_text,
}
if self.last_response_id is None:
# First message in the conversation — include the system
# instructions as part of the input, since there's no
# prior response to chain from yet.
kwargs["input"] = [
{"role": "developer", "content": self.system_prompt},
{"role": "user", "content": user_text},
]
else:
kwargs["previous_response_id"] = self.last_response_id
response = client.responses.create(**kwargs)
self.last_response_id = response.id
return response.output_text
convo = ChainedConversation(
system_prompt="You are a concise assistant for a bookstore inventory app."
)
print(convo.send("My name is Marcus."))
print(convo.send("What's my name?"))
print(convo.send("Can you remind me what business I mentioned earlier, if any?"))
Notice self.last_response_id is the only piece of conversation state this class holds — a single string, not a growing list. Everything else lives on OpenAI's servers, referenced by that ID. This is meaningfully less application-side bookkeeping than Lesson 2's approach, which is the whole appeal of this pattern for the right use case.
The Real Tradeoff: Convenience for Control and Cost Visibility
previous_response_id genuinely simplifies your code, but it's worth being precise about what you give up, because this isn't a strictly-better replacement for Lesson 2's manual approach — it's a different tool with a different tradeoff.
You lose direct visibility into exactly what's being sent. With manual history management, you can print your input list right before a call and see exactly what the model will receive. With previous_response_id, the actual reconstructed context lives server-side — you know it includes everything from the chain, but you can't directly inspect the exact assembled prompt the way you could a Python list you built yourself.
You lose easy editing. If you need to redact a message, correct something a user said, or otherwise modify history before the next call — as Lesson 2 noted was a legitimate use case — you can't do that with a response chain. Once a response is stored, it's stored as-is; you're not editing the chain's contents, only appending to it.
Billing still happens the same way underneath. It's tempting to assume previous_response_id is "free" continuity since you're not manually re-sending the full history text in your own request payload — but this is a common misconception worth correcting directly. OpenAI's own guidance is explicit: all input tokens for every response in the chain are billed as input tokens on each new call. The convenience is in your code not needing to reconstruct and hold that history — the token cost of the underlying context is identical to Lesson 2's manual approach, because the model still has to process the full accumulated context either way. A chain that's grown long costs just as much per call as a manually-managed history list of the same length would.
You depend on OpenAI's storage retention. As Lesson 1 covered, stored responses are retained for roughly 30 days by default. If a chain's early responses age out of that window, or if any response in the chain was created with store=False, the chain breaks at that point — a previous_response_id referencing an expired or never-stored response will fail. Lesson 2's approach has no such expiration, since you control the storage entirely yourself.
Real-World Example: A Short-Lived Support Session
previous_response_id is an especially good fit for interactions that are naturally short-lived and don't need to be inspected, edited, or persisted beyond the life of a single session — a good example is a temporary, in-page "ask a question about this article" widget on a website, where a visitor might ask two or three follow-up questions before navigating away, and nothing about that exchange needs to be stored in your own database at all.
def handle_widget_question(previous_id: str | None, question: str) -> tuple[str, str]:
kwargs = {"model": "gpt-5.6-luna", "input": question}
if previous_id:
kwargs["previous_response_id"] = previous_id
response = client.responses.create(**kwargs)
return response.output_text, response.id
# Simulating a short browser session, tracked purely by response ID
# (e.g. held in a JavaScript variable or a short-lived cookie on the frontend)
answer, resp_id = handle_widget_question(None, "What does this article say about tariffs?")
print(answer)
answer, resp_id = handle_widget_question(resp_id, "How does that compare to last year?")
print(answer)
Here, the application never needs its own database table for this widget's conversations — it just needs to hold onto the latest response ID for the duration of the visitor's session (in a JavaScript variable, for instance) and pass it back on the next question. This is meaningfully less infrastructure than Lesson 2's approach would require for the same feature, and it's a legitimate reason to prefer previous_response_id here: the conversation is genuinely ephemeral, nothing about it needs to be audited or edited later, and the reduced application complexity is a real win.
Contrast this with the multi-user support widget from Lesson 2, where the business likely does want a durable record of every support conversation for quality review, analytics, or compliance — in that case, owning the message history yourself in a database, as Lesson 2 showed, is the better fit, because previous_response_id chains aren't designed to be a queryable, exportable system of record; they're a convenience for continuing a conversation, not a database for conversations.
Combining Both Approaches
In practice, these two techniques aren't mutually exclusive, and production systems often use both. A common pattern: use previous_response_id to let OpenAI handle turn-to-turn continuity during the live conversation (simpler code, no need to hold a growing list in memory), while also logging each user message and each response's text to your own database as they happen, purely for your own records, analytics, or compliance — without that logged copy needing to be what's actually sent back to the model on the next call.
def send_and_log(conversation_id: str, previous_id: str | None, user_text: str) -> tuple[str, str]:
kwargs = {"model": "gpt-5.6-luna", "input": user_text}
if previous_id:
kwargs["previous_response_id"] = previous_id
response = client.responses.create(**kwargs)
# Log to your own database for records/analytics — this copy is
# never re-sent to the model; it's purely for your own use.
save_to_database(conversation_id, role="user", content=user_text)
save_to_database(conversation_id, role="assistant", content=response.output_text)
return response.output_text, response.id
This gets you the reduced bookkeeping of previous_response_id for the actual API interaction, while still maintaining your own durable, queryable record independently — the best of both lessons, applied deliberately rather than treating the two approaches as strictly either/or.
Branching a Conversation From a Single Point
One capability that falls naturally out of how previous_response_id works, and that's genuinely awkward to replicate with manually managed message lists, is branching: starting more than one continuation from the exact same point in a conversation. Because a response's ID is just a fixed reference to a specific point in history, nothing stops you from using that same ID as the previous_response_id for two, three, or more separate follow-up calls.
base_response = client.responses.create(
model="gpt-5.6-luna",
input="I'm deciding between three marketing strategies for my bookstore: social media ads, a local newspaper feature, or a customer loyalty program.",
)
# Branch A: explore the risks
branch_a = client.responses.create(
model="gpt-5.6-luna",
previous_response_id=base_response.id,
input="What are the risks of each option?",
)
# Branch B: explore the costs — also continuing from base_response,
# NOT from branch_a. Both branches share the same starting point.
branch_b = client.responses.create(
model="gpt-5.6-luna",
previous_response_id=base_response.id,
input="Roughly how much would each option cost to get started?",
)
print("Branch A (risks):", branch_a.output_text)
print("\nBranch B (costs):", branch_b.output_text)
Both branch_a and branch_b share full awareness of the original three marketing strategies, but neither one knows about the other branch's question — branch_a has no idea the cost question in branch_b was ever asked, and vice versa. This is exactly the behavior you'd want for a "explore several possible directions from the same starting context" feature — a brainstorming tool that lets a user try several different follow-up angles on the same base question, for instance, or an A/B comparison of how the model responds to two different framings of a next question, without one attempt contaminating the other. Replicating this with manually managed message lists (Lesson 2) requires you to explicitly copy the shared prefix into two separate lists yourself — not hard, but previous_response_id gives you this for free, just by reusing the same ID as a starting point for multiple independent calls.
Reasoning Models and Chained Context
If you're chaining conversations that use a reasoning-capable model — gpt-6-astra, for example, rather than a non-reasoning model like gpt-5.6-luna — there's a detail worth knowing about specifically. Reasoning models produce internal reasoning content as part of generating a response, and this reasoning content is encrypted by default when stored. When you chain from a response produced by a reasoning model, previous_response_id correctly preserves and passes along this encrypted reasoning context server-side, which is part of what makes reasoning models perform well across a chained, multi-turn interaction — the model isn't just seeing the prior turns' visible text, it retains access to its own prior reasoning trace in a way that's difficult to replicate by hand.
This is actually a meaningful point in favor of previous_response_id specifically for reasoning-model conversations: if you were instead manually reconstructing history for a reasoning model (Lesson 2's approach), you generally only have access to the model's final visible output for each turn — not its internal reasoning trace — so a manually rebuilt history can't include that reasoning content the way a server-side chain naturally does. For most everyday conversational features built on non-reasoning models like gpt-5.6-luna, this distinction doesn't come up. But if you're building something that leans on gpt-6-astra's deeper reasoning across multiple turns — a multi-step planning assistant, for example — previous_response_id chaining is worth preferring over manual history reconstruction specifically for this reason.
Inspecting What a Chained Call Actually Received
Because a previous_response_id chain reconstructs context server-side, it can feel like a black box compared to Lesson 2's fully transparent, self-assembled message lists. It's worth knowing you're not entirely without visibility: the Response object returned from a chained call still includes its own output array, usage object (with token counts for that specific call), and other standard response fields, even though you didn't explicitly pass the full history yourself.
response = client.responses.create(
model="gpt-5.6-luna",
previous_response_id=some_previous_id,
input="Summarize what we've discussed so far.",
)
print("Input tokens billed for this call:", response.usage.input_tokens)
print("Output tokens billed for this call:", response.usage.output_tokens)
Checking response.usage.input_tokens on a chained call is a direct, practical way to confirm the billing behavior described earlier in this lesson — you'll see that number grow as the chain gets longer, exactly the same way it would if you were manually re-sending a growing message list. This is a useful sanity check the first time you're building on previous_response_id and want to verify for yourself that the "convenience, not cost savings" framing from earlier in this lesson is actually true for your specific use case.
Frequently Asked Questions
Can I use previous_response_id and manually pass additional input messages in the same call? Yes — the input you provide on a chained call is appended after the reconstructed prior context, so you can pass a single new user message (the common case) or even a short list of new messages, and both get added on top of everything the chain already contains.
What happens if I pass both a full manual history in input and a previous_response_id? The reconstructed context from the referenced response is combined with whatever you pass as input — but doing this defeats much of the purpose of chaining, since you'd be paying the bookkeeping cost of Lesson 2's approach while also paying for the chain's reconstructed context. In practice, pick one approach per conversation rather than mixing them within the same thread, to avoid duplicated or confusing context.
Is there a limit to how long a chain can get? There's no separate, chain-specific length limit beyond the model's ordinary context window — a chain is simply a way of accumulating conversation history server-side, and it's bound by the same token limits as any other conversation. A sufficiently long chain will eventually need the same compaction or trimming strategies covered in Lesson 6.
Can I retrieve a past response's full content later, given just its ID? Yes — client.responses.retrieve(response_id) fetches a previously stored response directly, which can be useful for auditing or debugging a chain after the fact, separate from continuing the conversation with a new call.
Does previous_response_id work the same way across every model? The mechanism itself is model-agnostic — any model available through the Responses API supports being chained via previous_response_id. What differs, as covered above, is that reasoning models get additional benefit from chaining specifically because of how their internal reasoning content is preserved across the chain.
How This Fits Alongside the Rest of OpenAI's Conversation Tools
It's worth placing previous_response_id in context relative to the other conversation-management options this unit covers, since a beginner encountering all of them at once can reasonably wonder why there are several different ways to solve what feels like the same problem.
Manual message history (Lesson 2) is the foundational, always-available technique — it has no dependency on any particular API feature, works identically across the Responses API and the older Chat Completions API, and gives you complete control and visibility at the cost of doing all the bookkeeping yourself. previous_response_id (this lesson) is a Responses-API-specific convenience layered on top of that same underlying idea — instead of you holding the growing list, OpenAI's servers hold it for you, referenced by a single ID, at the cost of reduced editability and a retention window you don't control. The Conversations API (Lesson 4) goes a step further than previous_response_id specifically for durability — it gives you a conversation object with its own stable identifier, independent of any single response's expiration, designed for conversations that genuinely need to persist over the long term, across sessions, and potentially across different devices or client applications.
None of these three techniques is strictly "better" in every situation — they trade off control, convenience, and durability differently, and a real production system frequently uses more than one, chosen deliberately per feature based on how long that feature's conversations need to live and how much visibility or editability you need into their contents.
A Practical Decision Guide
When you're deciding which of these techniques fits a specific feature you're building, a few concrete questions tend to settle it quickly. Does the conversation need to survive for weeks or months, potentially resumed from a different device or a different client entirely? If yes, lean toward the Conversations API in Lesson 4 rather than a response chain, since chains depend on the default retention window and aren't designed as a durable, addressable conversation object in their own right. Does your application need to inspect, edit, redact, or export the exact conversation content at any point? If yes, manual history management from Lesson 2 is the right fit, since it's the only approach where you hold the actual data yourself rather than a reference to data OpenAI holds. Is the conversation short-lived, simple, and does reducing your own state-management code matter more than deep visibility or long-term durability? If yes, previous_response_id is very likely your best fit — it's the lowest-code-complexity option among the three for exactly this kind of interaction.
It's worth noting these three aren't always an exclusive choice made once per application — different features within the same product can reasonably use different approaches. A quick "ask about this page" widget might use previous_response_id for its low overhead, while the product's main account-level support chat, which needs to persist and be searchable across sessions, uses the Conversations API instead. Choosing per-feature, based on that feature's actual durability and inspectability needs, tends to produce cleaner code than trying to force one single conversation-management pattern across an entire application regardless of what each feature actually requires.
Common Mistakes
Assuming previous_response_id avoids the token cost of long conversations. As covered above, this is one of the most common and costly misconceptions about this feature. The full chain's tokens are billed on every call, identically to manually re-sending them. previous_response_id reduces code complexity, not token cost — Lesson 6's guidance on managing context length and cost applies exactly the same way to chained conversations as to manually managed ones.
Chaining from a response that used store=False. If any response in your intended chain was created with storage disabled, referencing its ID as a previous_response_id later will fail, since there's nothing stored server-side to reconstruct from. If you need chaining to work reliably, make sure store is True (the default) for every response you intend to chain from.
Relying on a response ID surviving indefinitely. Stored responses are not kept forever — the default retention window is roughly 30 days. A chain resumed after a long gap (a user returning to a conversation weeks later, for example) may find the referenced response has expired. For conversations that genuinely need to persist indefinitely, the Conversations API in Lesson 4 is the more appropriate tool, since it's designed explicitly for durable, long-lived threads rather than the shorter-lived convenience this feature provides.
Trying to edit or inspect the middle of a chain. Unlike a Python list you own, you can't reach into a response chain and modify an earlier turn. If your application needs that kind of editability — removing a message, correcting a typo in stored history, redacting something — manual message-list management from Lesson 2 is the right tool, not response chaining.
Troubleshooting
Error referencing an invalid or expired previous_response_id. Confirm the response you're referencing was actually created with store=True (or left at its default) and falls within the retention window. If you're building a long-lived feature where this matters, consider migrating to the Conversations API in Lesson 4, which is built for durability in a way response chaining isn't.
The model doesn't seem to have context from several calls back in a long chain. Chains can still be affected by the same context-window limits covered in Lesson 6 — a very long chain's total token count can exceed what the model can accept, at which point you'd need a compaction or trimming strategy, exactly as you would with a manually managed history. Chaining doesn't exempt you from the context window; it just changes who's responsible for holding the accumulated content.
Costs are higher than expected for a chained conversation. This is very likely the billing behavior described above catching a team by surprise — check your usage dashboard's token counts per call in a long chain. If costs matter and conversations run long, review Lesson 6's trimming and compaction strategies; they apply to previous_response_id chains exactly as they do to manual history.
Two different features seem to be sharing conversation context unexpectedly. Double-check that each independent conversation or session is tracking and passing its own distinct previous_response_id, and that IDs aren't being accidentally shared, reused, or overwritten across unrelated sessions — this is the response-chaining equivalent of the shared-mutable-list bug from Lesson 2, and it's just as serious a bug here.
Best Practices
Reach for previous_response_id specifically when a conversation is relatively short-lived, doesn't need to be edited or audited after the fact, and where reducing your own application's state-management code is a genuine win. Reach for Lesson 2's manual approach instead when you need full visibility into exactly what's sent, need to edit or redact history, or need conversations to persist reliably beyond the roughly 30-day default retention window. Don't assume chaining reduces cost — budget for chains the same way you'd budget for manually managed history, using Lesson 6's guidance. And consider combining both: use chaining for the live turn-taking convenience, while independently logging the conversation to your own storage for anything you need to keep, search, or audit later.
What's Next
previous_response_id solves turn-to-turn continuity well, but it has a real limitation for anything that needs to genuinely persist — a conversation a user might return to days or weeks later, across different devices or sessions, well past the default storage retention window. Lesson 4 introduces the Conversations API, a dedicated resource built specifically for that durability requirement, giving you a stable, independent conversation identifier that isn't tied to any single response's expiration.