How to Build a RAG App From Scratch with Streamlit and OpenAI

Diogo Resende
Diogo Resende
hero image
Want a career in tech?

Take our career path quiz to find the best fit for you and get a personalized step-by-step roadmap 👇

Take The 3-Minute QuizTake The 3-Minute Quiz

Most AI apps are useless the moment you ask them something new, simply because they're limited to what they were trained on.

So let's fix that…

In this article, I’m going to walk you through how to build a retrieval augmented generation (RAG) system from scratch using the exact same tools and workflows professional AI engineers are using right now.

We're skipping through the noise and focusing on what actually matters:

  • How to prompt properly

  • How to retrieve the right data from your own documents

  • And how to turn all of that into a working RAG application that you can run yourself, and deploy so others can use it too

So let’s get into it…

Sidenote: This project is actually part of my RAG course:

You’ll learn how to thrive in the AI era with this hands-on course that teaches you to build better AI applications using one of the most important AI techniques used in the real-world to supplement an AI model's knowledge with proprietary or new information: Retrieval Augmented Generation (RAG).

The course is seriously awesome, but I wanted to help you out even more, and figured I would pull this section out of the course and put it together as video and article so you can enjoy either, and set up your first RAG app:

You can watch the video that goes into a little more depth, otherwise, keep reading.

You can also grab the resources for this tutorial here.

With that out of the way, let's get into it...

For me, OpenAI’s file search is the easiest way to implement RAG with code, and arguably one of the best.

So we’re going to use it to build a Mini Rubber Ducky:

This is an AI assistant that answers questions using our own documents, in this case transcripts from my videos and a Python file. It’s a mini version of the Rubber Ducky assistant I built for my courses, dedicated to one specific section (multimodal data, which is probably the toughest one from a programming perspective).

It’s built from two types of documents:

  • Transcripts. Text files of the lessons. (Don’t mind the numbers in the file names, they’re just from my internal organization of videos)

  • A Python script. A .py file, (not a notebook), so you can see how it handles code as well as text

The app is public on GitHub, because that’s where we deploy it from, so feel free to have a look:

Why build it this way?

Say you know RAG is useful, and you think a specific RAG bot would be useful for something. The next step is a proof of concept, and I honestly think this is one of the easiest ways to build one.

Sure, there are easier no-code options, but you lose a lot of control. This way, it’s still 100% you doing everything.

Here’s the plan:

  • First, we cover vector stores, what they are, and why they make searching for files fast, scalable and accurate

  • Then we get set up and go hands-on in Google Colab. Grabbing the files, uploading them, and creating the vector store inside OpenAI

  • Next we'll connect it to the Responses endpoint and use the tool that enables RAG, so we’ll have a working RAG system in a notebook

  • Finally, we’ll build it into a Streamlit web app in Cursor, push it to GitHub and deploy it, so anyone can use it

I’m using Cursor for the front end. You’re welcome to use something else, but using the same tools as me will make it easier to follow along.

What are vector stores?

A vector store is a database, and specifically, it's a database meant for AI.

For example

Have you ever tried to find an old photo or document, and all you can remember is that it had something to do with Italy? Maybe that picture had a red car in it.

Well, in a sense, that's basically what vector stores are going to solve. They act like a smart memory bank for AI systems, because we search things by meaning, not by the exact words.

The foundation here is embeddings. Embeddings turn words, images, or even sounds into numbers. These numbers are called vectors, and they capture meaning.

If we take this image for example, then "queen" and "king" are classified as sitting very close together, while "apple" is going to be far apart.

A vector store is simply the place where you keep all those vectors neatly organized, ready to be searched like a database.

A normal database just looks for exact words. So if you type "vacation", you only get documents that literally have the word "vacation". But if your file says "holiday" or "trip", then you're going to miss it completely.

The cool thing about vector stores is they don't match keywords, they match meaning.

For example

  • Let's say that you have thousands of photos that you want to search through. You type "sunset on the beach" and the right ones instantly appear, even if one is called `img_1032`. That's because the system isn't reading file names. It's searching the meaning inside the image.

  • Or music. Imagine humming into your phone, like with Shazam, and the app finds the closest match. Not the exact words, not the exact notes, just similarity. That's the power of searching by vectors.

  • It even shows up in shopping and cooking. You browse a pair of sneakers online and instantly see other shoes you might like. That's a vector store behind the scenes, comparing the meaning of products.

Same with recipes. You type "quick pasta dinner" and it brings back dishes labeled "20-minute spaghetti" or "easy fettuccine". Not the exact same words, but the same intent.

How it works

Every item, whether it's a photo, song, or document, gets turned into a vector, which is basically a list of numbers. The vector store places them in a huge map of meaning, where similar things sit close together.

When you search, your query also becomes a vector. That vector goes into this map of meaning, and depending on where it sits, you get the closest, most similar chunks back.

This is super important for AI, because chatbots can now answer questions about your own data, using RAG. So instead of guessing, the chatbot checks the vector store for the most relevant information, then uses it to generate an answer.

So in plain words:

  • Embeddings turn messy human data into numbers that capture meaning

  • A vector store is where you keep them, so AI can quickly find, compare, and use what matters most

It's really the difference between scrolling endlessly and getting the right answer right away.

Handy right?

What we're going to do is show our AI how it can connect to and recall things about our transcripts and our Python files. Before that though, we need to do a few set up tasks.

How to set up the OpenAI API

Before we can build anything, we need three things:

  • an OpenAI API account with some credits

  • an API key

  • and a Colab notebook that's connected to both the API and our documents

We'll be using OpenAI's Responses endpoint throughout. In older versions of this course we used chat.completions, which still works, but Responses is the one in active development, so it's what you should be using today.

Step 1. Set up your OpenAI API account

When you log in to OpenAI, you'll see three products:

  • ChatGPT

  • The API platform, and

  • Sora

The API platform is for building your own programs, so that's what we're going to use. However, it's worth pointing out that ChatGPT and the API platform are two different things. This means that even if you currently pay for ChatGPT Plus or Team, that means nothing on the API platform.

There is a free tier, but it runs out after a few uses, so I recommend adding credits.

Add credits to your account

Super easy to do. Simply:

  • Verify your identity if prompted

  • Then go to Settings > Billing

  • Under Credit balance, click Add to credit balance

Don't worry about buying too many credits because it's actually fairly cheap to use.

For example

I've used the API for over two years across several companies and apps and only spent around $150-200 USD in total, so not bad at all. For this whole tutorial you should need $5, maybe $10 at most.

You also don't always need the latest model, as cheaper ones work fine for practice.

Step 2. Create your API key

Go ahead and set it up:

  • Log in to the OpenAI API platform

  • Go to Settings, then find API keys in the navigation panel on the left

  • Click Create new secret key. Even if you already have keys, I recommend creating a new one for this project

  • Give it a name. "OpenAI API key" is the standard, but I'm calling mine OPENAI_API_KEY_RAG so it's tied to this project

  • Select your project (most likely just Default project) and create the key

  • Copy the key, then click Done

IMPORTANT: Make sure to make a copy of it straight away. Once you close that dialog, the key is hidden for security reasons so you won't see it again, and you need it for the next step.

Step 3. Store the key in Colab

Open the starter notebook from the resources to go with this guide. You'll find it in RAG Bootcamp > RAG > RAG with OpenAI File Search.

