AI and Machine Learning

Build Custom GPTs with OpenAI Assistants API: A Deep Dive

Learn to build powerful custom GPTs programmatically using the OpenAI Assistants API. This guide covers core concepts, architecture, tooling, and real-world examples.

Khader Vali October 7, 2026 14 min read

Building Custom GPTs with OpenAI Assistants API: A Deep Dive

Welcome, fellow engineers and innovators! Today, we’re going to embark on an exciting journey into the heart of programmatic AI assistant development. If you’ve tinkered with OpenAI’s custom GPTs on their platform, you know the magic they offer. But what if you need that same magic, fully integrated into your applications, with complete control over the user experience, logic, and external systems? Enter the OpenAI Assistants API.

As a senior engineer at Khadervali.com, my goal is to equip you with the knowledge and practical insights to leverage this powerful API. We’ll move beyond simple chat completions and explore how to build sophisticated, stateful, and tool-augmented AI assistants that can transform your applications.

Why the Assistants API? A Paradigm Shift

For a long time, building conversational AI applications with large language models (LLMs) meant managing a lot of boilerplate code. You had to handle message history, manage tool calls, orchestrate complex multi-turn interactions, and often manually stitch together various components. The Assistants API changes all that, offering a higher-level abstraction that dramatically simplifies development.

Here’s why the Assistants API is a game-changer compared to directly using the Chat Completions API:

  • State Management (Threads & Messages): No more manually passing long conversation histories. The API manages the state of conversations within persistent “Threads,” making multi-turn interactions seamless.
  • Built-in Tooling: It abstracts away the complexity of tool use. Whether it’s the Code Interpreter for complex calculations, Retrieval Augmented Generation (RAG) for knowledge base integration, or Function Calling for external API interactions, the Assistant intelligently decides when and how to use these tools.
  • Persistent Assistants: You define an Assistant once with specific instructions, a model, and a set of tools. This configuration persists across multiple user interactions and threads, ensuring consistent behavior without re-specification.
  • Asynchronous Execution: Runs are asynchronous, allowing your application to remain responsive while the Assistant processes complex requests, potentially involving multiple tool uses and retries.
  • Reduced Prompt Engineering Overhead: Much of the “system” level prompt engineering is handled internally by the API, allowing you to focus on high-level instructions for your Assistant.

In essence, the Assistants API is your orchestrator, your state manager, and your tool coordinator, freeing you to focus on the unique value your custom GPT brings to your users.

Understanding the Core Concepts of OpenAI Assistants API

To effectively build with the Assistants API, we need to understand its fundamental building blocks. Think of these as the components of your custom GPT’s brain and memory.

Assistants: The Central Entity

An Assistant is the intelligent agent you create. It encapsulates the core personality, capabilities, and knowledge base of your custom GPT. When you define an Assistant, you specify:

  • Name: A human-readable name for your Assistant.
  • Instructions: The overarching rules, personality, and goals for your Assistant. This is where you define its “role.”
  • Model: The underlying LLM (e.g., gpt-4o, gpt-4-turbo) that powers your Assistant’s reasoning.
  • Tools: The set of capabilities your Assistant can leverage (Code Interpreter, Retrieval, Function Calling).
  • File IDs: Files that can be used by Retrieval or Code Interpreter tools, providing a knowledge base or data.

You create an Assistant once, and it can then serve multiple users across various conversation threads.

Let’s create a basic Assistant:

from openai import OpenAI
import os

# Ensure your OpenAI API key is set as an environment variable
# export OPENAI_API_KEY='your_api_key_here'
client = OpenAI()

def create_my_assistant():
    try:
        assistant = client.beta.assistants.create(
            name="Khadervali Tech Support Bot",
            instructions="You are a friendly and helpful tech support assistant for Khadervali.com. Your primary goal is to resolve user issues using available tools. If you cannot resolve an issue, offer to escalate to a human agent.",
            model="gpt-4o",
            tools=[{"type": "code_interpreter"}] # Let's start with Code Interpreter
        )
        print(f"Assistant created with ID: {assistant.id}")
        return assistant
    except Exception as e:
        print(f"Error creating assistant: {e}")
        return None

# assistant = create_my_assistant()
# if assistant:
#     print(f"Assistant details: {assistant}")

This code snippet demonstrates the simplicity of defining an Assistant. We’re giving it a name, a clear set of instructions, specifying the powerful gpt-4o model, and enabling the Code Interpreter tool.

Threads: The Conversational Context

