How to build a persistent assistant with the OpenAI Assistants API

The OpenAI Assistants API provides built-in thread management, file search, and code interpreter — removing the plumbing work from agentic applications. This guide covers creating an assistant, managing threads, and using built-in tools.

Why use the Assistants API

Building agents with the raw Chat Completions API requires you to manage conversation history, implement tool loops, handle file uploads, and maintain state between requests. The Assistants API handles all of this server-side: threads store conversation history, runs manage the agentic loop, and built-in tools (file search, code interpreter) require no implementation on your end.

The trade-off is less flexibility and slightly higher cost — OpenAI charges for thread storage and tool useTool UseTool use enables language models to call external functions, APIs, or execute code beyond text generation, forming the foundation for AI agents.Learn more →. For production applications, the reduced engineering complexity often justifies the cost.

Create an assistant

Create an assistant with tools and model: `assistant = client.beta.assistants.create(name='Data Analyst', instructions='You are a data analyst. When given a CSV file, you analyse it and provide statistical summaries and insights.', tools=[{"type": "code_interpreter"}, {"type": "file_search"}], model='gptGPTGenerative Pre-trained Transformer — the model architecture and family name behind OpenAI's most famous models, from GPT-2 to GPT-5.Learn more →-4o')`. Store the `assistant.id` — you will reuse this assistant across many user sessions.

The `instructions` field is the persistent system promptSystem PromptA special instruction given to a language model before the user conversation begins, establishing the model's persona, capabilities, constraints, and context.Learn more →. Unlike a regular system prompt, it can reference the assistant's tools and the context they provide. Keep it focused: describe what the assistant does, not generic 'be helpful' platitudes.

Manage threads and messages

Create a thread per user session: `thread = client.beta.threads.create()`. Add messages to the thread: `client.beta.threads.messages.create(thread_id=thread.id, role='user', content='Analyse the attached file and tell me the top 5 products by revenue.')`. Threads persist across sessions — store the `thread.id` in your database tied to the user.

Attach files to messages by uploading them first: `file = client.files.create(file=open('sales.csv', 'rb'), purpose='assistants')`. Then reference the file ID in the message content: `content=[{'type': 'text', 'text': 'Analyse this file'}, {'type': 'image_file', 'image_file': {'file_id': file.id}}]`.

Create and poll a run

Start the assistant working on the thread: `run = client.beta.threads.runs.create(thread_id=thread.id, assistant_id=assistant.id)`. Poll for completion: `while run.status in ['queued', 'in_progress']: time.sleep(1); run = client.beta.threads.runs.retrieve(thread_id=thread.id, run_id=run.id)`.

For production, use the streamingStreamingStreaming returns a model's tokens incrementally as they're generated rather than waiting for the complete response to finish.Learn more → API instead of polling: `with client.beta.threads.runs.stream(thread_id=thread.id, assistant_id=assistant.id) as stream: for text in stream.text_deltas: print(text, end='', flush=True)`. This is faster and avoids the polling overhead.

Retrieve the response

After the run completes, list messages: `messages = client.beta.threads.messages.list(thread_id=thread.id, order='desc', limit=1)`. The latest message from the assistant contains the response. Access the text: `response_text = messages.data[0].content[0].text.value`.

If the run used code interpreter, the messages may include image outputs (plots, charts). Check for `type == 'image_file'` in the content blocks and download them: `client.files.content(file_id).read()` returns the image bytes.