Next, go ahead and:

  1. Open the Secrets panel (the key icon) in Colab's left sidebar

  2. Click Add new secret and name it something like OPENAI_API_KEY_RAG

  3. Paste your key as the value

  4. Toggle notebook access on to activate it

If you name it something different, just swap your name into the code in the next step.

Step 4. Mount your Drive and connect to the API

Go to Files in Colab's left sidebar and click the folder icon to mount Google Drive, then allow it to connect when asked. (Sometimes a code cell to mount the drive appears instead. Don't be surprised, that works too)

from google.colab import userdata
import os
Then load your API key into the environment and create the OpenAI client

api_key = userdata.get('OPENAI_API_KEY_RAG')
os.environ['OPENAI_API_KEY'] = api_key
from openai import OpenAI
from IPython.display import Markdown
client = OpenAI()
Then, change into the notebook's folder, so it can find the documents we're going to search using

%cd /content/drive/MyDrive/RAG Bootcamp/RAG/RAG with OpenAI File Search

Because the key is already in the environment, OpenAI() picks it up automatically, so we don't need to pass it in. (If your Drive doesn't show as connected straight away, give it a moment and check again before moving on).

Once the API and the Drive are connected, everything is set up. We'll use gpt-5.2 as our model, but for practice you can use a cheaper one like gpt-5-nano. Either way, the tokens we spend here are basically nothing.