A Thread represents a single conversation session between a user and an Assistant. It’s where all messages for that particular conversation are stored and managed. The Assistants API handles the context window and message history internally, so you don’t have to worry about token limits for history in the same way you would with raw Chat Completions.

Each user typically gets their own Thread. This allows your custom GPT to maintain a continuous, stateful conversation with them.

def create_my_thread():
    try:
        thread = client.beta.threads.create()
        print(f"Thread created with ID: {thread.id}")
        return thread
    except Exception as e:
        print(f"Error creating thread: {e}")
        return None

# thread = create_my_thread()
# if thread:
#     print(f"Thread details: {thread}")

Creating a thread is straightforward. Once created, you’ll associate messages with it.

Messages: User Inputs and Assistant Responses

Messages are the individual turns within a Thread. They can be created by the user (your application sending user input) or by the Assistant (the responses generated by the AI). Messages can contain text, and optionally, file attachments.

def add_message_to_thread(thread_id, content, role="user"):
    try:
        message = client.beta.threads.messages.create(
            thread_id=thread_id,
            role=role,
            content=content
        )
        print(f"Message added to thread {thread_id}: {message.id}")
        return message
    except Exception as e:
        print(f"Error adding message to thread: {e}")
        return None

# Example usage (assuming thread and assistant exist)
# user_message = add_message_to_thread(thread.id, "Hi there, I need help with my internet connection.")

Notice the role parameter. For user input, it’s always "user". The Assistant’s responses will automatically be added with the role "assistant" by the API.

Runs: The Execution Engine

A Run is the process by which an Assistant reads a Thread, decides what to do (e.g., generate a response, use a tool), and executes those actions. When you create a Run, you’re telling the Assistant to process the latest messages in a Thread and generate a response or perform an action.

The lifecycle of a Run is critical to understand:

  • queued: The Run is waiting to be processed.
  • in_progress: The Assistant is actively processing the thread.
  • requires_action: The Assistant needs external action, typically to execute a function call and provide its output.
  • cancelling, cancelled, failed: Self-explanatory states indicating issues or termination.
  • completed: The Assistant has finished processing and generated a response or completed its task.
  • expired: The run took too long to complete and was automatically terminated.

Because Runs are asynchronous, your application needs to poll the Run’s status until it reaches a terminal state (like completed, failed, or requires_action).

<

Build Custom GPTs with OpenAI Assistants API: A Deep Dive
Generated Image

>

Here’s a basic example of initiating and polling a Run:

import time

def run_assistant_and_poll(assistant_id, thread_id):
    try:
        run = client.beta.threads.runs.create(
            thread_id=thread_id,
            assistant_id=assistant_id
        )
        print(f"Run created with ID: {run.id}")

        while run.status in ['queued', 'in_progress', 'cancelling']:
            time.sleep(1) # Wait for 1 second
            run = client.beta.threads.runs.retrieve(
                thread_id=thread_id,
                run_id=run.id
            )
            print(f"Run status: {run.status}")

        if run.status == 'completed':
            messages_page = client.beta.threads.messages.list(thread_id=thread_id)
            messages = messages_page.data
            
            # Filter for assistant's latest message
            assistant_messages = [
                m for m in messages 
                if m.role == 'assistant' and m.run_id == run.id
            ]

            if assistant_messages:
                latest_assistant_message = assistant_messages[0]
                for content_block in latest_assistant_message.content:
                    if content_block.type == 'text':
                        print(f"Assistant: {content_block.text.value}")
            else:
                print("No assistant message found for this run.")
            return run
        elif run.status == 'requires_action':
            print("Run requires action (e.g., function call).")
            return run
        else:
            print(f"Run finished with status: {run.status}")
            return run

    except Exception as e:
        print(f"Error during run: {e}")
        return None

# # To combine the above:
# my_assistant = create_my_assistant()
# my_thread = create_my_thread()

# if my_assistant and my_thread:
#     add_message_to_thread(my_thread.id, "What is 123 * 456?")
#     final_run = run_assistant_and_poll(my_assistant.id, my_thread.id)

This polling mechanism is a fundamental pattern when working with the Assistants API, especially when tools are involved.

Tools: Extending Assistant Capabilities

