Project Space Bus
Goals
- deeper dive into Python
- django as a webhost with apache reverse proxy loadbalance and gunicorn/uwsgi
- forms, models, views, urls etc
- openai integration
Application
You partake in conversations with various passengers in an intergalactic bus. Each passenger has a backstory, description, motivation and personality - representing an AI agent. The driver is a controller AI who can ensure conversations are in alignment with a standard. Another AI that can set spatial atmosphere with structured data.
Django
- has a model per actor
- form to create or edit the actor
- log?
- has two views
- bus screen for interaction
- edit screens for actors
OpenAI
- agents per model?
Dev Log
Python venv
https://docs.python.org/3/library/venv.html
A python virtual environment runs on top of the base python os installation and
houses its own isolated packages. While the venv is active cli command python
will use the same python version as the base python and pip
will install packages
only within the virtual environment.
These should not be tracked in version control and cannot be moved around the filesystem. Instead they should be recreated as needed.
Get python venv toolset using apt:
apt-get install python3-venv
Setup the venv to run the app in:
python -m venv ./virtual_env
Activate the venv (linux):
source virtual_env/bin/activate
Deactiave the venv (while in venv mode):
deactivate
Delete the venv:
rm -rf ./virtual_env
Recreating the venv:
- Use pip to output currently installed packages using
pip freeze
orpip freeze > requirements.txt
- Reinstall those packages with
pip install -r requirements.txt
This file should be tracked in the project version control.
Note that using Poetry
is best for package and environment dependency management. These create pyproject.toml
(similar to a composer.json file in PHP) and a poetry.lock
file (similar to the composer.lock file in PHP). Both should be committed for best practices.
https://python-poetry.org
Python Coding Standards
https://peps.python.org/pep-0008
Django Installed
Install django (in venv):
python -m pip install Django
Test installation
$ python
>>> import django
>>> print(django.get_version())
5.2.5
New Django Site
$ mkdir [folder]
$ django-admin startproject [project name] [folder]
This created a folder with all scaffolding for the overall application project. A project parents internal apps.
manage.py
is essentially the equivalent of Laravel artisan.
Start Dev Server (from folder)
$ python manage.py runserver
The development server automatically reloads Python code for each request as needed. You don’t need to restart the server for code changes to take effect (except when new files are created).
Create the first App within the Project
$ python manage.py startapp [app name]
Wire up routes and views accordingly: https://docs.djangoproject.com/en/5.2/intro/tutorial01/
Wire up database to the settings.py file.
Run default app Migrations. These run based on apps in INSTALLED_APPS
in the settings.
$ python manage.py migrate
Create models, add app to installed apps, create migrations, run migrations, add to built in django admin
SpaceBus App Models
# create parent project in folder spacebus_app
django-admin startproject spacebus spacebus_app
cd spacebus_app
# build admin and preinstalled app migrations to local sqlite db
python manage.py migrate
# create sub app in project called thebus to house our bus models and logic
python manage.py startapp thebus
Create the models thebus/models.py
:
class Location(models.Model):
name = models.CharField(max_length=100)
coordinate_x = models.FloatField(null=True, blank=True)
coordinate_y = models.FloatField(null=True, blank=True)
coordinate_z = models.FloatField(null=True, blank=True)
def __str__(self):
return f"({self.name}, {self.coordinates})"
class SpaceBus(models.Model):
name = models.CharField(max_length=100)
state = models.CharField(max_length=50) # e.g., 'idle', 'in transit', 'docked'
capacity = models.IntegerField()
levels = models.IntegerField()
rows_per_level = models.IntegerField()
seats_per_row = models.IntegerField()
coordinate_x = models.FloatField(null=True, blank=True)
coordinate_y = models.FloatField(null=True, blank=True)
coordinate_z = models.FloatField(null=True, blank=True)
last_location = models.ForeignKey(Location, related_name='last_location', on_delete=models.CASCADE, null=True, blank=True)
next_location = models.ForeignKey(Location, related_name='next_location', on_delete=models.CASCADE, null=True, blank=True)
def __str__(self):
return f"{self.name} ({self.state}) heading to {self.next_location.name if self.next_location else 'N/A'}"
class Seat(models.Model):
bus = models.ForeignKey(SpaceBus, on_delete=models.CASCADE)
level = models.IntegerField()
row = models.IntegerField()
seat_number = models.IntegerField()
dirtiness = models.FloatField(default=0.0) # 0.0 (clean) to 1.0 (dirty)
def __str__(self):
return f"Bus: {self.bus.name}, Level: {self.level}, Row: {self.row}, Seat: {self.seat_number}"
class Race(models.Model):
name = models.CharField(max_length=100)
description = models.TextField()
homeworld = models.ForeignKey(Location, on_delete=models.CASCADE)
def __str__(self):
return f"{self.name} from {self.homeworld.name}"
class Passenger(models.Model):
name = models.CharField(max_length=100)
race = models.OneToOneField(Race, on_delete=models.CASCADE)
age = models.IntegerField()
backstory = models.TextField()
is_user = models.BooleanField(default=False)
bus = models.ForeignKey(SpaceBus, on_delete=models.CASCADE, null=True, blank=True)
seat = models.OneToOneField(Seat, on_delete=models.CASCADE, null=True, blank=True)
destination = models.ForeignKey(Location, on_delete=models.CASCADE, null=True, blank=True)
def __str__(self):
return f"{self.name}, {self.race} headed to {self.destination.name if self.destination else 'N/A'}"
Add the app thebus
to the list of installed apps for the spacebus project in spacebus/settings.py
The app is thebus.apps.TheBusConfig
which is a func defined in thebus/apps.py
Create model migration files with python manage.py makemigrations thebus
See the SQL that will run for the migrations with python manage.py sqlmigrate thebus 0001
See the migration plan before it runs: python manage.py migrate --plan
Run the migrations python manage.py migrate
Interestingly migrations that modify a table actually create a new table with full columns then insert all records from old table into new table then drop old table.
We can use the interactive shell to tinker with models and app: python manage.py shell
SpaceBus App Admin
Create the base superuser with python manage.py createsuperuser
Login at http://localhost:8000/admin/ (with server running)
thebus
app models are not managed in admin by default. We must expose models to admin by registering them
with in the thebus/admin.py
file.
We can edit the admin form field presentation: https://docs.djangoproject.com/en/5.2/intro/tutorial07/