(Yes there are newer models, but Astra is overkill for this, and the costs will ramp up. You're fine with a lower model).

As I've shared, we're building a Mini Rubber Ducky.

In the documents folder (Docs), we have the scripts for one of the sections, which is the one on multimodal data, and we also have a .py file. So we're going to have this duality of different file types. Of course, this could be about anything else, but I want to show you how it works with text files and also Python files.

Our next step is to build our knowledge component.

How to retrieve the file paths for your documents

Let's build our knowledge component. First, I want us to explore the API reference as well, so go to Tools, then File search and retrieval.

Notice though that it's text only. So even if you see a PDF, a .docx, or a .pptx in the list, which can contain images, this is not meant for images. (What gets extracted is the text).

This is what you truly need to keep top of mind. It's not necessarily a con, but it's probably the biggest limitation we have.

Define the docs directory and supported extensions

First, we define where the docs are and which extensions we support. The directory is called Docs, with a capital D.

For us, the extensions are .txt and .py, but feel free to add more, and then put everything in quotes, inside a list:

# Define the place where the Docs are and extensions
docs_directory = "Docs"
supported_extensions = [".txt", ".py"]

This is just us stating where the docs are, and the extensions.

Collect the file paths

Before we do the upload, we need to see which files there are, so we're going to append all the file paths together into a list.

We iterate over every single thing in that directory, and if it has one of our extensions, we keep it:

  1. Create an empty list called files_path

  2. Loop over every filename in the directory using os.listdir(docs_directory)

  3. Build the full path with os.path.join(docs_directory, filename)

  4. Check it's actually a file with os.path.isfile(file_path)

  5. Get the extension with os.path.splitext(filename)[1].lower()

  6. If the extension is in supported_extensions, append the file path.

You have two options for the checks. You can combine them into one condition, or nest them. Personally, I prefer nested ifs, but I completely get it if you don't.

Before finishing the loop, I want us to take a step back and print the file name and the file extension, so we truly understand what we're getting:

# append all file names and paths together
files_path = []

for filename in os.listdir(docs_directory):
    file_path = os.path.join(docs_directory, filename)
    if os.path.isfile(file_path):
        file_extension = os.path.splitext(filename)
        print(filename)
        print(file_extension)

So here we have the file name, ending in .txt, and the result of the split. splitext splits the name at the dot, so index [1] gives you the extension, like .txt. If you were to put [0] instead, you'd get the rest of the name, which isn't what we want.

Then add .lower() on the end. This is very good practice, so that a file ending in .TXT still matches.

Here's the full version:

# append all file names and paths together
files_path = []

for filename in os.listdir(docs_directory):
    file_path = os.path.join(docs_directory, filename)
    if os.path.isfile(file_path):
        file_extension = os.path.splitext(filename)[1].lower()
        if file_extension in supported_extensions:
            files_path.append(file_path)

print(f"the files are {files_path}")

Make sure your .py file is in there.

Once you've done that, we're going to create the vector store and upload the files.

How to create a vector store and upload files in OpenAI

I'm going to show you two ways of doing this. One with code, and one no-code.

Let's start with code.

Create the vector store

Creating a vector store is very simple. You want to store it in a variable, so it's vector_store = client.vector_stores.create(...).

If you check the API reference under Vector stores, then Create vector store, the only thing that's mandatory is the name. So we'll call it "mini rubber ducky".

There's also a chunking strategy you can use, but I'd recommend setting this to auto. (If you don't set it, it uses auto anyway, so we leave it out here).

You can also pass file IDs here, but we're going to add those later. I like to split both steps.

# creating a vector store
vector_store = client.vector_stores.create(
    name="mini rubber ducky"
)

# Retrieve the id
print(vector_store.id)

Run it, and print the vector store ID. (Mine ends with 4947a6).

Now go to the OpenAI dashboard, then Storage, then Vector stores. There's the mini rubber ducky, with the same ID.

So that's the code version done. Now for the no-code option.

The no-code way to add files

The no-code option is basically clicking 'Add files' in the dashboard and dragging them in.

Super simple, but it's manual and in the future when you use this on projects, you'll probably be building systems that you'll keep adding to, so you want to have your files organized and not have to do it by hand each time.

So that's both methods done. Now to upload the files.

Upload the files to OpenAI

Before we can add files to the vector store, we need to upload them to OpenAI, so that they show up in your files storage.

  1. Create an empty list called file_ids

  2. Loop over each file_path in files_path

  3. Open the file with "rb", which means read binary

  4. Call client.files.create() with the file and a purpose

  5. Append response.id to file_ids

# Upload files to OpenAI
file_ids = []

for file_path in files_path:
    with open(file_path, "rb") as file:
        response = client.files.create(
            file=file,
            purpose="assistants"
        )
        file_ids.append(response.id)

In the API reference under Files, then Upload file, you can see ``client.files.create``. You can also do the ``open`` outside, that's fine. The most important thing is that you specify the file, and then the purpose.

I want to fully go through the purpose, because you need to understand what you're picking.

The options are:

  • assistants. This is ours. It's what we use for RAG

  • fine-tune. If you've taken more content from me, maybe you've used fine-tuning

  • vision. There's also vision fine-tuning, which is slightly different. It's so expensive. I tried it once for something super simple and thought, wait, what? No way I'm doing that

  • batch. This is for saving money, when you don't need things done soon and don't care if they happen overnight. I don't really see the purpose for us

  • user_data and evals. I haven't used either of these

The upload returns an object with an id, and that's what we're looking for. So go ahead and run it, and in the dashboard.

You should see the files appear with new dates:

Attach the files to the vector store

Now, for each file ID in file_ids, we attach the file to the vector store using client.vector_stores.files.create().

In the API reference under Vector stores, then Vector store files, it says "create a vector store file by attaching a file to a vector store". I guess the name is a bit misleading, but basically you're attaching a file to the vector store.

It takes the vector store ID, which is vector_store.id, and the file ID, which comes from our loop:

# Add files to the vector store
for file_id in file_ids:
    result = client.vector_stores.files.create(
        vector_store_id=vector_store.id,
        file_id=file_id
    )

Run it, then go back to the dashboard. You'll see the vector store getting populated, with some kilobytes showing, and the files being attached.

Now that we have our vector store, the next step is to actually get an output from our RAG system.

So go ahead and go to:

  1. The API reference

  2. Then the Responses API,

  3. Then Create a model response

  4. and look for file search

Make a basic call with the file search tool

It's up to you which model you pick, but let's go with GPT-5.2. Then we need an input, something super simple like "When should build a multimodal RAG?".

That alone though is just input and output. What we need to add is tools.

You'll probably get a bunch of autocomplete suggestions here. I'd say they're a bunch of BS.

The way you do it is:

  • type is "file_search"

  • vector_store_ids is a list. Note that "ids" is plural, which means you can have multiple vector stores. For us, it's just vector_store.id

  • max_num_results is how many chunks we're retrieving. 20 is a good value, from my perspective

# Call the API
response = client.responses.create(
    model="gpt-5.2",
    input="When should build a multimodal RAG?",
    tools=[
        {
            "type": "file_search",
            "vector_store_ids": [vector_store.id],
            "max_num_results": 20
        }
    ]
)

# Explore the output
display(Markdown(response.output_text))

Make sure to watch the spelling of vector_store_ids and your curly brackets. An extra bracket or a typo in that key is an easy mistake to make here.

The answer says to build multimodal RAG when answering your user's question requires retrieving and grounding non-text evidence, and then a bunch more.

The question though is did we actually use our data or not?...

Check that the answer uses your data

There's a parameter called include, which lets you specify additional output data to include in the model response. One of the options is file_search_call.results, so that's what we'll add.

What I like about this, at least in the beginning, is that it lets us make sure what we're building makes sense and is built on our data, not on whatever the model knows.

response = client.responses.create(
    model="gpt-5.2",
    input="When should build a multimodal RAG?",
    tools=[
        {
            "type": "file_search",
            "vector_store_ids": [vector_store.id],
            "max_num_results": 20
        }
    ],
    include=["file_search_call.results"]
)

# Explore the output
response

As you can see, the response is massive. So one thing to consider is tokens as this gets expensive, or can get expensive, at scale.

Let's go to the first output, response.output[0]. This is the file search tool call, and it holds a lot.

First, lets get the queries:

# Explore the output
response.output[0].queries

These are the ones that were sent to our data:

  • "When should build a multimodal RAG?"

  • "Multimodal RAG when to use multimodal retrieval augmented generation criteria"

  • and "Use cases for multimodal RAG images tables diagrams"

This was the response:

It's actually pretty cool, because it augments our query to get the best outcome, and I do appreciate that.

The second output, response.output[1], doesn't have queries or results. That's the message with the content. The results live in response.output[0]:

# Explore the output
response.output[0].results[0]

You'll see results from index 0 to 19, because we asked for 20. Index 20 would be the 21st, so it's out of range.

For each result, you can see the text that was retrieved, and specifically the score. The first one is 0.62 (0.6209). Index 1 is a bit less, and they go down to 19, which has the lowest score.

Again, this is for us to see what is being retrieved and what isn't.

Add instructions

The last thing to add, and I always encourage this, is instructions. This is our system/developer message:

response = client.responses.create(
    model="gpt-5.2",
    input="When should build a multimodal RAG?",
    tools=[
        {
            "type": "file_search",
            "vector_store_ids": [vector_store.id],
            "max_num_results": 20
        }
    ],
    include=["file_search_call.results"],
    instructions="Only answer based on the information retrieved. Say you don't know if you don't know"
)

# Explore the output
display(Markdown(response.output_text))

Run it with Shift+Enter, and see whether the output changes now that we've added this extra parameter. This also lets us better define what we want.

This time, it's a lot closer to our material, because we now have the CLIP model mentioned in the answer. And you can still check the queries and the results as before.

That's our OpenAI file search demo. Next, we'll take this and deploy it with Streamlit.

How to set up Cursor and your requirements file

For the practical part of this build, we're going to be using Cursor, which is software for programming. I'll briefly introduce how it works.

You only need the free tier, which is the Hobby plan. I switch between Hobby and Pro depending on how much I'm building at the moment. I'm currently on Pro, but that's not going to change anything here.

You just need to:

  1. Go to the Cursor website and either sign in or click Download (for Windows, macOS, or whatever you're on). I always encourage you to have some kind of account

  2. Follow the standard installation instructions

Once Cursor is installed, you need to kick things off with a project. The easiest way to do this:

1. Go to the folder where you keep your projects. I have one big folder with all the projects I'm always working on.

2. Click New folder and give it whatever name you like. I'm calling mine Mini Rubber Ducky v2 because I already have one. The name doesn't matter.

3. In Cursor, open the project and select that folder. You just need to know where you stored it.

You're going to see that there's nothing in it, which is fine. That's the status quo for now.

Figuring out which libraries we need

For this particular project, we need three libraries:

  • In Colab we used google.colab.userdata to hold our secrets. We need a library for that, just not a Colab one. That's python-dotenv, which is what allows us to store all the information we want to hide, like API keys

  • The same way we had OpenAI in the notebook, we also need openai here

  • And because we're deploying with Streamlit, we also need streamlit

Especially at the beginning and the end of a build, I use the Cursor chat a lot. So I asked it:

"Prepare a requirements.txt file with the latest stable versions of openai, dotenv, and streamlit. The year is 2026".

Make sure to always specify the year. This has happened to me before. It's 2026 now, but it wouldn't be unlikely for the model to go and find the "latest" versions from 2024, which would be a bit useless.

Because I'm on Pro, I have access to more models, but I always leave the model selector on Auto. If you're on the free plan, that's also what you have access to.

The result is a requirements.txt with our three libraries:

openai==2.15.0
python-dotenv==1.2.1
streamlit==1.53.0

Building your AI web app

Ok so far you've built our Mini Rubber Ducky RAG system. However, it's currently stuck inside a Python script or a notebook.

Why does that matter?

Well sure, It works and it's powerful, but it's not exactly something you can send to a friend or show your boss. You can't ask them to install Python and run a script just to see your work right?

So we need to make it accessible, by taking that raw code and transform it into a real, functioning web application.

To do that, we're going to use a tool called Streamlit. And the best part is that you don't need to learn HTML, CSS or JavaScript. If you know Python, which I know you do, you already know how to build this app.

Here's the transformation you're about to go through:

  1. Set up our coding environment. We're moving away from notebooks and into real software development practices, using Cursor and virtual environments

  2. Build the interface. A sleek sidebar, a chat window that looks almost as good as ChatGPT, and a history feature that remembers what you said

  3. Connect the brain. We'll hook your RAG logic up to this interface

  4. Deploy it. We put it live on the web.

By the end, you won't just have code on your laptop. You'll have a URL, an actual link. You can text it to a friend and say, "Hey, look at what I built," and they can chat with your assistant on their phone.

This is a massive milestone. So if you're ready to stop just writing scripts and start building real software, let's get started.

## How to set up a virtual environment and .env file

The next step is to create a virtual environment, and we'll keep using the Cursor chat for this.

This environment is going to hold just our key libraries. That's very useful when you want to keep things stable. We're developing for OpenAI 2.15, Streamlit 1.53 and so on, but I might have other apps that use different versions of OpenAI and Streamlit. I don't want to break them, so it's important that we split the different apps into their own environments.

In the Cursor chat, ask:

> Create a virtual environment and install the libraries.

Under the hood, this runs something along these lines (on Windows, the PowerShell activation script is `venv\Scripts\Activate.ps1`):

```bash
python -m venv venv
venv\Scripts\Activate.ps1; pip install -r requirements.txt

![Cursor chat running python -m venv venv and pip install -r requirements.txt](56-cursor-chat-python-venv-pip-install-requirements.png)

### Creating your .env file

While that's running, let's add a new file called `.env`.

You'll notice it shows a little "forbidden" style icon next to it. That indicates the file is hidden, meaning Cursor and its agents can't access it. It's very important that you have something similar to this for your secrets.

![New .env file in Cursor's explorer showing the blocked icon next to it](57-cursor-env-file-hidden-icon.png)

Inside it, you want two things:

1. Your OpenAI API key. Go to your OpenAI dashboard, click **Show** on your key and copy it. (Not the most cybersecurity-wise thing to do on screen, but it's fine.) 2. Your vector store ID, which you can grab from the script/notebook we worked on earlier.

```bash
OPENAI_API_KEY=your-api-key-here
VECTOR_STORE_ID=your-vector-store-id-here

**Warning:** don't wrap the values in quotes. Later on, my app kept complaining that it couldn't find the OpenAI API key environment variable, even though it was clearly in the `.env` file. Taking the quotes out is what fixed it.

### When the install doesn't go smoothly

I have to say, this part about installing things often doesn't go as swiftly as it should.

In my case, I think what happened was that it created the virtual environment and installed the libraries, but not inside the virtual environment. It installed them somewhere else. The chat figured that out and recreated the environment, then reinstalled.

At one point it got stuck, so I cancelled it. When I checked, neither `openai` nor `streamlit` had been installed, so I told it to try again. That time it worked, and it only took another 30 to 60 seconds. Oftentimes this part is just brute force.

**Tip:** when Cursor detects the new environment, it'll ask something like "We noticed a new environment. Do you want to select it for the workspace folder?" Click **Yes**.

![Cursor notification asking to select the new Python environment for the workspace folder](58-cursor-select-new-virtual-environment-prompt.png)

### Mock up the design first

While the install is running, let's think about what we're actually building. This is a mini chatbot.

There are some issues with Streamlit we need to be aware of, like sessions. We're not going to fully overcome that because we're not connecting to a database (so far). But what we are going to do is create a mockup.

I asked ChatGPT:

> Create a design for the UX of a Streamlit app with a chatbot called Rubber Ducky to answer students' queries: > 1. Side panel to initiate a new chat. > 2. A brief presentation of the chatbot at the top of the page. > 3. We want a chatbot-like design for Streamlit.

![ChatGPT-generated mockup of the Rubber Ducky Streamlit chatbot with sidebar and chat window](59-rubber-ducky-streamlit-chatbot-mockup-chatgpt.png)

That way we have something to guide ourselves. I always recommend you start with a design, or at least a flow. For a chatbot, the flow is almost self-explanatory, but the design is not. If we understand the kind of design we're looking for, that's always a good idea.

By the way, you can definitely take the image and have the AI build the code straight from it. To be 100% honest with you, we're going to take the long way. I want to show you how the Streamlit functions work, because it's relevant that you understand what they're for and what's possible.

You could do it the quick way, that's for sure. But in the end, anyone can do that. And if anyone can do it, it's an average output. We want to at least be able to take an average output and make it better.

## How to configure the Streamlit page

Our setup is done, so let's create a new file called `app.py`. This is where our magic is going to happen.

### Import the libraries and connect the OpenAI client

We start by importing our libraries:

- `os` - `streamlit` as `st` - `load_dotenv` from `dotenv`, which is for our `.env` file - `OpenAI` from `openai`

Very straightforward.

Then we load the environment variables with `load_dotenv()`. That's how we connect everything.

Next, we definitely want to initialize the OpenAI client. We do that by getting the API key with `os.getenv("OPENAI_API_KEY")`, which gets the environment variable, and then passing that API key to the client.

```python import os import streamlit as st from dotenv import load_dotenv from openai import OpenAI

# Load environment variables load_dotenv()

# Initialize OpenAI client api_key = os.getenv("OPENAI_API_KEY") client = OpenAI(api_key=api_key) ```

### Set the page configuration

Now I want us to set the page configuration. This is meant to be super easy. We call `st.set_page_config()` with:

- `page_title`: Mini Rubber Ducky - `page_icon`: yes, it gave me the duck - `layout`: wide - `initial_sidebar_state`: expanded

```python
# Set the Page configuration
st.set_page_config(
    page_title = "Mini Rubber Ducky",
    page_icon = "🦆",
    layout = "wide",
    initial_sidebar_state = "expanded",
)

You can look these options up in the Streamlit docs, or just hover over `st.set_page_config` in Cursor to see them. For `layout`, you have `centered` or `wide`.

With `centered`, page elements are constrained to a centered column of a fixed width. If you're designing for mobile and you only care about mobile, this would probably be the way to go. `wide` is usually the one I use, and it's for when you care more about web apps. That said, `wide` will still work on mobile.

![Cursor hover documentation for st.set_page_config showing the centered and wide layout options](60-cursor-hover-docs-streamlit-set-page-config-layout.png)

Then there's `initial_sidebar_state`. If you look at our mockup, we have a sidebar, so we want it to be `expanded`.

### Test it locally

Save the file and ask the Cursor chat to test the app locally. It'll check `app.py` and then run it with something like this (activating the virtual environment first):

```bash
.\venv\Scripts\Activate.ps1; streamlit run app.py

There are three things where I always lean on the chat:

1. The virtual environment and installing the libraries. 2. Local testing, which sometimes throws errors in whatever this language is. 3. At the very end, pushing to GitHub.

Those are the three where I'm like, "Do it. I'm not dealing with this whatsoever."

The good thing is that you usually only need to get it right once. After that, you're good to go and just need to reload the page. Until you get there it can be a bit painful, but don't get discouraged, even if it takes a few minutes. It's going to be fine.

When I ran it, Cursor came back with a few suggestions. One of them was that `st.set_page_config()` must be the first Streamlit command, and it also added a title and some welcome text as main app content:

```python
# Main app content
st.title("🦆 Mini Rubber Ducky")
st.write("Welcome to Mini Rubber Ducky!")

That's a bit of nonsense copy, but it's fine as a placeholder for now. It also added some debugging code that printed whether my API key was there. I removed that, because what's the point?

**Sidenote:** `st.set_page_config()` needs to be the first *Streamlit* command, but loading the environment variables isn't a Streamlit command. So you can keep `load_dotenv()` and the client setup above the page config if you want. I'm stubborn, and that's how I like it.

The app loaded, but it complained that it couldn't find my OpenAI API key environment variable, even though it was in the `.env` file. That was the quotes issue I warned you about earlier. Once I took the quotes out of the `.env` file and ran it again, it worked.

![Mini Rubber Ducky Streamlit app running on localhost:8501 with title and welcome text](61-streamlit-app-running-localhost-mini-rubber-ducky.png)

So now we have our app running with the title "Mini Rubber Ducky." All of this has been setup, setup, setup, so let's start building something new.

## How to set up session state and load the vector store

Now we're going to focus on what's called the session state. Whenever you're talking to the chatbot, there are a few things we need to keep over time, or that we need right at the start.

For now, there are two things we're sure we absolutely need:

1. **Our vector store ID.** This is where all the information lives. It's the foundation of our RAG. 2. **Something for the chat.** We're building a chatbot, so we need something within the state that keeps getting added to, so we have memory throughout the conversation.

### Initialize the session state

This is really straightforward.

If `messages` isn't in the session state yet, we start it as an empty list, because we don't have any messages so far.

Then, if `vector_store_id` isn't in the session state (notice the difference here), we set it to `None`.

```python
# Initialize the session state
if "messages" not in st.session_state:
    st.session_state.messages = []
if "vector_store_id" not in st.session_state:
    st.session_state.vector_store_id = None

This is where we're saying: we're going to have messages, and we're going to have a vector store ID. We don't know them yet, but we're going to have them.

### Write a function to load the vector store

Next, we need a function to load the vector store.

Personally, I enjoy writing what the function should do before writing the function. So we define `load_vector_store()`. Cursor gives you some suggestions, but I like to do it my way and start with a docstring: load the vector store ID from the `.env` file.

But there's something we haven't covered yet, and this is very important: we're not going to have this `.env` file forever.

The `.env` file is for our environment here, on our machine. At some point we're going to put all of this on GitHub. If I can navigate to that file and see my API key and vector store ID, so can anyone else. So we can't rely on it once we deploy.

For that, Streamlit has something called Streamlit secrets. As we deploy the app on Streamlit, we'll define our secrets there. So our function needs to load the ID from the `.env` file and fall back to Streamlit secrets.

Here's how it works:

1. We **try** to get the vector store ID with `os.getenv("VECTOR_STORE_ID")`. 2. If we don't have it, we try to get it from the secrets with `st.secrets.get("VECTOR_STORE_ID")`. If that throws an exception, we just `pass`. We're not going to throw an error there. 3. If we still don't have a vector store ID, we show an `st.error` saying no vector store ID was found, and to set the environment variable or add it to the Streamlit secrets. Then we return `None`. 4. Otherwise, we return the vector store ID. 5. Around all of this, if anything else goes wrong, we catch the exception, show an "Error loading vector store" message, and return `None`.

```python # Function to load the vector store def load_vector_store(): """load the vector store id from .env file, then fall back to streamlit secrets""" try: vector_store_id = os.getenv("VECTOR_STORE_ID")

if not vector_store_id: try: vector_store_id = st.secrets.get("VECTOR_STORE_ID") except Exception: pass

if not vector_store_id: st.error("No vector store id found. Please set the VECTOR_STORE_ID environment variable or add it to Streamlit secrets.") return None

return vector_store_id

except Exception as e: st.error(f"Error loading vector store: {e}") return None ```

![The load_vector_store function in app.py in Cursor with the .env and Streamlit secrets fallback](62-cursor-app-py-load-vector-store-function.png)

In an ideal world, here in Cursor, we get the vector store ID from the `.env` file, neither of the `if not` blocks kicks in, and we return it straight away.

If it's not in the `.env` file but it is in the Streamlit secrets, we grab it from there. Either way, we end up returning the ID. Only if it's in neither place do we hit the error.

You may ask yourself: what about the OpenAI API key? Shouldn't that fall back to Streamlit secrets too? I think there's a high chance we're going to get an error there later. But I think it's often way more fun, and way more educational, to deal with errors as they come rather than share everything in one go.

So to recap: we have messages and a vector store ID in our session state, and we've built a function that loads the vector store and deals with errors.

## How to build the app: the sidebar and reset button

Time to start building the app. If we look at what we have so far, we've got the Mini Rubber Ducky welcome. It may not be the cutest thing yet, and we can improve on it, but that part is there.

Now let's build the chat side. We're not going to have extras like "Python basic exam tips" here. We're just going to have a new chat.

We're going to build this backwards. We have an output that we roughly expect, so we'll define `main`, write what we want it to do, and then build the pieces it needs.

### Step 1: Add the sidebar with a reset button

1. Inside `main`, open a `with st.sidebar:` block. As soon as you do this, Streamlit knows the app will have that sidebar. 2. Add a header. Feel free to name it whatever you like, but I'm calling it "Settings", because even though we only have a new chat button for now, I may want to add other things later (like you can see in the bigger Rubber Ducky). 3. Add an `if st.button(...)` for "Reset Conversation". The button is what shows up in the sidebar, and the `if` is what happens when you click it: we reset the conversation.

You'll see squiggly lines at this point. That's because we're missing the function that actually resets the conversation, so let's build that next.

### Step 2: Build the function to reset the conversation

Above `main`, define `reset_conversation`. The way it works is that we set `st.session_state.messages` to a list. You could leave it empty, but I'm going to add a first message: one with the role `assistant` and some content, which is our initial message with `.strip()` so we remove any extra whitespace.

Then we call `st.rerun()`, which updates the app.

### Step 3: Define the initial message

Now we get a squiggly line on the initial message, so let's define it:

"Hi! I am Mini Rubber Ducky, your friendly assistant. How can I help you today? I can help in many modes"

Yes, I snuck a joke in there about multimodal RAG. I know, nerd.

### Step 4: Run the app

If you save this and go to localhost, you won't see anything yet. That's because we still need to actually run the app, so add the `if __name__ == "__main__":` block at the bottom.

Here's the code so far:

```python # Define the initial message INITIAL_MESSAGE = """ Hi! I am Mini Rubber Ducky, your friendly assistant. How can I help you today? I can help in many modes """

# Build function to reset conversation def reset_conversation(): """reset the conversation history""" st.session_state.messages = [ {"role": "assistant", "content": INITIAL_MESSAGE.strip()} ] st.rerun()

def main(): # Sidebar with reset button with st.sidebar: st.header("Settings") if st.button("Reset Conversation"): reset_conversation()

if __name__ == "__main__": main() ```

Save it, go back, run it, and you'll see our Settings sidebar.

![Streamlit app on localhost showing the Settings sidebar with a Reset Conversation button](63-streamlit-sidebar-settings-reset-conversation-button.png)

So what does this new component do? When you click "Reset Conversation", the messages go away and we go back to just the initial message, which I always find cute to have.

## How to add the chat input

Next we need a way for the user to actually write their prompt, their input.

### Step 1: Add the chat input

Back in Cursor, inside `main`, create the prompt with `st.chat_input`. I'm using "Ask me anything about multimodal RAG" as the placeholder text, but you can customize it further.

### Step 2: Store the user's message

If we actually have a prompt, we need to append it to our messages.

For that to work, we also need to initialize the messages in the first place. So we check whether there are messages in the session state, and if not, we start the list. It's the same thing our reset button does: the first message is the bot's initial message.

Warning: make sure this initialization goes inside `main`, along with everything else. I initially put it outside and had to move it in.

### Step 3: Display the user's message

It's not just about getting the input. We also want to display it, so with `st.chat_message("user")` we call `st.markdown(prompt)`.

### Step 4: Display the full conversation history

We always need to display everything, meaning the entire conversation history. So for each message in `st.session_state.messages`, we create a chat message with its role and show its content.

Here's what `main` looks like now:

```python def main(): # Sidebar with reset button with st.sidebar: st.header("Settings") if st.button("Reset Conversation"): reset_conversation()

# Initialize the messages # (st.session_state.messages starts as [] at the top of app.py) if not st.session_state.messages: st.session_state.messages = [{ "role": "assistant", "content": INITIAL_MESSAGE.strip() }]

# Display the entire conversation history for message in st.session_state.messages: with st.chat_message(message["role"]): st.markdown(message["content"])

# Chat Input prompt = st.chat_input("Ask me anything about multimodal RAG") if prompt: st.session_state.messages.append( {"role": "user", "content": prompt}) with st.chat_message("user"): st.markdown(prompt) ```

This is still partially broken, but let's see what we get. We have the initial message ("How can I help you today? I can help you in many modes"), and the input box saying "Ask me anything".

If I ask something like "What kind of modalities could I use?", my message shows up, and then it just stops.

![Streamlit chat showing the bot greeting and a user question with no reply yet](64-streamlit-chat-input-user-message-no-reply.png)

That's our current status. We have a prompt, and now we need an output.

To recap what we did here:

- We initialize the messages with an initial message ("Hi! I am Mini Rubber Ducky, your friendly assistant") - We display all the messages - We take the user's input for the prompt

## How to make the bot reply (and set up the common kwargs)

Now we're going to take the prompt from the user and have our bot reply.

### Step 1: Add the assistant's reply

This needs to go inside the existing `if prompt:` block.

1. Open a `with st.chat_message("assistant"):` block. 2. Inside it, add `with st.spinner("Thinking..."):`. This means that until we get an answer there's a spinner on screen (hence the Streamlit spinner) that says "Thinking", because we are agentic. Yay. 3. Inside the spinner, get a `response` from an `ask_bot` function that takes the prompt as its input. 4. After that, `st.markdown(response)`. Fairly straightforward. 5. Then append the response to `st.session_state.messages`. It's exactly the same thing we did for the prompt: one is the prompt, the other one is the response.

```python if prompt: st.session_state.messages.append( {"role": "user", "content": prompt}) with st.chat_message("user"): st.markdown(prompt)

# Process the user input with st.chat_message("assistant"): with st.spinner("Thinking..."): response = ask_bot(prompt) st.markdown(response) st.session_state.messages.append( {"role": "assistant", "content": response}) ```

The big piece that's missing is `ask_bot`. That's the one we really need to build.

### Step 2: Define the ask_bot function

Define `ask_bot`, which takes a `user_prompt` that's always going to be a string. Its job is to send questions to OpenAI and get responses back.

Some things in this call are going to be dynamic, and some things are going to be stable and persistent. So let's start with the persistent ones, which I'm putting in a `common_kwargs` dictionary. It will have a model, tools, and text settings.

### Step 3: Set the model

Let's start with the easy one. I have a preference for always defining the model at the top of the file, so `MODEL = "gpt-5.2"`.

This is just a preference, but everything I can define at the very start makes it easy for me later, because I don't have to scroll through the file to change it.

### Step 4: Add the file search tool

For the tools, the easiest way is always to go to the documentation and copy-paste. I don't know this by heart, and as you may have noticed, Cursor doesn't either. Its suggestion was awful.

So copy the file search tool definition from the docs (I grabbed it from the `client.responses.create` call in our earlier Colab notebook). It has the type `file_search`, a maximum number of results of 20, and the vector store ID. For the vector store ID, we use our own: `st.session_state.vector_store_id`.

![Google Colab notebook with the OpenAI file_search tool definition selected for copying](65-google-colab-openai-file-search-tool-definition.png)

*In the notebook it uses `vector_store.id`; in the app we swap in `st.session_state.vector_store_id`.* ### Step 5: Set the text verbosity

Then we add `text`. If you're not sure where this comes from, go to the API documentation, open Responses, then "Create a model response", and look for `text`. Click "Show properties" and you'll find `verbosity`.

So we set `text` with `verbosity` set to `low`. (Verbosity. Such a strong word.)

![OpenAI API reference for Create a model response showing the text verbosity property](66-openai-api-reference-responses-text-verbosity.png)

These are our common kwargs:

```python # Define the model MODEL = "gpt-5.2"

# Build the ask_bot function def ask_bot(user_prompt: str): """Send questions to OpenAI and get responses""" common_kwargs = { "model": MODEL, "tools": [ { "type": "file_search", "vector_store_ids": [st.session_state.vector_store_id], "max_num_results": 20 } ], "text": {"verbosity": "low"} } ```

Tip: watch your brackets and commas when you paste the tool definition in from the docs. I ended up with an extra curly bracket, and then an "invalid syntax" error from a missing comma. If you see that error, check the punctuation in this dictionary first.

At this point the app is structurally finished, because we have `ask_bot` in place. But right now, `ask_bot` is technically correct and practically useless. So let's finish it.

## How to handle multi-turn conversations with previous_response_id

There's one thing I'm going to say that feels very stupid, but it's also very true. And it feels so stupid precisely because it's true: there's going to be a state where we have the first message, and a state where it's not the first message.

Very stupid to say, but programmatically it makes sense. Why?

Look at how our conversation works. The prompt goes into the messages, and the output goes into the messages. But from an API perspective, how do we actually move from one turn of the conversation to the next?

The answer, and you may already know this, lies in the previous response ID. If you look at the API docs, there's a `previous_response_id` parameter, and it even says to use it to create multi-turn conversations. So yes, we store the messages ourselves, but it's through the previous response ID that the conversation is actually occurring.

![OpenAI API reference describing previous_response_id for multi-turn conversations](67-openai-api-reference-previous-response-id.png)

### Step 1: Initialize the previous response ID

At the top of the file, where we initialize the session state, add `previous_response_id` as `None` if it is not in the session state yet.

### Step 2: Handle the second-plus turns

Back in `ask_bot`: if we have a previous response ID, this means we're on the second-plus iteration. So we call `client.responses.create` (storing the result in `resp`) with:

- `previous_response_id` set to `st.session_state.previous_response_id` - An `input` list with the role `user` and the user prompt - Our `**common_kwargs`

Really straightforward.

### Step 3: Handle the first turn

Then there's the `else`: if we don't have a previous response ID, this is the first one. We call `client.responses.create` again with an `input` that also includes the user prompt, but with one extra message: the role `assistant` with our initial message. And of course our common kwargs as well.

### Step 4: Save the response ID and return the text

After either call, we need to store the previous response ID for next time, so set it to `resp.id`.

Last but not least, we need to return something, which is `resp.output_text`.

```python if st.session_state.previous_response_id: resp = client.responses.create( previous_response_id=st.session_state.previous_response_id, input = [{"role": "user", "content": user_prompt}], **common_kwargs ) else: resp = client.responses.create( input = [ {"role": "assistant", "content": INITIAL_MESSAGE.strip()}, {"role": "user", "content": user_prompt}], **common_kwargs )

st.session_state.previous_response_id = resp.id return resp.output_text ```

### Fix 1: Only call ask_bot when there's a prompt

When I reloaded the page, I got an `openai.BadRequestError`: "Invalid type for 'input[1].content': expected one of an array of objects or string, but got null instead", type "invalid_request_error".

If you look closely, the app showed the initial message and then immediately tried to reply without ever asking for our prompt. That's odd. The cause was indentation: the `ask_bot` call (the whole assistant block) should only run when we have a prompt, so it must sit inside the `if prompt:` statement. Once I moved it in and reloaded, the error was gone.

![Streamlit app showing an openai.BadRequestError invalid_request_error because input content was null](68-streamlit-openai-badrequesterror-invalid-type-null.png)

### Fix 2: Load the vector store

Next I asked "How can I build a multimodal RAG?" and got a different error: "Invalid type for 'tools[0].vector_store_ids[0]': expected a string, but got null instead".

This has to do with the vector store ID. The tool definition is fine, but the session state only initialized `vector_store_id` as `None`; we never actually loaded the vector store into it.

So in `main`, after the sidebar, load the vector store: if the session state's vector store ID is empty, set `st.session_state.vector_store_id` to `load_vector_store()`.

```python # Top of app.py: initialize the session state if "messages" not in st.session_state: st.session_state.messages = [] if "vector_store_id" not in st.session_state: st.session_state.vector_store_id = None if "previous_response_id" not in st.session_state: st.session_state.previous_response_id = None

# ...

def main(): # Sidebar with reset button with st.sidebar: st.header("Settings") if st.button("Reset Conversation"): reset_conversation()

# Load the vector store if not st.session_state.vector_store_id: st.session_state.vector_store_id = load_vector_store() ```

Reload, paste the prompt in again, and it shows "Thinking". So agentic. And it's working.

![Mini Rubber Ducky Streamlit app answering a question about building a multimodal RAG](69-streamlit-rag-chatbot-multimodal-rag-answer.png)

So to recap: we fixed the indentation issue, we fixed the missing vector store, and we now get responses back from the bot.

One thing we're still missing, if you think about it, is instructions. How do we actually include them? That's what we'll explore next, by looking at the API to see what the best options are for system instructions.

## How to add system instructions to your RAG app

Before we ship the app, there's one more thing to cover: instructions.

We're using `previous_response_id` to keep the conversation state across multiple turns. But if you look at the OpenAI docs, there's an important catch: when you use `instructions` together with `previous_response_id`, the instructions from a previous response are not carried over to the next response.

That makes it easy to swap instructions between turns, which is fine for some specific use cases. But for us, it means we need to pass the instructions on every single call. So we treat them as actual instructions and include them each time.

![OpenAI Responses API reference explaining instructions are not carried over with previous_response_id](70-openai-responses-api-instructions-previous-response-id.png)

### Pass the instructions on every call

Our `ask_bot` function already builds a `common_kwargs` dictionary that gets unpacked into every `client.responses.create` call, so the easiest place to add the instructions is right there. That way they go out with every call, including the ones that use `previous_response_id`:

```python def ask_bot(user_prompt: str): """Send questions to OpenAI and get responses""" common_kwargs = { "model": MODEL, "tools": [ { "type": "file_search", "vector_store_ids": [st.session_state.vector_store_id], "max_num_results": 20 } ], "text": {"verbosity": "low"}, "instructions": INSTRUCTIONS }

if st.session_state.previous_response_id: resp = client.responses.create( previous_response_id=st.session_state.previous_response_id, input = [{"role": "user", "content": user_prompt}], **common_kwargs ) ```

Sidenote: if you get a "Dictionary entries must contain key/value pairs" error here, check that you wrote `"instructions": INSTRUCTIONS` and not `instructions = INSTRUCTIONS`. We're inside a dictionary, so it needs a quoted key and a colon, not an equals sign.

### Write the system instructions

I already had the instructions written, so rather than type them out live, I'd rather focus on the main things I think are important to include when you use file search with OpenAI.

Here's how mine are structured:

- **Role and constraints**: concise, direct, practical, active voice, no fluff. (By the way, I think saying "no fluff" is fluff, but that's my two cents.) - **Goals and objectives**: answer the learner's questions, prefer retrieved facts over memory, and focus on multimodal RAG concepts. - **Answer style**: more constraints and boundaries on how the answer should look. - **Important context**: what are we actually talking about here? - **More constraints**: if the learner references a lecture, search for files with that stem and tailor the answer. Don't invent lecture numbers or titles. If the answer isn't in the corpus, say so clearly.

I keep them in a constant called `INSTRUCTIONS`, defined just above the `ask_bot` function:

```python # Define the instructions INSTRUCTIONS = """ You are Mini Rubber Ducky, the course assistant for a multimodal RAG assistant course. Be concise, direct, and practical. Use active voice. No fluff.

Primary objective - Answer questions about the course content and code using the attached Vector Store (transcripts, notebooks, scripts). - Prefer retrieved facts over memory. If the files don't cover it, say so. - Focus on multimodal RAG concepts, implementations, and best practices.

Retrieval & citations - Always use File Search first. - Ground every substantive answer in retrieved snippets. - If nothing relevant is found, say: "I don't see this in the course files." Then suggest the most relevant module(s) the learner should review. - Never include source citations or reference labels in the final answer text.

Answer style - Keep outputs scannable: short paragraphs, bullet steps, compact runnable code samples when needed. - When explaining "how to build X", outline the pipeline stages (ingest → retrieve → generate → evaluate) before diving into code. - Close each reply with a friendly follow-up question the learner might ask next. - Stay approachable, encouraging, and human.

Boundaries - Don't invent references, credentials, metrics, or file names. - If the topic is outside multimodal RAG/this curriculum, acknowledge the gap and offer a high-level pointer or ask for clarification.

Context: Course focus - Multimodal RAG: handling text, images, audio, and video in retrieval-augmented generation systems - Vector stores and embeddings for multimodal data - Integration patterns and architectures - Practical implementations and code examples

If the learner references a lecture/section by name/number, search for files with that stem and tailor the answer. Never invent lecture numbers or titles—they change over time. If the answer isn't in the corpus, say so clearly. """ ```

![Mini Rubber Ducky system instructions drafted in a Google Doc before pasting into app.py](71-rag-system-instructions-mini-rubber-ducky-prompt.png)

This is a very standard system instruction, and it's the kind of structure I'd recommend you use.

### Add some personality

Before we test it, I asked Cursor to add one emoji to each user-facing interaction in the app. Personally, I think it gives some pizzazz, some color, to the app.

This is going to sound super random, but I learned the other day that boomers are more likely to click on a YouTube video that has an emoji at the start. That felt relatable for some reason.

So now we've got a thinking emoji, a welcome emoji, more duckies, and more emojis in general.

### Test it

Save the file (I do like to save before running), then run the app again and ask it something like:

> Tell me step by step how to implement multimodal RAG.

![Mini Rubber Ducky Streamlit app answering with step-by-step multimodal RAG pipeline stages](72-streamlit-rag-chatbot-multimodal-rag-steps-answer.png)

It comes back with a step-by-step answer and even notes that the course demo uses GPT-4o mini. You might not recognize every step yet, but I can recognize all of them from the course materials.

And even though the app itself runs on GPT-5.2 (`MODEL = "gpt-5.2"`), the course materials still use GPT-4o mini, simply because it's a very robust multimodal RAG setup that hasn't needed any changes yet. Which makes me very happy.

Feel free to test it, improve the copy, and change whatever you like. This is how you make it yours.

That's development done. Now let's take this and make it shareable with just about anyone.

## How to push your RAG app to GitHub

### Make the API key work locally and on Streamlit

Before we get to GitHub, there's one part of the code I wasn't convinced about: how we load the API key.

Locally, the key comes from our `.env` file. But once the app is deployed, it needs to come from Streamlit secrets instead. So I added that part of the code to the chat in Cursor and asked it to also use Streamlit secrets.

Honestly, that's a pretty bad prompt, and the first suggestions it came back with were a hard no. It started building a whole function around it, and that's something I don't like, when LLMs do things in an over-complicated way.

What I wanted was simple: get the API key from the environment, and if it's not there, try Streamlit secrets. What I ended up keeping is a small `get_api_key()` helper that does exactly that, and then the client gets created from whatever it returns:

```python # Initialize OpenAI client def get_api_key(): """Get OpenAI API key from .env file, then fall back to streamlit secrets""" api_key = os.getenv("OPENAI_API_KEY") if not api_key: try: api_key = st.secrets.get("OPENAI_API_KEY") except Exception: pass return api_key

api_key = get_api_key() client = OpenAI(api_key=api_key) if api_key else None ```

### Create the repository

1. Sign in to GitHub. I'm assuming you have an account, and if not, creating one is about as easy as it gets. 2. Go to your repositories and create a new one. I called mine `mini-rubber-ducky`. 3. Set it to **Public**.

![GitHub dashboard showing the Top repositories list and the green New button](72a-github-dashboard-top-repositories-new-button.png)

![GitHub Create a new repository page with mini-rubber-ducky name and Public visibility](73-github-create-new-repository-mini-rubber-ducky.png)

(I don't know if I've shared this before, but the GitHub homepage is the worst homepage in the world. What you usually care about is your repositories, because we're all very self-centered.)

Important: to deploy with Streamlit's free tier, your repository needs to be public. So no enterprise secrets in there.

### Add a .gitignore file

Before we upload anything, we need a `.gitignore` file.

The `.gitignore` tells Git which files to skip when uploading. Most importantly, it lets us ignore the `.env` file, which means all of our secrets stay local with us. Some things live locally, some things live on GitHub, and that's the way to go.

I asked Cursor to create the `.gitignore` for me. This is so programmatic and automated that using AI for it is really the way to go. The file it generated was around 79 lines, and there's no point typing that by hand.

![Cursor-generated .gitignore file for a Python Streamlit project, excluding .env secrets](74-cursor-gitignore-file-python-streamlit.png)

### Push your code to GitHub

Then I asked Cursor to walk me through adding everything to the repo, step by step.

It's the same as with the virtual environment, where I mostly lean on the AI. I find Git way too annoying. And being 100% honest, before LLMs I used to keep a literal sheet of paper with notes on how to update my local folder from a GitHub repo, and how to update the repo with my new stuff.

So I guess in all the years I've been using GitHub, I never truly learned how to use it. Mea culpa. But I survived, I'm still here, and things still work.

These are the commands Cursor ran for me, in order (GitHub also shows you the equivalent ones for your repo when you create it):

```bash
git init
git remote add origin https://github.com/<your-username>/mini-rubber-ducky.git
git add .
git status
git commit -m "Initial commit: Mini Rubber Ducky Streamlit app"
git branch -M main
git push -u origin main

`git add` maps out everything that needs to be added, but it doesn't commit anything yet. You'll even see "No commits yet" at that point. `git commit` is where everything actually gets committed, and then you set the branch and push.

![Cursor agent running git push -u origin main to the mini-rubber-ducky GitHub repo](75-cursor-agent-git-push-origin-main-github.png)

The most important thing is that you understand what success looks like. It's only gone well when your repo page is no longer the empty setup page, and you actually see your files there.

The same goes for future updates: it's only gone well when you see your files marked as changed a minute ago. I appreciate that Git works this way. Either all your changes are live or none of them are. There's no halfway state.

![mini-rubber-ducky GitHub repo showing .gitignore, app.py and requirements.txt pushed one minute ago](76-github-repo-mini-rubber-ducky-files-pushed.png)

And that's it. The GitHub part is a no-brainer once you've done it.

## How to deploy your RAG app to Streamlit

Now let's wrap it up with Streamlit.

Go to streamlit.io and sign in. We're going to deploy on the free version.

![Sign in to Streamlit Community Cloud page with Google, GitHub and email options](76a-streamlit-community-cloud-sign-in-page.png)

The paid enterprise option is actually very expensive. Its main advantage is that your app doesn't have to be publicly available. All of my Streamlit apps, including the Rubber Ducky, are public, and you can check most of my code. So there are no secrets in there, and that's definitely something to keep in mind.

### Create the app

1. Click **Create app**. 2. Choose **Deploy a public app from GitHub**. 3. Find your repo, `mini-rubber-ducky`. 4. Set the branch to `main`. 5. Set the main file path to `app.py`. 6. Pick a custom URL, for example `mini-rubber-ducky`.

![Streamlit Community Cloud My apps page with the Create app button top right](76b-streamlit-my-apps-create-app-button.png)

![Streamlit What would you like to do screen with Deploy a public app from GitHub](76c-streamlit-deploy-public-app-from-github.png)

![Streamlit Community Cloud Deploy an app form with branch main, app.py and mini-rubber-ducky URL](77-streamlit-community-cloud-deploy-app-form.png)

### Set the Python version and secrets

1. Open **Advanced settings**. 2. Set the Python version. Personally, I use 3.12. 3. In the **Secrets** box, paste in the contents of your `.env` file: the API key and the vector store ID (go back to Cursor and copy them).

Streamlit secrets use TOML format, so the values need to be wrapped in quotes:

```toml
OPENAI_API_KEY = "sk-proj-..."
VECTOR_STORE_ID = "vs_..."

![Streamlit Advanced settings with Python 3.12 selected and the empty Secrets TOML box](78-streamlit-advanced-settings-python-3-12-secrets.png)

*The Secrets box before pasting anything in. Never share a screenshot with your real key in it.* I added the quotes from muscle memory. If there's no error, my muscle memory was right. If there is an error, we fix it like we usually do.

### Deploy and test

Click **Deploy**. Streamlit tells you your app is "in the oven".

![Streamlit loading screen saying Your app is in the oven while deploying](78a-streamlit-app-in-the-oven-deploying.png)

How easy was this? I'd honestly have appreciated a few more errors so I could show you how to fix them. Granted, we didn't hit many issues here, but that also goes to show it can be quite easy.

Once it's live, ask it a question. I asked:

> What is the CLIP model?

We get the thinking indicator, and then the answer: CLIP (Contrastive Language-Image Pre-training) is an OpenAI model that learns a shared embedding space for text and images using contrastive learning.

It even follows up by asking what you want to use CLIP for in your RAG system, and suggests options like cross-modal retrieval (text→image, image→text) and zero-shot classification.

![Deployed Mini Rubber Ducky app on streamlit.app answering What is the CLIP model](79-streamlit-app-deployed-rag-chatbot-clip-answer.png)

## Final takeaway

You've now built a working RAG application, and that already puts you ahead of most people who are still just experimenting with prompts.

What we've built is pretty powerful stuff. But it's still just a starting point. From here, you can go further by working with audio and images, building AI agents that can take actions, and turning what you've built into production-ready AI systems.

Best articles. Best resources. Only for ZTM subscribers.

If you enjoyed this post and want to get more like it in the future, subscribe below. By joining the ZTM community of over 100,000 developers you’ll receive Web Developer Monthly (the fastest growing monthly newsletter for developers) and other exclusive ZTM posts, opportunities and offers.

No spam ever, unsubscribe anytime

You might like these courses

More from Zero To Mastery

How to Become an AI Engineer & Get Hired in 2026 preview
How to Become an AI Engineer & Get Hired in 2026
14 min read

Learn everything you need to know to become an AI Engineer, as well as how to get hired as one in 2026 with this step-by-step guide!

Most In-Demand Tech Jobs For 2026 preview
Most In-Demand Tech Jobs For 2026
17 min read

Want to get hired in a tech job in 2026? Pick one of these 6 if you want: 1) High salary 2) Jobs available now 3) Can learn the skills as a complete beginner.

10 Claude Code Plugins You Need Right Now! preview
10 Claude Code Plugins You Need Right Now!
11 min read

Take Claude Code to the next level with 10 of our favorite plugins for better coding, testing, UI design, planning, and faster, more reliable results!