Tools are what make the Assistants API truly powerful, allowing your custom GPTs to go beyond mere text generation. The API provides three powerful tool types:

  • Code Interpreter: This enables the Assistant to write and execute Python code in a sandboxed environment. It’s incredibly useful for mathematical calculations, data analysis, complex logic, and even generating charts (though the output will be text-based representations). You simply enable it, and the Assistant decides when to use it.
  • Retrieval: This tool allows your Assistant to augment its responses with information from files you provide. It’s an implementation of Retrieval Augmented Generation (RAG). You upload documents (PDFs, text files, CSVs, etc.), and the Assistant can search them to answer questions, summarize content, or extract specific details.
  • Function Calling: This is arguably the most flexible tool. It allows your Assistant to call functions that you define in your application. These functions can interact with external APIs, databases, or any custom logic your application needs. The Assistant decides when to call a function, extracts the necessary arguments, and then awaits the result from your application.

Architectural Patterns for Custom GPTs with Assistants API

Understanding the core concepts is the first step. Now, let’s look at how these pieces fit together in a complete application, especially when incorporating tools.

Basic Request-Response Flow

For a simple custom GPT without complex tool interactions, the flow is relatively straightforward:

Diagram (in words):

  1. User Input: User types a message in your application’s UI.
  2. Your Application: Receives the user input.
  3. OpenAI API (Add Message): Your application calls the Assistants API to add the user’s message to an existing Thread (or creates a new Thread if it’s the first interaction).
  4. OpenAI API (Create Run): Your application then creates a Run, telling the Assistant to process the Thread.
  5. OpenAI API (Poll Run Status): Your application continuously polls the status of the Run.
  6. OpenAI API (Run Completed): Once the Run status is completed, your application retrieves the latest messages from the Thread.
  7. Your Application: Extracts the Assistant’s response.
  8. User Output: Displays the Assistant’s response to the user.

This flow is synchronous from the user’s perspective (they wait for a response) but asynchronous under the hood (your app polls the API). This pattern works well for applications where the Assistant doesn’t need to interact with external systems beyond its internal knowledge or code execution.

Integrating Tools: Function Calling Deep Dive

Function Calling is where the Assistants API truly shines in terms of customizability and integration with your existing services. It allows your custom GPT to perform actions in the real world.

When to use Function Calling:

  • Retrieving real-time data (weather, stock prices, news).
  • Interacting with user accounts (booking appointments, placing orders).
  • Sending notifications (emails, SMS).
  • Querying proprietary databases or internal APIs.

The Function Calling Flow:

  1. Define the Function: You define the signature of your function (name, description, parameters) using a JSON schema. This schema tells the Assistant what the function does and what arguments it expects.
  2. Register with Assistant: You include this function definition when creating or updating your Assistant.
  3. User Query: A user asks a question that requires external information or action (e.g., “What’s the weather like in London?”).
  4. Assistant’s Decision: The Assistant, based on its instructions and the user’s query, determines that one of its registered functions is relevant. It then generates the arguments for that function based on the user’s input.
  5. Run Status: requires_action: The Run status changes to requires_action. The API sends back the details of the function call(s) the Assistant wants to make.
  6. Your Application’s Role: Your application receives the requires_action status, parses the function name and arguments, and then executes the *actual* function in your backend code.
  7. Submit Tool Outputs: Once your function has executed and returned a result, your application submits this result back to the Run using client.beta.threads.runs.submit_tool_outputs.
  8. Assistant Continues: The Assistant then processes the tool’s output, incorporating it into its understanding of the conversation, and generates a final response.

<

Build Custom GPTs with OpenAI Assistants API: A Deep Dive
Generated Image

>

Let’s implement a simple weather tool:

# Function to be called by the Assistant
def get_current_weather(location, unit="fahrenheit"):
    """Get the current weather in a given location."""
    # In a real application, this would call a weather API
    if "london" in location.lower():
        return f"It's 15 degrees Celsius and cloudy in London. (Unit: {unit})"
    elif "paris" in location.lower():
        return f"It's 20 degrees Celsius and sunny in Paris. (Unit: {unit})"
    elif "new york" in location.lower():
        return f"It's 70 degrees Fahrenheit and clear in New York. (Unit: {unit})"
    else:
        return "Weather data not available for that location."

# Update Assistant to include function calling
def update_assistant_with_weather_tool(assistant_id):
    try:
        assistant = client.beta.assistants.update(
            assistant_id,
            tools=[
                {"type": "code_interpreter"},
                {
                    "type": "function",
                    "function": {
                        "name": "get_current_weather",
                        "description": "Get the current weather in a given location",
                        "parameters": {
                            "type": "object",
                            "properties": {
                                "location": {
                                    "type": "string",
                                    "description": "The city and state, e.g. San Francisco, CA",
                                },
                                "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
                            },
                            "required": ["location"],
                        },
                    },
                }
            ]
        )
        print(f"Assistant {assistant.id} updated with weather tool.")
        return assistant
    except Exception as e:
        print(f"Error updating assistant with tool: {e}")
        return None