SpaceBus Primitive Views
Focusing on two views:
- Overall bus that displays it and its passengers
- Has a form to converse with all passengers
- Specific passenger view for looking at a passenger
- Has a form to converse with specific passenger
Add app views at thebus/views.py
such as:
from django.shortcuts import render
from django.http import HttpResponse
# https://docs.djangoproject.com/en/5.2/ref/models/querysets/
def index(request):
buses = SpaceBus.objects
return HttpResponse("Index of all active buses: %s count" % buses.count(
def bus(request, bus_id):
bus = SpaceBus.objects.get(id = bus_id)
return HttpResponse("View bus %s: %s" % (bus_id, bus))
def passenger(request, passenger_id):
passenger = Passenger.objects.get(id = passenger_id)
return HttpResponse("View passenger %s: %s" % (passenger_id, passenger))
Then add the url for the view at app/urls.py
:
from django.urls import path
from . import views
urlpatterns = [
path('', views.index, name='index'), # lists buses
path('bus/<int:bus_id>', views.bus, name='bus'), # single bus view
path('passenger/<int:passenger_id>', views.passenger, name='passenger'), # single passenger view
]
Then include the app urls in the main project urls with:
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path('admin/', admin.site.urls),
path('spacebus/', include("thebus.urls")),
]
At this stage we have our three primitive views that allow us to view the structures we have established.
We want to now extend these into template views with navigation between the various views.
SpaceBus Templating
Templates are put into an app subfolder named templates/thebus
.
We created a base template to inherit from for our main html structure: shell.html
<html>
<head>
<title>SpaceBus Enterprise</title>
</head>
<body>
{% block body %}
No Body
{% endblock %}
</body>
{% block postbody %}
{% endblock %}
</html>
We then updated the views (controllers) to pull data and pass to templates:
from django.shortcuts import render
from django.http import HttpResponse
from .models import *
# https://docs.djangoproject.com/en/5.2/ref/models/querysets/
def index(request):
buses = SpaceBus.objects.all()
return render(request, "thebus/index.html", {
"buses": buses
})
def bus(request, bus_id):
bus = SpaceBus.objects.get(id = bus_id)
passengers = Passenger.objects.filter(bus_id = bus.id)
return render(request, "thebus/bus.html", {
"bus": bus,
"passengers": passengers,
})
def passenger(request, passenger_id):
passenger = Passenger.objects.get(id = passenger_id)
return render(request, "thebus/passenger.html", {
"passenger": passenger
})
index.html
{% extends "thebus/shell.html" %}
{% block body %}
Index of all buses:
<ul>
{% for bus in buses %}
<li>
<a href="{% url 'bus' bus.id %}">{{ bus }}</a>
</li>
{% endfor %}
</ul>
{% endblock %}
bus.html
{% extends "thebus/shell.html" %}
{% block body %}
Bus {{ bus }}
<ul>
{% for passenger in passengers %}
<li>
<a href="{% url 'passenger' passenger.id %}">{{ passenger }}</a>
</li>
{% endfor %}
</ul>
{% endblock %}
passenger.html
{% extends "thebus/shell.html" %}
{% block body %}
Passenger {{ passenger }}
{% endblock %}
At this stage we can navigate in and around the site in a basic way.

Open AI Integration
We now have a basic Django site with our models which we can manage in the admin, and views which can let us see our models. Its now time to begin integrating OpenAI so that we can have conversations with our passengers.
Our first pass will be to simply add a form to interact with a passenger in a one on one basis. We will take the stored description of the passenger and the backstory and use it as the prompt introduction to ChatGPT whilst also introducing the player from the perspective of the passenger marked as player.
Integrating OpenAI
Obviously we will be integrating with the official OpenAI Python SDK.
https://platform.openai.com/docs/libraries?language=python
I stored my openai key in a local file then bootstrapped a basic prompt into the primary controller to see if it worked:
pip install openai
from openai import OpenAI
def get_openai_key():
f = open("/home/doomguy/source/spacebus/openai_key")
key = f.read().strip()
f.close()
return key
def index(request):
# test we can connect and perform a basic prompt
client = OpenAI(
api_key = get_openai_key()
)
prompt = "Give me a short, inspiring space travel quote."
response = client.responses.create(
model = "gpt-5-nano",
#instructions = "You are a coding assistant that talks like a pirate.",
input = prompt,
)
buses = SpaceBus.objects.all()
return render(request, "thebus/index.html", {
"buses": buses,
"prompt": prompt,
"response": response.output_text,
})
It output:
We travel to the stars to discover how far wonder can carry us.
Agentic Flow
In order to move beyond a basic prompting system we need to incorporate two main things:
- Conversational history to ensure talking with a passenger doesn't reset on each prompt.
- Designating developer instructions for the AI agent to follow so that prompts can come from a roleplay perspective.
Our first iteration will cover the conversational requirement and we will focus on the Passenger view in order to test this. We will create a form on the passenger page that will let us supply a conversation prompt and output the AI response on page POST.
These prompts will need to ensure they retain the previous prompts and responses to ensure the conversation evolves and doesn't reset on each load. We can do this in fews ways using OpenAI:
- We supply the input as an array instead of string with each element having a role and the related string. The AI agent will see this sequentially growing array as historical conversation context.
- We create a
conversation
with our initial prompt and supply the conversation ID into successive prompts and allow OpenAI to retain the session state of conversation on their side. - After each prompt and response we take the response ID and supply it as a parameter to the next prompt.
The second option seems the most robust and we will use it.
Alongside this we must also designate "roles" by which to orchestrate the conversation.
- The
developer
role allows us to supply prompts instructions on how the AI should behave in the conversation. These instructions supercede any requests of theuser
. - The
user
role is the roleplaying portion of our user where we supply prompts acting as the player passenger on the bus. - The
assistant
role is the AI agent and are designated only on the response output. These can be resupplied in the conversation model we discussed previously.
Passenger Form
We namespaced the URLs to make the convention properly used by adding app_name = "thebus"
to the urls.py
file
and then for templatized urls we prefix the view name with it, e.g. {% url 'thebus:passenger' ... %}
On the passenger template we add the form to submit to a dedicated view which can submit the user's prompt, will initiate the open ai call and then return and print the response on the page.
The first pass is a basic form that submits the prompt and queries Open AI for a non-conversational response that has an instruction set for a generic alien (not incorporating our alien backstory for the passenger).
Updated passenger.html
template:
{% extends "thebus/shell.html" %}
{% block body %}
<p>
Passenger {{ passenger }}
</p>
<form method="POST" action="{% url 'thebus:passenger' passenger.id %}">
{% csrf_token %}
<div>
Prompt:<br>
<textarea name="prompt" style="width: 500px; height: 200px;">{{ prompt }}</textarea>
</div>
<button type="submit">Submit</button>
</form>
{% if response %}
<div>
Response:
<p>{{ response }}</p>
</div>
{% endif %}
{% endblock %}
Updated passenger
view:
def passenger(request, passenger_id):
passenger = Passenger.objects.get(id = passenger_id)
prompt = request.POST.get("prompt", "")
response = ""
if (prompt):
client = OpenAI(
api_key = get_openai_key()
)
result = client.responses.create(
model = "gpt-5-nano",
instructions = "You are an alien bus driver that is belligerent with passengers but answers questions honestly and bluntly. Keep answers short.",
input = prompt
)
response = result.output_text
return render(request, "thebus/passenger.html", {
"passenger": passenger,
"prompt": prompt,
"response": response,
})

We can then extend the instructions to include our passenger race and backstory:
instructions = "You are an alien traveling on a space bus across the galaxy.\n"
instructions += "Your race is: " + passenger.race.name + ".\n"
instructions += "Your race description is: " + passenger.race.description + "\n"
instructions += "Your personal description is: " + passenger.backstory + "\n"
client = OpenAI(
api_key = get_openai_key()
)
result = client.responses.create(
model = "gpt-5-nano",
instructions = instructions,
input = prompt
)
response = result.output_text
The instructions for our Gungo are now:
You are an alien traveling on a space bus across the galaxy.
Your race is: Gungo.
Your race description is: Blue, hairy, six-armed race that are boisterous drinkers and bruisers. They often act as mercenaries and traders. They are blunt in their personality and generally rude.
Your personal description is: Trader going to a coconut and exotic fruit conference.
And when prompted with "How are you doing today?" we have a new response:
Doing great, thanks. The space bus is humming, my six arms are juggling orders, and I’m bound for a coconut and exotic fruit conference. Blunt, boisterous, and always ready to haggle—how can I help you today? Looking for coconuts, rare fruits, or a trade route suggestion?
AI Conversation Loop
We can now extend the form to create a conversation model and resubmit its id along with every prompt in order to facilitate an actual conversation with the passenger.
Updated view:
def passenger(request, passenger_id):
passenger = Passenger.objects.get(id = passenger_id)
prompt = request.POST.get("prompt")
conversation_id = request.POST.get("conversation_id")
response = None
past_items = []
if (prompt):
instructions = "You are an alien traveling on a space bus across the galaxy.\n"
instructions += "Your race is: " + passenger.race.name + ".\n"
instructions += "Your race description is: " + passenger.race.description + "\n"
instructions += "Your personal description is: " + passenger.backstory + "\n"
client = OpenAI(
api_key = get_openai_key()
)
# if no conversation, start one
# else continue it
if not conversation_id:
conversation_id = client.conversations.create(
items = [
{
"role": "developer",
"content": instructions
}
]
).id
# get chat history
items = client.conversations.items.list(conversation_id, limit=30)
for item in items.data:
past_items.append(item)
# send the new prompt with the new or existing conversation
result = client.responses.create(
model = "gpt-5-nano",
input = prompt,
conversation = conversation_id,
)
response = result.output_text
return render(request, "thebus/passenger.html", {
"passenger": passenger,
"prompt": prompt,
"response": response,
"conversation_id": conversation_id,
"past_items": past_items,
})
Updated template:
{% extends "thebus/shell.html" %}
{% block body %}
<p>
Passenger {{ passenger }}
</p>
<p>
Conversation: {{ conversation_id }}
</p>
<form method="POST" action="{% url 'thebus:passenger' passenger.id %}">
{% csrf_token %}
<div>
Prompt:<br>
<textarea name="prompt" style="width: 500px; height: 200px;">{{ prompt|default_if_none:"" }}</textarea>
</div>
<input type="hidden" name="conversation_id" value="{{ conversation_id|default_if_none:"" }}">
<button type="submit">Submit</button>
</form>
{% if response %}
<div>
Response:
<p>{{ response }}</p>
</div>
{% endif %}
{% for item in past_items %}
<p>{{ item.role }}</p>
{% for content in item.content %}
<p>{{ content.text }}</p>
{% endfor %}
<!-- <pre>{{ item }}</pre> -->
{% endfor %}
{% endblock %}
This version creates the conversation and also pulls the log to present it on the page.

AFTERTHAT TODO
- openai integration research
- form to interact between you and passengers
UNRELATED TODO
- websockets details from tOSty project for PHP
- and likely for python