Inside
Kreo
A picture book of how Kreo-Agent works today: every layer, every flow and every file.
Edition of 26 September 2026 · written for newcomers to the codebase · MTS
How to read this book
Each spread has two pages. The left page tells you what is going on in plain words. The right page shows it as a picture. If you only look at the pictures, you will still understand most of Kreo.
kreo/, 8,133 linesfrontend/src, 6,250 linesContents
Kreo in one picture
Kreo is a personal assistant that lives on your Windows PC. It is a Python program that keeps running in the background. It has its own AI model, its own voice and its own memory.
You talk to it through a client: the web page at http://127.0.0.1:8765, or the terminal command kreo chat. The client is only a window. The thinking, speaking, listening and remembering all happen inside the agent.
The AI model runs as a separate program, llama-server.exe, on port 8766. Kreo starts it, talks to it over HTTP and stops it again.
Kreo only goes on the internet when a question needs it: the weather, a web search, or finding your location if you allow that.
Two clients, one brain
The browser andkreo chat use the same WebSocket and get exactly the same answers.A model you own
Qwen3-4B, a 4-billion-parameter model compressed to 4-bit (Q4_K_M), runs on the GTX 1650 and CPU.Six layers, like a restaurant kitchen
The code is stacked in layers. Each layer only talks to the layer next to it. If you picture a small restaurant, every piece has an obvious job.
| In the kitchen | In Kreo | Folder |
|---|---|---|
| Diners at the table | Clients (web page, terminal) | frontend/ kreo/cli/ |
| The waiter who takes orders | API routes and the WebSocket | kreo/api/ |
| The head chef who decides every step | The orchestrator | kreo/core/agent/ |
| A clever apprentice who only fills in order slips | The AI model | kreo/llm/ |
| The kitchen stations | Tools: tasks, weather, search | kreo/core/tools/ |
| The kitchen timer | The scheduler | kreo/core/scheduler/ |
| The ledger and the pantry | SQLite, config, logs | kreo/database/ config/ |
frontend/srcTerminal chat kreo/cli/chat.pykreo commands kreo/__main__.pyapi/app.py10 route files api/routes/WebSocket api/ws.pyRuntime api/state.pyagent/orchestrator.pyRuns agent/runs.pyPermissions permissions/Scheduler scheduler/llm/providers/llama-server runtime llm/runtime/Piper voice, Whisper ears core/speech/database/Settings config/Logs logs/Read top to bottom for a request coming in, bottom to top for the answer going out.
The golden rules
A handful of rules shape every file. Once you know them, most of the design stops looking strange.
Small AI models are fluent but unreliable. A 4-billion-parameter model will happily invent a temperature, add a conversation that does not exist, or get clock arithmetic wrong. Kreo is built so that those mistakes cannot turn into actions.
The rules on the right are written in CLAUDE.md at the root of the repo. Each one exists because something went wrong without it.
The model fills in forms
It only returns structured JSON. Python reads it and decides what happens.core/agent/schemas.pyEvery figure comes from Python
Temperatures, sunrise times and search results are computed or fetched, then handed to the model to phrase.core/tools/*.pySpeech belongs to the agent
Kreo speaks with Piper and listens with faster-whisper on the agent's machine. The browser voice is only a fallback for a remote client.core/speech/Clients attach, never start
kreo chat connects to the running agent. It never starts a second copy with its own model.cli/chat.pyOne port: 8765
If the port is taken, Kreo reports it and exits with code 2. It never quietly moves.__main__.py · port_is_freeSensitive actions ask first
They pass through the PermissionEngine and leave an AuditLog row.core/permissions/engine.pyDeleting needs a "yes" read by Python
The model can never delete by sounding confident.tools/conversations.py · is_affirmativeSecrets never touch SQLite
Credentials go to Windows Credential Manager throughkeyring.planned for integrationsWho does what: the model or Python?
This is the most important split in the whole project. The model is used for language. Python is used for everything that must be right.
Structured output means the model is not allowed to write free text for this step. llama-server receives a JSON schema and turns it into a grammar, so the model can only produce JSON that fits the form.
On the right, follow one real request. The model's only job is to fill in two small forms and then write one friendly sentence. Python checks the words, works out the time and writes the task.
The model does
- Classify the message into an intent
- Pull out fields: title, day, place
- Phrase the final reply
- Think privately first (reasoning)
Python does
- Override the intent when it knows better
- Work out times like "in 30 minutes"
- Check permissions, write the audit row
- Fetch weather, compute sunrise, search
- Save, edit and delete in SQLite
- 1You type "Remind me to stretch in 30 minutes" at 14:00.
- 2Model fills form 1: ParsedIntentparse_intent
{ "intent": "create_reminder", "entity": "stretch", "priority": "normal", "confidence": 0.9 } - 3Python checks the wordsasks_for_written_contentNot a request to write something, so it stays a reminder. Permission
create_reminderis AUTO. - 4Model fills form 2: TaskExtraction_create_task_for
{ "title": "Stretch", "due_at": "2026-09-26T14:20:00", "announce": "speak" } ← the model got the time wrong - 5Python fixes the timeparse_relative_durationIt reads "in 30 minutes" from your own words and sets 14:30, overriding the model. Small models cannot do clock arithmetic.
- 6Task #12 is savedcreate_taskStored in UTC in
tasks, kind "reminder". - 7Model phrases the reply"Done. I'll remind you to stretch at 14:30."
The folder map
The repo root is the project root. The Python backend lives in kreo/ and the web client in frontend/.
- 1CLAUDE.mdThe rules, and the traps found the hard way.
- 2kreo/__main__.pyThe
kreocommand and how the agent starts. - 3kreo/api/app.pyHow every service is built and wired together.
- 4kreo/core/agent/orchestrator.pyThe brain: 886 lines that handle every chat turn.
- 5frontend/src/ChatContext.tsxHow the browser sends a message and shows the stream.
config/kreo.json, data/kreo.db, logs/, models/ and bin/ live on your machine only. The example config, config/kreo.example.json, is the template.
Kreo-Agent/ ├─ kreo/ Python backend (the agent) │ ├─ __main__.py the `kreo` command │ ├─ api/ FastAPI app, routes, WebSocket │ ├─ core/ │ │ ├─ agent/ orchestrator, schemas, prompts, runs │ │ ├─ tools/ tasks, weather, sunrise, search… │ │ ├─ speech/ Piper voice, Whisper ears, chimes │ │ ├─ scheduler/ reminders every 20 s │ │ ├─ permissions/ AUTO / ASK / NEVER │ │ ├─ resources/ CPU, RAM, GPU snapshot │ │ └─ memory/ integrations/ placeholders │ ├─ llm/ model providers + llama-server runtime │ ├─ database/ SQLite tables, log sink │ ├─ config/ typed settings, logging │ ├─ cli/ terminal chat client │ ├─ windows/ toasts, start with Windows │ └─ assets/sounds/ marimba, chime, ping ├─ frontend/ React + TypeScript + Vite │ └─ src/ pages/ components/ styles/ ├─ bin/llama.cpp/ official llama-server.exe + DLLs ├─ models/ Qwen3-4B GGUF, Piper voice ├─ config/ kreo.json (yours), kreo.example.json ├─ data/kreo.db the SQLite database ├─ logs/ kreo.log, llama-server.log ├─ tests/ 32 files, run with pytest ├─ benchmarks/ Stage 0 model benchmark ├─ scripts/ kreo.cmd, kreo.ps1, install-path.ps1 ├─ initial-docs/ PRD, TRD, stages, design └─ overview/ this book
What happens when you run kreo run
The kreo command is a small launcher. Only run starts the agent. Every other subcommand talks to an agent that is already running, over HTTP.
| Command | What it does |
|---|---|
kreo run (or just kreo) | Starts the agent. --no-model skips the AI, --open opens the browser, --reload is for development. |
kreo stop | Finds whoever holds port 8765 and asks it to stop. --force kills it after 10 s. |
kreo status | Prints GET /api/status. --json for raw. |
kreo chat | Terminal chat on the same WebSocket. --voice for hands-free. |
kreo listen | Listens once on the agent's microphone and prints the text. |
kreo model on|off|toggle | Resume or suspend the AI model. |
kreo reasoning on|off|toggle | Turn the model's thinking step on or off. |
kreo tasks | Lists tasks. --status open|all|todo|done|failed. |
kreo autostart on|off|status | Start with Windows, through Task Scheduler. Works without a running agent. |
kreo open [path] | Opens the web client in your browser. |
kreo where | Prints where the config, database, model and logs live. |
scripts/kreo.cmd and kreo.ps1 run .venv\Scripts\python.exe -m kreo, and install-path.ps1 puts them on your user PATH so kreo works from any folder.
- 1Read the arguments__main__.build_parser
- 2Load the configload_settingsBuilt-in defaults, then
config/kreo.json(or the example file), thenKREO_*variables. - 3Set up loggingsetup_loggingConsole plus
logs/kreo.log, rotating at 5 MB × 5. - 4Is port 8765 free?port_is_freeNo: print "run kreo stop" and exit with code 2. Never move to another port.
- 5Build the web appcreate_app10 routers under
/api, the/wssocket, and the built client fromfrontend/dist. - 6uvicorn starts serving, and the lifespan runs:Databaseinit, WAL→LogSinklogs → DB→ModelManager→PermissionEngine→Orchestrator→RunRegistry→Scheduler
- 7Pack them into the Runtimeapp.state.runtime
- 8Start the schedulerReminders are checked every 20 s from now on.
- 9Load the modelmodels.load("startup")Starts llama-server. If it fails, Kreo logs a warning and keeps going without a model.
- 10Warm up the earsLoads Whisper in the background so the first "listen" is quick.
- 11RUNNING"Kreo-Agent ready on http://127.0.0.1:8765"
The Runtime: one box of shared services
Every service is created once, at start-up, and put into a single object called the Runtime. Any route that needs something takes it from there.
The Runtime lives at app.state.runtime. A route asks for it with FastAPI's dependency system, Depends(get_runtime) in api/deps.py, then uses rt.agent, rt.db and so on.
This means there is exactly one model manager, one database and one orchestrator, however many browser tabs or terminals are connected.
PAUSED and SLEEPING are waiting for the tray app (Stage 6) and the resource manager (Stage 7).
rt.settings is the live config. The Settings page, Permissions page and kreo autostart change it in memory only. Nothing writes config/kreo.json back, so a restart brings the file's values back.
kreo/api/state.py. Colours follow the key: violet for Kreo's own logic, orange for the model, brown for storage.The model engine: llama-server
The AI model does not run inside Kreo's Python process. Kreo starts the official llama-server.exe as a child program and talks to it over HTTP, on 127.0.0.1:8766.
- Crashes. The ready-made
llama-cpp-pythonwheels are AVX-512 builds. They crash this Ryzen 5 4600H. - Security. Smart App Control is on, so unsigned DLLs without reputation are blocked. The official build is trusted.
- Memory. Killing a process always frees the GPU memory (VRAM). That matters when a game needs the GPU.
llama-server.exe -m models/Qwen3-4B-Q4_K_M.gguf -ngl 20 -c 4096 -t 6 --host 127.0.0.1 --port 8766 -fa auto -np 1 --jinja --no-webui --reasoning-budget 640
-ngl 20 | Put 20 layers of the model on the GPU, the rest on the CPU |
-c 4096 | Context window: how many tokens it can see at once |
-np 1 | One slot, so one request at a time |
--jinja | Use the model's own chat template |
--reasoning-budget 640 | Cap on thinking tokens, so there is room left to answer |
GET /health every second until {"status":"ok"} (240 s limit), then chat over POST /v1/chat/completions. 3 assign(pid) puts the server in a job object, so Windows kills it if Kreo dies. No orphan keeps holding the VRAM.build_provider(settings) decidesllama_server default
The child-process design above.llm/providers/llama_server.pyllama_cpp do not use here
Runs the model inside Python. Crashes this CPU.llm/providers/llama_cpp.pymock tests
Replies "[mock] You said: …". Tests never need a model.llm/providers/mock.pyModel states and the provider contract
The model can be switched off to free the GPU while the rest of Kreo keeps running. The ModelManager tracks where it is in its life.
One lock means two clicks on the model switch can never race. Every change of state calls the listeners, and one listener writes a ResourceEvent row with a CPU, RAM and GPU snapshot. You can see these on the Activity and Developer pages.
| Method | Used for |
|---|---|
load() / unload() | Start or stop the model |
generate(messages, thinking=…) | Whole reply at once. Retries with thinking off if the answer comes back empty |
stream(messages, thinking=…) | Reply piece by piece as StreamDelta(text, reasoning) |
structured_generate(messages, Schema) | JSON that must fit a Pydantic form. Thinking off, temperature 0 |
embed(texts) | Vectors for search. Not built yet (Stage 3) |
thinking is passed on every call rather than stored on the provider, because one provider is shared by every request in flight.
kreo model on|off, and POST /api/model/toggle all call toggle(), which flips between LOADED and SUSPENDED.The rest of Kreo only ever talks to the contract, so a different engine could be swapped in without touching the orchestrator.
A message's journey
This part is the heart of the book. We follow one message from your keyboard to Kreo's reply, through every file it touches.
There are two ways in. Clients normally use the WebSocket at /ws, which streams the reply as it is written. There is also POST /api/chat, which waits and returns the whole reply at once. No client uses it today, but both go through the same steps: stream() and handle() in the orchestrator.
A single turn asks the model two or three times. The first calls are quick and strict, and the last one is the friendly reply.
A "yes" or "no" answering Kreo's own question skips step 1 entirely. Python recognises it first.
- 1You press SendChatContext.sendOpens a WebSocket and sends
{"type":"chat", message, conversation_id, input_mode, record, session}. - 2The doorway accepts itws.py · websocket_endpointHands the message to
rt.runs.start(). The socket only watches from now on. - 3A Run is createdruns.py · RunRegistryThe reply becomes a background task owned by the agent, so closing the tab loses nothing.
- 4Your message is saved_ensure_conversation · _storeNew conversation if needed, titled from your first 120 characters.
- 5Quick Python checksis_farewell · _answer_to_pending_deleteDid you say goodbye? Are you answering "Shall I delete…?"
- 6The model classifies itparse_intentOne of 19 intents, as JSON.
- 7Permission checkpermissions.evaluateAUTO runs, ASK waits for approval, NEVER refuses.
- 8Python does the work_performCreates the task, fetches the weather, searches… and returns an
Outcomewith a note. - 9The note is framed_with_outcomeReference, completed action, or a sentence to say.
- 10The model writes the replyprovider.streamThinking arrives as
reasoningevents, the answer astokenevents. - 11You watch it appearIn voice mode, each finished sentence is already being spoken.
- 12Everything is stored_store · _metric · _auditReply, speed figures and audit row. Deletions run last. Then
end.
The same journey as a sequence diagram
Time runs downwards. Solid arrows are calls, dashed arrows are answers and events. The WebSocket carries small JSON events, in this order:
| Event | When | Carries |
|---|---|---|
run | Always first | run_id, so a client can reattach |
start | Conversation known | conversation_id, input_mode, recorded |
farewell | You said goodbye | Ends hands-free mode after speech |
intent | After classifying | e.g. create_reminder |
task | A task was touched | task_id, kind, action |
sources | A web search ran | Title, URL, domain, snippet |
reasoning ×n | While thinking | Pieces of the hidden thinking |
token ×n | While answering | Pieces of the answer |
conversation_deleted | After storing | ids, included_current |
end | Always last | message_id, latency_ms, tokens_per_second |
error | Anything failed | A readable message |
The docstring at the top of ws.py still leaves out sources and conversation_deleted, but both are sent.
start, one token saying the model is unavailable, end. If the permission is NEVER: intent, a refusal token, end.Understanding the message
First, the model sorts your message into one of 19 intents. It fills in a form called ParsedIntent.
| Field | Meaning |
|---|---|
intent | Which of the 19 kinds (default chat) |
entity | The thing it is about, e.g. "stretch" |
priority | low, normal, high or urgent |
requires_approval | The model's guess. Python ignores it and asks the PermissionEngine |
arguments | Extra text fields |
confidence | 0 to 1 |
unknown, which is treated as chat. Every call writes a GenerationMetric row.Tasks and reminders
tasks. An open loop becomes kind "job" for now.
Facts from the world
Conversations
Just talk
Planned
| create_reminder, create_task, create_open_loop | create_reminder |
| update_task, complete_task, cancel_task | update_task |
| delete_task · delete_conversation · web_search | same name |
| draft_reply · schedule_meeting | draft_email · schedule_meeting |
| everything else | no check needed |
The switchboard: _perform
With the intent known and the permission checked, one method decides which piece of Python runs. It always returns an Outcome.
The Outcome, a small parcel of results
@dataclass
class Outcome:
note: str | None # what the model should know
task_id: int | None # task created or changed
sources: list[dict] # web sources to cite
note_is_instruction: bool # note is a sentence to say
delete_conversations: list # ids to delete at the very end
tools/__init__.py is only a docstring. The orchestrator imports each tool module and calls its functions directly, so the model can never call a tool.
| If… | Python does | Outcome |
|---|---|---|
| ASK permission | Nothing | Instruction: "I haven't done that. It needs your approval first." |
| create_* | _create_task_for: guards → TaskExtraction → create_task | task_id, no note |
| update / complete / cancel / delete task | _edit_task_for: recent tasks → TaskAction → resolve_task → change it | task_id + fact: "Marked #4 … as done." |
| delete_conversation | _stage_conversation_delete: nothing deleted yet | Instruction: "Shall I go ahead?" |
| list_tasks · query_memory | _list_tasks_note | Fact: "The user's open tasks are: …" |
| weather · sun_times | _environment: EnvironmentQuery → environment.answer | Reference fact with real figures |
| web_search (if enabled) | _search_web: DuckDuckGo → read pages → number sources | Reference + sources |
| anything else | Nothing | Empty: the model just chats |
Framing the note
The Outcome's note is added to the prompt as one extra system message. How it is worded changes how a small model behaves, so there are three frames.
The prompt sent for the reply is: Kreo's persona (SYSTEM_TEMPLATE, with today's date and time), then recent history, then the framed note.
History is cut to 4 messages when there is a note, so the facts are not drowned out, and 20 otherwise.
Wrong frame
Search results framed as an action already done:"I performed a web search and found several results about the weather…"Right frame
Framed as reference material:"It's 18 °C and cloudy in Pune today [1]."conversations.say() and sets note_is_instruction.
Reference weather · sun_times · web_search
{note}
Answer the user's question using only the information above.
Never mention the search… State the answer as plain fact in
the first sentence.
Completed action task edits, task lists
Kreo has already carried this out: {note}
Report exactly this to the user. Do not invent any other detail.
Instruction note_is_instruction = True
Reply with exactly this, rephrased only lightly if you must: "Just to check — that will delete "Trip ideas", and it can't be undone. Shall I go ahead?" Add nothing else. Do not mention these instructions. Do not invent any other conversation, number or detail.Built by
say() and added as it stands.
Notes longer than the context budget (about 6,451 characters) are cut at a word boundary and end with "…[truncated to fit the context window]".
Writing the reply, and token budgets
A token is a piece of a word, roughly three or four characters. Every model call has a budget of tokens, and Kreo sets each one on purpose.
Qwen3 can think before answering. The thinking and the answer come out of the same budget, max_tokens = 2,048. Without a cap, the model could think until the budget ran out and answer nothing. So llama-server is started with --reasoning-budget 640.
The thinking is streamed as reasoning events. The client shows it in a folded "Thinking…" block above the answer.
thinking=FalseFor streaming this retry lives in orchestrator.stream. For whole replies it lives in LlamaServerProvider.generate.
max_tokens = 2,048 (to scale)Runs: replies that survive a closed tab
A chat turn belongs to the agent, not to the browser tab that asked for it. That work unit is called a Run.
An early version drove the orchestrator straight from the WebSocket handler. Closing the tab aborted the reply halfway, and it was never saved. Now RunRegistry.start() launches the reply as its own background task.
A Run keeps every event it has produced. Anyone who subscribes gets the full replay first, then follows live. Each subscriber sees every event exactly once.
The same idea exists on the client: the conversation lives in ChatProvider, above the router, so moving to another page does not kill the reply.
| Client sends | Meaning |
|---|---|
chat | Ask something new |
attach + run_id | Rejoin a reply. Unknown run: gone |
answer + conversation_id | Answer a question already stored but never answered (store_user=False, so it is not saved twice) |
forget + session | Drop an off-the-record thread |
ping | Server answers with status |
Inside a Run
12-character id, the prompt,conversation_id, every event so far, subscriber queues, done, error, the asyncio task.core/agent/runs.pyTidy-up
Subscribers are closed withcontextlib.aclosing. Otherwise an abandoned queue keeps filling until garbage collection.RunRegistry.eventsThe toolbelt
Tools are ordinary Python modules in kreo/core/tools/. They do the exact work: dates, databases, maths and web requests.
A tool never decides what to do. The orchestrator calls it after classifying your message. The tool returns facts, and the facts come back to the model as a note to phrase.
There are two kinds of note, and choosing the right one matters (see chapter 14):
A fact
"Marked #4 Call the bank as done.""Sunrise 06:14, sunset 18:21 … Figures from Open-Meteo."
A sentence to say
Built withsay(), with note_is_instruction = True."Just to check — that will delete…"
tasks.py 441 lines
Create, edit, complete and delete tasks and reminders. Regular expressions and Python decide what is written.no network · SQLiteconversations.py 292
Two-step deletion, "yes/no" and goodbye detection, andsay().no network · SQLiteenvironment.py 73
Answers weather and sunrise questions by combining the three tools below.coordinatorweather.py 159
Daily and current weather turned into one factual sentence.Open-Meteo forecast + archive · no keyastronomy.py 143
Sunrise, sunset, solar noon, twilight, from NOAA's solar equations.offline mathslocation.py 135
Where are you? A named place, then your home setting, then an opt-in IP lookup.Open-Meteo geocoding · ipapi.co · ip-api.com · ipwho.isweb.py 218
Search, read the top pages, lay out numbered sources for citations.DuckDuckGo (ddgs) · httpx · BeautifulSoupTasks and reminders
Every task, reminder and job is one row in the tasks table. You can create them by talking to Kreo, on the Tasks page, or with POST /api/tasks.
The API keeps started_at and completed_at in step with the status.
| Column | Values |
|---|---|
kind | task · reminder · job |
priority | low · normal · high · urgent |
due_at | When it is due, stored in UTC |
fired_at | Set once the reminder has gone off, so it never fires twice |
announcement | The sentence Kreo will say out loud |
announce_mode | speak (sound, toast, voice) · notify (sound, toast) · silent (list only) |
- 1GuardsNot a question, not a request to write something.
- 2Model fills TaskExtractiontitle, description, due_at, priority, spoken_announcement, announce.
- 3tidy_titleStrips "remind me to" and "at 19:30": the title becomes "Take the bins out".
- 4parse_relative_durationFor "in 30 seconds / minutes / hours", Python's own sum replaces the model's time.
- 5parse_dueDrops any timezone the model added, treats the time as local, converts to UTC.
- 6default_announcementIf the spoken line just repeats the title, a better one is written.
- 7create_taskRow saved. The client gets a
taskevent and shows a link.
The reply gets a fact note such as "Marked #7 Take the bins out as done." If nothing matches, Kreo says so instead of guessing.
Weather, sunrise and location
Ask "Will it rain in Manchester tomorrow?" or "When does the sun set?" Every number in the answer is fetched or computed in Python.
The model fills in a tiny form, EnvironmentQuery, with just two fields: day (an ISO date, or empty for today) and location (empty means where you are).
Sun times need no internet at all. astronomy.py uses NOAA's solar equations and handles polar day and polar night.
PATCH /api/location sets it for this run, POST /api/location/detect looks it up from your IP, and GET /api/location/sun shows today's sun times.
auto_detect is onastronomy.py
Sunrise, sunset, solar noon, day length, first and last light. Local maths, local time.weather.py
Weather code, max and min temperature, rain, max wind, sunrise and sunset; plus current temperature when the day is today.Weather for Manchester on Saturday 27 September 2026: light rain, high 16 °C, low 9 °C, 4.2 mm of rain, wind up to 24 km/h. Sunrise 07:02, sunset 18:58. Figures from Open-Meteo.
Example of the note's shape. The model is told to answer only from this, as plain fact in the first sentence. If Open-Meteo cannot be reached, the note says so and no figure is given.
Deleting conversations safely
Deleting is the one thing that cannot be undone, so it always takes two turns. The "yes" is read by Python, never by the model.
resolve_targetscurrent | This chat |
named | By number, then title, then fuzzy title (0.6). Any number you say means "named" |
all | Only if you clearly say "all" |
is_affirmativeAn exact yes-phrase, or up to 7 words starting with a yes-word and containing none of: but, not, except, instead, other, another, rather, unless, only, first, actually, wait, no. "Yes but only the old one" is not a yes.
- 1Delete that conversation's
generation_metrics - 2Detach its tasks (
conversation_id→ NULL). Tasks survive. - 3Delete its
messages - 4Delete the
conversationsrow, then write an audit row
You say "no"
"Right, I've left everything where it is."You say something else
The pending delete is dropped quietly and your message is handled normally. Kreo never asks twice.Web search with citations
When you ask about something recent, Kreo searches DuckDuckGo, reads the top pages, and answers with numbered citations such as [1].
web.pyis_public_http_urlrefuses localhost, private, loopback, link-local, reserved and multicast addresses. A web page cannot trick Kreo into reading your router or your own API.- Each page download stops at 2 MB.
- Only readable text is kept, up to 1,800 characters a page.
web group)Permission action web_search is AUTO by default. If nothing useful comes back, the note tells the model to say it could not find it.
[1] Met Office — Manchester forecast (metoffice.gov.uk)
Snippet… Page text…
[2] BBC Weather — Manchester (bbc.co.uk)
Snippet… Page text…
Cite as [1]. Answer first. No bullet points.
sources event, drawn by components/Sources.tsx. Letter icons are drawn locally; no favicon is fetched. They are also saved in the message's metadata.
Off the record
Kreo's answer to incognito mode. Flip the switch in the chat toolbar and the conversation is held in memory only. It is never written to the database.
The client sends record: false and a random session id with each message. The orchestrator creates no conversation, and the thread lives in EphemeralStore (core/agent/ephemeral.py).
When you switch back on the record, the client sends {"type":"forget"} and the thread is dropped at once. A restart wipes all of them.
The whole chat turns graphite, so you cannot forget which mode you are in.
| Thing | Normal | Off the record |
|---|---|---|
| Conversation row | ✓ saved | ✗ none |
| Your messages and Kreo's replies | ✓ saved | ✗ memory only |
| Tasks and reminders you ask for | ✓ saved | ✓ saved: you asked for them |
| Audit row for a sensitive action | ✓ with your words | ✓ reason replaced by "off the record" |
| Speed metric (no content) | ✓ | ✓ |
Prompt shown in /api/chat/active | ✓ | ✗ hidden |
| Can be reattached after a refresh | ✓ | only within the session |
The scheduler: reminders that fire on time
Kreo keeps working with no browser open. A background timer, APScheduler, checks for due reminders every 20 seconds. It never calls the AI model.
due-tasks · every 20 s
Find tasks that are due now, not fired yet, still open, and no more than 2 days late.check_due_tasksprune-tasks · every 30 min
Remove old finished tasks (chapter 18).prune_finished_tasksBoth run with max_instances=1 and coalesce=True, so a slow run never piles up behind itself.
fired_at is set and the Notification row is written in the same transaction. Even if something crashes during delivery, a reminder can never fire twice.
- 1Mark fired + add a NotificationOne transaction:
level="reminder",source="scheduler". - 2Windows toastWith an "Open Kreo" button to
/tasks, ifnotifications.enabled. Result saved asdelivered/delivery_error. - 3Quiet hours or silent?Then stop here.
- 4Play the chimemarimba (default), chime or ping, made by
scripts/make_sounds.py. - 5Speak the announcementIf the mode is
speakand spoken reminders are on: Piper reads it on the agent's speakers. - 6Tell listenersThe bell in the top bar picks up the unread count on its next status poll.
Permissions and the audit log
Every action with consequences has a permission level. The PermissionEngine looks it up before anything happens.
settings.pypermissions.defaults in kreo.jsondelete_conversation, was missing from older config files and fell back to ASK. The agent then did nothing while claiming success.
create_reminderchat · orchestratorapproval_required, approval_status: pending or not_requiredChanges made on the Permissions page are held in memory until restart. Saving them to the database is planned for Stage 12.
AUTO Kreo just does it
ASK Kreo waits for you
approval_status = pending. An approval screen comes in Stage 12.
NEVER Kreo refuses
Resource awareness
The aim: Kreo gets out of the way when your PC is busy. Start a game and the model goes to sleep. Stop playing and it comes back.
Built today: snapshot() in core/resources/state.py reads CPU, RAM, GPU, VRAM, the app in front, and the battery. You see it as meters on the Overview page, and a snapshot is saved with every model state change.
Not built yet (Stage 7): the loop that watches these numbers and suspends the model on its own. /api/status always reports ACTIVE for now, and gaming_detected is always false.
You can already free the GPU by hand: the Model switch, or kreo model off.
psutilpynvml), which gives up after 3 failed startsGetForegroundWindowResourceSettings: poll every 5 s, and wait 60 s of calm before resuming. Profiles: performance, balanced (default), resource_saver, gaming, manual.
How Kreo speaks
Kreo's voice comes out of the speakers of the machine running the agent. The browser never speaks; it only asks the agent to.
The voice is Piper, a small neural text-to-speech engine that runs on the CPU. The model is en_US-l2arctic-medium (about 77 MB). It has many speakers, and four of them are native Hindi speakers of English, which gives Kreo its Indian accent. Piper publishes no Indian English voice of its own.
If Piper is missing or fails, Kreo falls back to the Windows voice (SAPI), driven through a short PowerShell command.
| Key | Speaker id | Voice |
|---|---|---|
svbi | 2 | Female. Default for the female persona |
tni | 9 | Female, softer |
asi | 10 | Male. Default for the male persona |
rrbi | 19 | Male, deeper |
POST /api/speech/stop bumps a generation counter. Every sentence queued before the stop sees the new number and gives up. Playback is purged and any SAPI process is killed.
- 1Tokens stream inChatContextEach
tokenis pushed into theSpeechQueuewhile you read. - 2Cut into sentencesspeech.ts · SpeechQueueA sentence ends at
. ! ?followed by a space, so "3.5" stays whole. At least 16 characters. - 3Clean for speakingforSpeechRemoves
[1]citations, Markdown symbols and URLs. - 4POST /api/speech/speakOne sentence per request. It returns only when the sentence has finished playing, so sentences never overlap and need no timers.
- 5tts.speak picks the enginecore/speech/tts.pyPiper first (if installed and not set to "sapi"), otherwise SAPI.
- 6Piper renders a WAVpiper_engine.synthesiseVoice loaded once and cached. Text capped at 600 characters. File in
%TEMP%. - 7winsound plays itsounds.play_wavOn the agent's speakers. The WAV is deleted afterwards.
How Kreo listens
Listening also happens on the agent's machine. The client calls POST /api/speech/listen, and the agent records from its own microphone. The browser never records or uploads any audio.
The recogniser is faster-whisper with the base.en model (about 75 MB), running on the CPU in int8. It is loaded once, and warmed up at start-up so the first request is quick.
A lock allows one recording at a time. A second client gets "Kreo is already listening somewhere else".
When the client is on a different machine from the agent, it falls back to the browser's own recogniser. Chrome and Edge send that audio to the browser vendor, and the code says so.
Also used by the microphone button in text mode, by kreo listen, and by /mic in kreo chat.
The hands-free loop
The chat has two modes, text and voice, and you can switch in the middle of a conversation. Voice mode is a loop: listen, think, speak, listen again.
The loop lives in useVoiceLoop.ts. Instead of each step starting the next with callbacks, one rule is checked whenever anything changes. If the rule says "listen" and nothing is listening, it starts. If it says "don't", it stops.
The microphone is closed while Kreo speaks, so it cannot hear itself and answer itself.
Saying goodbye ends the session. Python spots it with is_farewell, the stream sends farewell, and the client leaves voice mode 700 ms after the last word is spoken. The model can never end a session by sounding final.
listening_availableMessages sent by voice are stored with input_mode = "voice", and the chat shows a "spoken" mark under them.
shouldListen = supported && mode === 'voice' && !busy && !speaking
KreoOrb) shows the same states: idle, listening, thinking, speaking, error.LiveVoice)The database
Everything Kreo remembers lives in one SQLite file, data/kreo.db. There are 21 tables. Eight are in use today; the rest are ready for later stages.
database/engine.py)- Async. SQLAlchemy with
aiosqlite, so a database call never blocks the chat stream. - WAL mode and foreign keys on.
- Home-made migrations. No Alembic. On start-up,
_add_missing_columnscompares the models with the file and adds any new columns. Dropping or retyping a column would need a real migration. - Full-text search. FTS5 indexes
messages_ftsandmemories_fts, kept in step by triggers. - Always UTC. SQLite stores no timezone, so everything is written in UTC and tagged on the way out by
iso_utc().
Configuration and secrets
Every setting has a typed default in kreo/config/settings.py. Your own values go in config/kreo.json, which git ignores.
If kreo.json does not exist, Kreo uses config/kreo.example.json. When you add a new setting to the code, add it to the example file too.
Environment variables start with KREO_ and use a double underscore for nesting: KREO_SERVER__PORT=9000 means server.port.
KREO_* variable only fills in keys the file leaves out.
keyring library. Email, calendar and Slack will use this.
settings.py Pydantic modelsserver
host 127.0.0.1 · port 8765ai
provider · model_path · context 4096 · gpu_layers 20 · threads 6 · max_tokens 2048 · reasoning_budget 640 · thinking · server_port 8766speech
engine auto/piper/sapi · persona · speaker · length_scale · recognise_model base.en · listen_silence 1.2 snotifications
enabled · sound · speak_reminders · quiet hours 23:00–07:00 · voice · ratepermissions
defaults: action → auto / ask / neverweb · location · tasks
search limits · home place, auto_detect · auto-prune after 24 hresources · startup
thresholds, profile · start_with_windows, open_clientdatabase · logging
data/kreo.db · level INFO, logs/Logs and performance metrics
Kreo keeps two kinds of records about itself: logs (what happened) and metrics (how fast the model was).
The text file logs/kreo.log is the source of truth. A copy of each log line also goes into SQLite, so the Developer page can search and filter them.
llama-server writes its own output to logs/llama-server.log. If the model fails to start, look there first.
Each row has latency, prompt and completion tokens, reasoning length, tokens per second, and whether it worked. The stream reports no token usage, so for streamed replies tokens are estimated as characters ÷ 4.
- 1logger.info("…")Anywhere in
kreo/. - 2Console and logs/kreo.logRotating, 5 MB × 5 files.
- 3BufferingLogHandlerKeeps up to 5,000 lines in memory. Never touches the database itself. Skips SQLAlchemy's own logs to avoid a loop.
- 4LogSink flushes every secondOne batch insert into
log_records. Every 60th flush keeps only the newest 50,000 rows. - 5Developer → Logs
GET /api/dev/logs?level&q&logger, refreshed every 5 s.
Analytics page
Ranges 1 h to 30 d. Latency and tokens-per-second charts, p50 and p95, log levels.GET /api/dev/metricsUnder each reply
Time, intent, latency, tokens per second, task link.end event · messages tableThe web client
The browser app is React 19 and TypeScript, built with Vite. FastAPI serves the built files from frontend/dist, on the same port as the API.
State lives in React Context. Styles are plain CSS with design tokens in index.css: violet marks AI presence, everything else stays neutral. Charts are hand-drawn SVG.
index.htmlis served with no-cache headers. The JS and CSS files have content hashes in their names, so they can be cached safely./api/statusreports which bundle is live. If the page is running an older one,StatusContextreloads it once.- In development,
npm run devserves on port 5173 and proxies/apiand/wsto 8765.
main.tsx └ StrictMode → BrowserRouter → App └ ThemeProvider light / dark / system └ StatusProvider polls /api/status every 4 s └ ConfirmProvider in-app "Are you sure?" └ ChatProvider the conversation, above the routes └ Shell ├ Sidebar 11 pages, badges, theme ├ app-main │ ├ TopBar title, search, bell, Model switch │ └ ErrorBoundary → Routes → pages/* └ CommandMenu Ctrl+K
The eleven screens
The sidebar lists eleven pages in three groups. Two are honest placeholders for features that are not built yet.
Top bar
Page title, Search (Ctrl K), the notification bell with unread count, and the Model on/off switch. Shows the model's last error.Sidebar
Agent status dot, badges for open tasks and errors, a pulsing dot on Chat while Kreo replies, theme toggle, host:port and uptime.Command menu
Ctrl+K searches pages, tasks and conversations. Arrow keys, Enter, Escape.Status
StatusContext polls every 4 s: agent state, model, resources, scheduler jobs, counts.| Route | Page | What you do there |
|---|---|---|
/ | Overview | Greeting, figure tiles, needs attention, 24 h latency chart, CPU/RAM/GPU meters, tasks due soon, recent chats |
/chat | Chat | Talk to Kreo. Thinking block, Markdown, sources, reply details, off-the-record switch, hands-free mode |
/tasks | Tasks | Open / All / Done / Failed tabs, quick add, Start, Complete, Cancel, Reopen, Retry, Delete |
/activity | Activity | One timeline of audited actions, runtime events and reminders |
/automations | Automations | Scheduler jobs with next run times, reminders waiting to fire |
/memory | Memory planned | "Not built yet" banner, counts, stored conversations |
/integrations | Integrations planned | What is connected now, what is coming (email, calendar, Slack) |
/analytics | Analytics | Model speed and latency over time |
/permissions | Permissions | Auto / Ask / Never per action, recent audit entries |
/dev | Developer | Full transcripts with reasoning, searchable logs, runtime events |
/settings | Settings | Theme, start with Windows, location, Kreo's voice, reminder sound, spoken reminders, quiet hours |
ChatContext: the client's side
ChatContext.tsx (681 lines) owns the conversation in the browser: the messages, the socket, voice mode, dictation and off-the-record mode.
- 1Trim the text. Give up if it is empty or a reply is already running.
- 2Silence any speech and clear the box. Remember if voice mode is on.
- 3Add two bubbles at once: yours, and an empty Kreo bubble marked
pending. - 4Open a new WebSocket for this turn and send
{type:"chat"}. - 5Each event updates the last bubble (see right).
- 6On
end: close the socket, reload the conversation list.
/api/chat/active. If a reply is still being written, it loads the stored messages and sends attach. A rejoined reply is not read aloud.
| Event | On screen |
|---|---|
start | Switches to the new conversation. A guard stops the reload from wiping the live reply |
intent · task | Stored on the bubble; a task shows as a link |
reasoning | Grows the folded "Thinking…" block |
sources | Adds numbered source chips. Added, not replaced: one turn can search twice |
token | Appends to the answer. In voice mode also feeds the SpeechQueue |
conversation_deleted | Shows a notice; clears the thread after 3.5 s if it was this one |
farewell | Leaves voice mode once speech has finished |
end | Clears pending, shows latency and speed, speaks the last fragment |
error | Writes "Error: …" into the bubble and stops speech |
The terminal client
kreo chat is a second client, equal to the browser. It attaches to the running agent over the same /ws socket. If no agent is running, it says so and exits.
/mic [text]/voice · /text/tasks/reasoning/new/help · /quit- Speaks the whole reply after
end(up to 2,000 characters), not sentence by sentence. - Never off the record.
- Three silent turns in a row switch voice mode back to text.
- Ignores
sourcesandfarewell.
An illustrative session. Status lines overwrite themselves while the model thinks; replies are word-wrapped as they stream.
Start with Windows, toasts and the tray
Kreo is meant to be part of Windows: it starts when you sign in, runs in the background, and taps you on the shoulder with a toast.
Turning it on registers a Task Scheduler entry named Kreo-Agent, from generated XML. Never schtasks /TR, whose quoting breaks on any path with a space. Never the Run registry key.
Task Scheduler is the authority on whether it is on, not the config file. Windows drops XML elements that hold their default value, so a missing <Enabled> means enabled.
kreo/windows/tray/ is a placeholder for the planned menu: Start, Pause, Sleep, Stop, Open Kreo, Settings, Exit. pystray and pillow are already in the optional dependencies.
<LogonTrigger> at sign-in, current user <Delay>PT30S</Delay> wait 30 s <Principal> InteractiveToken · LeastPrivilege no admin <Settings> MultipleInstances IgnoreNew · runs on battery ExecutionTimeLimit PT0S no limit RestartOnFailure every 1 min, 3 times <Exec> <Command>.venv\Scripts\pythonw.exe no console window <Arguments>-m kreo run [--open] <WorkingDirectory>the project folder
Written as UTF-16 to a temp file, registered with schtasks /Create /XML … /F, then the temp file is deleted.
winotify (windows/notifications/toast.py). Title capped at 64 characters, body at 200. It never raises an error; it returns whether it was delivered, and that is stored on the notification.
kreo root and the API
Each row: the file, its line count, its job in plain words, and the key names inside it.
kreo/
kreo as a package and holds the version, 0.1.0.__version__kreo command. Parses subcommands; only run starts the agent, the rest call its HTTP API.main · build_parser · cmd_run · cmd_stop · cmd_chat · port_is_free · call_api · find_server_pidskreo/api/
create_app.lifespan creates every service at start-up and tears them down at shutdown. Serves the built client with a no-cache index.html.create_app · lifespan · FRONTEND_DIST · spa · _warm_up_listening/ws WebSocket. Starts a Run for each chat message and forwards its events. Handles attach, answer, forget and ping.websocket_endpoint · _forward · _unansweredkreo/api/routes/
The agent and its tools
kreo/core/agent/
kreo/core/tools/
say().PendingDeletion · is_affirmative · is_negative · is_farewell · say · resolve_targets · delete_conversations · CONFIRMATION_TTLServices and speech
kreo/core/ (other services)
kreo/core/speech/
kreo/llm/
--no-model.MockProviderOutside git
llama-server.exe, ggml-cuda.dll (about 547 MB), CPU kernel DLLs for many chip families. The build picks one at runtime.Qwen3-4B-Q4_K_M.gguf (about 2.5 GB), piper/en_US-l2arctic-medium.onnx (about 77 MB). Whisper is downloaded and cached by faster-whisper.Storage, config and the CLI
kreo/database/
log_records in batches every second.LogSink · BufferingLogHandler{label: count} dict.counts_bykreo/config/
kreo/cli/
/ws, with voice through the agent.run_chat · Session · Stream · Printer · _send · _listen_once · _speakkreo/windows/
scripts/
-m kreo.scripts\ to the user PATH (not setx, which truncates at 1,024 characters). -Remove undoes it.Project root
kreo-agent 0.1.0 by MTS, Python ≥ 3.11. Console script kreo. Extras: speech, windows, gpu, dev, and legacy llm.kreo.json is missing.benchmarks/results/.The web client's core files
frontend/src/
App in a router./api/status every 4 s; model and thinking switches; reloads a stale bundle.StatusProvider · useStatusfrontend/src/components/
window.confirm.Pages
frontend/src/pages/
They use the mock provider and a temporary SQLite file, so they never need a model. conftest.py turns notifications off: tests must never raise a toast, play a sound or speak.
Agent and chat
Tools
Voice and model
Storage and config
API, clients, Windows
Each chip is a file named tests/test_<chip>.py. Groups are by file name.
Every endpoint on port 8765
All HTTP routes live under /api. Anything else serves the web client.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/health | Alive, version, client bundle |
| GET | /api/status | State, uptime, model, resources, scheduler, counts |
| GET | /api/status/model | Model status only |
| POST | /api/model/toggle | Suspend or resume the model |
| POST | /api/model/suspend · resume | Free the VRAM, or bring the model back |
| POST | /api/model/load · unload | Load or unload directly |
| POST | /api/model/thinking · /toggle | Reasoning on or off, for the next message |
| GET / POST | /api/startup | Start with Windows, read from Task Scheduler |
| POST | /api/chat | Whole-reply chat (not used by clients) |
| GET | /api/chat/active | Replies still being written |
| GET | /api/conversations | List, with q, include_archived, limit |
| PATCH / DELETE | /api/conversations/{id} | Rename, archive, or delete |
| GET | /api/conversations/{id}/messages | Messages with reasoning and figures |
| GET / POST | /api/tasks | List (status, kind) or create |
| PATCH / DELETE | /api/tasks/{id} | Change or delete a task |
| Method | Path | Purpose |
|---|---|---|
| GET | /api/notifications | List, unread_only |
| POST | /api/notifications/{id}/read · read-all | Mark read |
| GET | /api/speech/capabilities | Voices, microphone, sounds, settings |
| POST | /api/speech/speak | Speak on the agent; returns when finished |
| POST | /api/speech/stop | Stop speaking, clear the queue |
| POST | /api/speech/listen | Record and transcribe one utterance |
| POST · GET | /api/speech/play/{name} · sounds/{name}.wav | Play a chime on the agent; serve it for preview |
| GET / PATCH | /api/location | Home location (this run only) |
| POST · GET | /api/location/detect · search?q= | IP lookup; geocode a place |
| GET | /api/location/sun?day= | Sun times, computed locally |
| GET | /api/dev/metrics · logs · events · audit | Developer views |
| GET | /api/settings | The effective config |
| PATCH | /api/settings/speech · notifications | Change until restart |
| GET · PUT | /api/permissions · /{action} | Read or set a level |
| WS | /ws | Chat streaming (chapters 11 and 16) |
| GET | /assets/* · /{any} | Client files; anything else returns index.html |
Where the project stands
Kreo is built stage by stage from initial-docs/stages.md. Each stage must leave a system that runs.
- Chat with a local Qwen3-4B, streaming, with visible reasoning
- Tasks and reminders created, edited, completed and deleted by conversation
- Reminders as toasts, chimes and spoken announcements, with quiet hours
- Web search with citations; offline sun times; Open-Meteo weather
- Voice in and out on the agent's machine, and a hands-free mode
- Off-the-record chats; safe two-step conversation deletion
- Permissions with an audit trail; start with Windows
- A web client with eleven screens, and a terminal client
Surprises in the code
Things that differ from what the comments say, or that catch newcomers out. Worth knowing before you change anything nearby.
KREO_* variables only fill keys missing from kreo.json, although the docstring says they win.kreo run --reload ignores --config and --no-model: uvicorn calls the factory with no arguments.main() calls basicConfig, then setup_logging adds a second handler.ws.py leaves out the sources and conversation_deleted events, and several fields.provider.stream() has none; orchestrator.stream() does it. provider.generate() has its own.database/models.py and an enum in llm/runtime/manager.py./api/status always reports ACTIVE.api.health, api.chat, api.stopSpeaking and browserSpeechSupported are never called. The model catalogue CANDIDATES is not read at runtime..live once matched a StatusDot modifier and drew big green blobs on four screens. Layout classes now get names nothing else would use, like .voice-stage.Words you will meet
- Agent
- A program that runs by itself and acts for you. Here, the Python process started by
kreo run. - LLM
- Large language model: the AI that reads and writes text. Kreo uses Qwen3-4B.
- GGUF
- The file format llama.cpp loads models from.
- Quantised (Q4_K_M)
- Numbers stored in about 4 bits instead of 16, so the model fits a small GPU.
- Token
- A piece of a word, about 3–4 characters. Budgets are counted in tokens.
- Context window
- How many tokens the model can see at once: 4,096.
- Reasoning / thinking
- Private notes the model writes before answering.
- Structured output
- Forcing the model to reply with JSON that fits a schema.
- Intent
- The kind of request: chat, create a reminder, weather…
- Orchestrator
- The code that runs a chat turn from start to finish.
- Note
- Facts or a sentence Python hands the model to phrase.
- Run
- One chat turn, owned by the agent, with all its events.
- VRAM
- Memory on the graphics card. The GTX 1650 has 4 GB.
- FastAPI
- The Python web framework behind
/apiand/ws. - Lifespan
- FastAPI's start-up and shutdown hook, where services are built.
- WebSocket
- A two-way connection that stays open, used to stream replies.
- SSE
- Server-sent events. llama-server streams to Kreo this way.
- SQLite
- A database in a single file:
data/kreo.db. - WAL
- Write-ahead logging: lets SQLite read while it writes.
- FTS5
- SQLite's full-text search index.
- ORM
- Python classes that stand for tables (SQLAlchemy).
- Job object
- A Windows group of processes that can be killed together.
- TTS / STT
- Text to speech (Piper) and speech to text (faster-whisper).
- SAPI
- Windows' built-in speech voices, Kreo's fallback.
- APScheduler
- The Python library that runs jobs on a timer.
- Toast
- A Windows notification in the corner of the screen.
- keyring
- Stores secrets in Windows Credential Manager.
- Audit log
- A permanent record of sensitive actions.
Running it yourself
The commands a developer uses every day, from the project root in PowerShell.
# run the tests (mock model, temporary database) .\.venv\Scripts\python.exe -m pytest # lint and format .\.venv\Scripts\python.exe -m ruff check kreo tests benchmarks .\.venv\Scripts\python.exe -m ruff format kreo tests benchmarks # start the agent without the AI model .\.venv\Scripts\python.exe -m kreo --no-model # chat from the terminal .\.venv\Scripts\python.exe -m kreo chat # rebuild the web client cd frontend; npm run build
initial-docs/prd.md, trd.md and stages.md, and the rules in CLAUDE.md. Each stage must leave a runnable system.
Kreo runs on
your system.
The browser is only one way in. Every capability works with no browser open.
Inside Kreo · edition of 26 September 2026 · MTS