# Combined run and function handling
def run_assistant_with_tools_and_poll(assistant_id, thread_id):
    run = client.beta.threads.runs.create(
        thread_id=thread_id,
        assistant_id=assistant_id
    )
    print(f"Run created with ID: {run.id}")

    while True:
        time.sleep(1)
        run = client.beta.threads.runs.retrieve(
            thread_id=thread_id,
            run_id=run.id
        )
        print(f"Run status: {run.status}")

        if run.status == 'requires_action':
            tool_outputs = []
            for tool_call in run.required_action.submit_tool_outputs.tool_calls:
                if tool_call.type == "function":
                    function_name = tool_call.function.name
                    arguments = eval(tool_call.function.arguments) # Be careful with eval() in production
                    print(f"Assistant wants to call function: {function_name} with args: {arguments}")

                    # Execute the local function
                    if function_name == "get_current_weather":
                        output = get_current_weather(**arguments)
                        print(f"Function output: {output}")
                        tool_outputs.append({
                            "tool_call_id": tool_call.id,
                            "output": output
                        })
                    # Add more function handlers here for other tools
                    else:
                        print(f"Warning: Unknown function call: {function_name}")
                        tool_outputs.append({
                            "tool_call_id": tool_call.id,
                            "output": "Error: Unknown function"
                        })
            
            # Submit the tool outputs back to the run
            if tool_outputs:
                run = client.beta.threads.runs.submit_tool_outputs(
                    thread_id=thread_id,
                    run_id=run.id,
                    tool_outputs=tool_outputs
                )
        elif run.status == 'completed':
            messages_page = client.beta.threads.messages.list(thread_id=thread_id, order="desc", limit=1)
            latest_message = messages_page.data[0]
            if latest_message.role == 'assistant':
                for content_block in latest_message.content:
                    if content_block.type == 'text':
                        print(f"Assistant: {content_block.text.value}")
            break
        elif run.status in ['failed', 'cancelled', 'expired']:
            print(f"Run ended with status: {run.status}")
            break

# # Example usage for weather bot:
# # 1. Create assistant (or retrieve existing one)
# # my_assistant = create_my_assistant() # If creating new
# # Or use existing: assistant_id = "asst_YOUR_ASSISTANT_ID"
# # my_assistant = client.beta.assistants.retrieve(assistant_id)

# # 2. Update assistant with weather tool
# # my_assistant = update_assistant_with_weather_tool(my_assistant.id)

# # 3. Create a thread
# # my_thread = create_my_thread()

# # 4. Add a message and run with tool handling
# # add_message_to_thread(my_thread.id, "What's the weather in London?")
# # run_assistant_with_tools_and_poll(my_assistant.id, my_thread.id)
# # add_message_to_thread(my_thread.id, "And in New York?")
# # run_assistant_with_tools_and_poll(my_assistant.id, my_thread.id)

The eval() function used to parse arguments from tool_call.function.arguments should be handled with extreme caution in a production environment due to security risks (arbitrary code execution). For production, consider using a safer JSON parser like json.loads() if the arguments are guaranteed to be valid JSON, or implement a more robust argument parsing mechanism. The example above is for illustrative purposes.

Integrating Tools: Retrieval for Custom Knowledge

Retrieval allows your custom GPT to access and synthesize information from documents you provide, significantly expanding its knowledge base beyond its training data.

  • Use cases: Building a bot for product documentation, internal company policies, academic research papers, FAQs, or any specialized domain knowledge.
  • How it works: You upload files to OpenAI. The API processes these files, creating embeddings and an index. When the Assistant needs to answer a question that might be in these files, it uses the Retrieval tool to search and incorporate relevant passages into its context.

Steps for Retrieval:

Written by

Khader Vali

Senior Software Engineer specializing in cloud architecture, real-time systems, and enterprise-scale applications.

Share this article

Related Articles

Building AI Agents with Function Calling in Python hero image showcasing LLM tool use architecture

Building AI Agents with Function Calling in Python

Jun 01, 2026 · 16 min read

Multimodal AI: Vision, Language & Audio Combined

Oct 01, 2026 · 19 min read

RLHF Explained: Reinforcement Learning from Human Feedback

Sep 24, 2026 · 17 min read