Local, self-hosted character roleplay chatbot. Dispatches each chat to a backend you choose — your own OpenScenes Agent (a standalone llama-cpp server, BYOM) or a cloud provider like OpenRouter, Together AI, Groq, or Mistral (BYOK) — chosen per chat. Your providers, your models, your data: no platform lock-in, no ads, no middleman.
User manual — install OpenScenes and start chatting, no coding required:
- Requirements
- Setup
- First run
- Configuring providers (BYOK)
- Creating a character
- Chatting
- Managing users and personas
- Configuration
Technical documentation — architecture, API, and development, for self-hosters and contributors:
OpenScenes lets you create characters with a persona, a scene context, and an opening line, then chat with them through a browser. Conversation history, characters, personas, chat styles, per-user LLM labels, and connection credentials all live as plain JSON / markdown files in database/ on your disk.
Inference itself lives out of process. The main app is provider-agnostic — it speaks HTTP to either:
- OpenScenes Agent — a sibling project (
../agent/) that wrapsllama-cpp-pythonand serves local GGUF weights. Runs anywhere, talks back over a tiny HTTP contract. Use it for fully-local, offline inference on your own hardware. - A cloud provider — OpenRouter, Together AI, Groq, or Mistral, each an OpenAI-compatible endpoint reached with your own API key. Providers are defined in a registry, so the list is easy to extend.
You pick per chat. Credentials are per-user (BYOK) and never leave your server except in outbound calls.
The
database/tree contains your chats, personas, API keys, and per-user LLM lists. The repo's.gitignoreexcludes it — keep it that way.
Everything a user needs to get OpenScenes running and start chatting. No coding — just two commands and a provider key.
- Python 3.10+
- Node.js and npm (any recent LTS)
- Either a cloud provider API key (OpenRouter, Together AI, Groq, or Mistral), or the OpenScenes Agent installed and running (see OpenScenes Agent).
cd source
python setup.pyThat creates a virtualenv at core/env/, installs the backend requirements (just Flask + flask-cors + requests now — no llama-cpp here), and builds the React client.
If you want local inference, also install the agent — its setup is documented in https://github.com/WassBe/openscenes-agent/blob/main/README.md.
python start.pyFlask starts on http://localhost:8080 and serves both the API and the built client from that one address. Open it in your browser. Press Ctrl+C to stop. No separate Node process runs at this point — the client was already built during setup.
Before sending your first message, link at least one provider in Settings → Connections:
- OpenScenes Agent — paste the agent's URL (e.g.
http://127.0.0.1:8090). If the agent has anAPI_KEYin itsconfig.ini, paste that too; leave blank otherwise. - A cloud provider (OpenRouter, Together AI, Groq, Mistral) — paste your API key for that provider. Each section appears automatically for every provider in the registry.
API keys are masked in every response (…last4 preview plus a has_api_key flag) and only travel in the outbound call to the provider.
Then register at least one LLM in Settings → LLMs. Each entry has a Name (a free display label, e.g. Claude 3.5 (OpenRouter)), a Model (the identifier sent to the provider), and a Provider:
- Provider: OpenScenes Agent — the model is the LLM name registered with your agent (e.g.
Meta-Llama-3.1-8B-Instruct-Q4_K_M). - Provider: a cloud provider — the model is that provider's model id (e.g.
anthropic/claude-3.5-sonneton OpenRouter).
Because the display name is separate from the model id, you can register the same model under several providers and tell them apart in the chat picker.
- Go to Manage → Chat styles and create a style: name, description, and a markdown rules file that tells the model how to behave. Each style has a duplicate button so you can branch variants. Chat styles are pure rules wrappers and don't reference any LLM — the LLM is picked per chat.
- Go to Manage → Characters and create a character:
- Name — how the character is referred to everywhere.
- Description — the short blurb shown in lists.
- Persona — a markdown document describing who the character is.
- Context — where the scene takes place.
- Opening message — the first line the character says. Optional.
- Picture — an avatar. Optional; PNG/JPG/GIF/WEBP.
Persona, context, and opening support {character} and {user} placeholders that get replaced with real names at chat time.
Go to Home or Search, pick a character, click Start chat. The chat opens with the character's opening line if one was set.
In the chat header, pick a chat style and an LLM before sending your first message. Both choices persist per chat — you can change them at any time without losing history.
Per-message actions:
- Edit — rewrite the last message of a role without regenerating anything.
- Retry — ask the model for another response to the same prompt. Saved as a variant; use
‹ ›to flip between them. - Continue — send with an empty composer to have the model keep writing.
- Rewind — truncate the history back to a chosen message.
- Delete — remove a single message.
Click your name (or "Guest") in the navbar to open the session panel. Create users, rename them, upload profile pictures, and manage personas.
A persona is the role you play in the scene — name, description, and a markdown document the model receives alongside the character's prompt. If you have multiple personas, a selector appears in the chat header.
Each user has their own LLM labels and Connections credentials — switching users switches both.
source/config.ini controls where the database lives and what addresses the backend and client listen on:
DATABASE=./database
CORE_ADDRESS=localhost
CORE_PORT=8080
CLIENT_ADDRESS=localhost
CLIENT_PORT=5173Per CONVENTION.md §Sensitive data, no API keys live here — those are stored per-user inside the gitignored database/ tree.
How OpenScenes is built, its HTTP API, and how to run it in development. For self-hosters and contributors — skip this if you just want to chat.
source/
├── config.ini — addresses, ports, database path
├── setup.py — installer (venv + deps + client build)
├── start.py — launcher (runs the backend, which also serves the built client)
├── api-routes.html — rendered API reference
├── core/ — Flask backend (no local inference)
├── client/ — React 19 + Vite frontend
└── database/ — filesystem-backed storage
├── styles/ — chat-style presets (index + one .md per style)
├── characters/ — character data (index + one dir per character)
└── users/ — user data
└── <dir>/ — user.json, chats.json, settings.json, llms.json, personas/
| File | Role |
|---|---|
main.py |
All Flask routes — chats, users, personas, characters, chat styles, per-user LLMs and settings |
girlfriend.py |
Prompt assembly, HTTP dispatch to the Agent or OpenRouter, chat history I/O |
config.py |
Reads config.ini and exports the resolved constants |
requirements.txt |
flask, flask-cors, requests |
Every data collection is stored as an index file shaped {"next_id": int, "items": [...]}. IDs are monotonically increasing and never reused.
React 19 + Vite + react-router-dom 7. Built output goes to client/dist/ and is served by Flask (the backend) when launched through start.py — no separate Node process runs at runtime.
client/src/
├── main.jsx — entry point, providers, route tree
├── App.jsx — shell (navbar, sidebar, page slot)
├── context/
│ ├── AppContext.jsx — global state: users, characters, chat styles, chats
│ └── ThemeContext.jsx — light/dark theme, persisted to localStorage
├── components/
│ ├── Avatar.jsx
│ ├── Icon.jsx
│ ├── Navbar.jsx
│ ├── Sidebar.jsx
│ └── SessionModal.jsx
└── pages/
├── Home.jsx
├── Search.jsx
├── Character.jsx
├── Manage.jsx — shared admin (Characters + Chat styles)
├── Settings.jsx — per-user (LLMs + Connections)
├── chat/Chat.jsx — chat room
├── manage/
│ ├── CharactersTab.jsx
│ └── StylesTab.jsx
└── settings/
├── LlmsTab.jsx
└── ConnectionsTab.jsx
All storage is plain files — no database engine. Each resource lives in a randomly-named directory to avoid collisions, with an index JSON at the collection root:
users/users.json+users/<dir>/— each user dir holdsuser.json,chats.json,settings.json(Connections: OpenRouter key + Agent address/key, gitignored),llms.json(this user's LLM labels), andpersonas/.characters/characters.json+characters/<dir>/— each character dir holdscharacter.json,persona.md,context.md,opening.txt, and optionally a picture.styles/styles.json+ one.mdper chat style.
All endpoints are under /api. Every response is JSON. Errors come back as {"error": "..."}.
For the full rendered reference open source/api-routes.html in a browser.
| Method | Path | Description |
|---|---|---|
| GET | /api/chats/<user_id> |
List all chats for a user |
| POST | /api/chats/<user_id>/start |
Start or return a chat for a character |
| GET | /api/chats/<user_id>/<chat_id> |
Get paginated history (?before=<id>&limit=<n>) |
| PUT | /api/chats/<user_id>/<chat_id> |
Replace the full history |
| DELETE | /api/chats/<user_id>/<chat_id> |
Delete a chat |
| PUT | /api/chats/<user_id>/<chat_id>/style |
Set the chat style |
| PUT | /api/chats/<user_id>/<chat_id>/llm |
Set the LLM |
| POST | /api/chats/<user_id>/<chat_id>/rewind/<message_id> |
Truncate at a message |
| PUT | /api/chats/<user_id>/<chat_id>/messages/<message_id> |
Edit a message |
| DELETE | /api/chats/<user_id>/<chat_id>/messages/<message_id> |
Delete a message |
| Method | Path | Description |
|---|---|---|
| POST | /api/chat/send |
Generate a reply and append it to history |
Body fields: user_id, chat_id, character_id, persona_id, style_id, message (string), new_attempt (bool), continue_mode (bool). The chat's llm_id is read server-side.
| Method | Path | Description |
|---|---|---|
| GET | /api/users |
List all users |
| POST | /api/users |
Create a user (username) |
| PUT | /api/users/<id> |
Rename a user |
| DELETE | /api/users/<id> |
Delete a user and all their data |
| GET / PUT / DELETE | /api/users/<id>/picture |
Profile picture |
| GET / PUT | /api/users/<id>/settings |
Connections (per-provider API keys, Agent address + optional key). Keys masked in responses. |
| GET | /api/users/<id>/llms |
List the user's LLM entries ({id, name, model, provider}) |
| POST | /api/users/<id>/llms |
Create (name, model, provider ∈ {agent, openrouter, together, groq, mistral}) |
| GET / PUT / DELETE | /api/users/<id>/llms/<llm_id> |
Get / edit (name?, model?) / delete one entry |
| Method | Path | Description |
|---|---|---|
| GET | /api/providers |
List supported providers (registry + agent) for the UI |
| Method | Path | Description |
|---|---|---|
| GET | /api/users/<user_id>/personas |
List personas |
| POST | /api/users/<user_id>/personas |
Create (name, description, content) |
| GET / PUT / DELETE | /api/users/<user_id>/personas/<id> |
Get / edit / delete |
| Method | Path | Description |
|---|---|---|
| GET | /api/characters |
List all characters |
| GET | /api/characters/<id> |
Get character + persona / context / opening |
| POST | /api/characters |
Create |
| PUT | /api/characters/<id> |
Edit |
| DELETE | /api/characters/<id> |
Delete (cascades to every chat referencing it) |
| GET / PUT / DELETE | /api/characters/<id>/picture |
Picture |
| Method | Path | Description |
|---|---|---|
| GET | /api/styles |
List all chat styles |
| GET | /api/styles/<id> |
Get style + rules markdown |
| POST | /api/styles |
Create (name, description, rules) |
| PUT | /api/styles/<id> |
Edit |
| POST | /api/styles/<id>/duplicate |
Clone under a unique <name> (copy) |
| DELETE | /api/styles/<id> |
Delete |
Contributions are welcome — bug fixes, new features, improvements to the docs. Please read CONTRIBUTING.md before opening a pull request.
Backend — Flask directly with file watching:
cd source/core
pip install -r requirements.txt
CHATBOT_DEBUG=1 python main.pyFrontend — Vite dev server with HMR, proxies /api to the Flask backend:
cd source/client
npm install
npm run devThe dev server reads the target backend URL from ../config.ini via vite.config.js. Both servers must be running.
Agent (if you want local inference) — see ../agent/README.md for its own setup, its wire contract, and the move-test that proves it's relocatable.