Architecture1 of 10
One engine, several front doors
On this page
Kotoba is a single Python engine with more than one way in. A browser, a terminal and a Discord bot are three separate processes, and each one builds the same engine before it serves anything. They share one database, one long-term memory directory and one file library, so a fact learned in the terminal is in the prompt of the next browser turn, and a file written from Discord is in the web app's Files panel.
Counting surfaces rather than processes gives four, because the web server answers two very
different callers: the browser app, and ElevenLabs' Conversational AI posting to the OpenAI-shaped
endpoint at /v1/chat/completions. That second caller behaves differently enough to change the
engine's timing rules — see Inline versus deferred.
browser terminal Discord ElevenLabs agent
(Next build) kotoba (CLI) kotoba discord (posts to /v1)
| | | |
| HTTP + SSE | in-process | in-process | HTTP (SSE reply)
| + voice WS | | |
v v v v
+---------------+ +----------------+ +---------------+ +---------------+
| kotoba.server | | kotoba.cli | | kotoba.discord| | kotoba.server |
| (FastAPI) | | .session | | .run/.bridge | | /v1 handler |
+-------+-------+ +--------+-------+ +-------+-------+ +-------+-------+
| | | |
+--------------------+---------+---------+-------------------+
|
core.engine.start(...)
|
+---------------+-----------------+------------------+---------------+
| | | | |
db.Database soul + voice LLM/voice keys MCP manager cron +
(SQLite/WAL) patterns decrypted from (background OAuth
the keystore connects) tickers
|
core.loop.agentic_loop
(one loop, all surfaces)
Why core/engine.py exists
Startup used to live inside FastAPI's lifespan. That was fine while FastAPI was the only entry
point. It stopped being fine the moment a second one appeared: a new entry point either copied the
whole sequence or silently ran without it, and "silently" is the operative word — nothing raised.
Two of those steps hurt most when skipped, and both are named in core/engine.py's own docstring:
- The LLM key preload. A key saved from the Settings panel lives encrypted in the database, never
in the environment. Without
_preload_llm_keys,llm.get_client()returnsNoneand she tells a configured user she has no key. app_settings.apply_on_startup. This re-applies the tool families the user switched off. Skip it and a fresh process offers exactly the tools the app is hiding.
So start() is now the one sequence, and every entry point calls it:
| Entry point | Call |
|---|---|
kotoba.server lifespan | core_engine.start(tickers=True) |
kotoba.cli.session.Session.open | core_engine.start(tickers=True, refresh_oauth=False) |
kotoba.discord.run.serve | core_engine.start(tickers=False, refresh_oauth=False) |
kotoba doctor | core_engine.start(tickers=False) — so its key check sees what a real run sees |
stop() is the mirror, and it is idempotent on purpose: the CLI calls it from a signal handler and
again from its own finally, and a half-torn-down engine must not raise on the second pass.
The two flags, and why they are not cosmetic
tickers starts the cron loop. refresh_oauth defaults to it and is the half a process must not
take when another one already has it.
- Cron in two processes is survivable:
cron._tickrefuses to claim a job it cannot deliver, so the ticker follows the user rather than racing for them. The Discord bot still passesFalse, because cron delivers into whatever event queues its own process holds — a second ticker would claim a due job and announce a private reminder into whichever channel happened to be listening. - OAuth refresh in two processes is not survivable. Two of them rotating one token means one holds a credential the provider has already retired, so only the web server ever runs it.
What start() does, in order
- Open the database (
Database.connect→ busy timeout, WAL, migrations, views). - Read
SOUL_PATHand sync it intosoul_config; build the tool voice patterns from it. - Decrypt the saved LLM keys and the saved ElevenLabs key into the modules that read them.
- Migrate legacy DB memory facts into the Markdown memory store.
- Start the MCP manager, and connect saved servers in the background — N unreachable servers would otherwise hold startup for minutes.
- Apply the saved toolset toggles.
- Start the sandbox reaper, and the tickers if asked.
Any failure past connect() tears the whole thing down through stop(). That is not tidiness:
aiosqlite's worker thread is not a daemon, so a raise that leaves the connection open does not exit
the process — it prints its traceback and then hangs forever. A missing soul file was the live case.
Where each surface's own shape lives
This page is the seam between them, not a description of any one. For the surfaces themselves:
- The browser app: The web app.
- The terminal: Terminal.
- The Discord bot: Discord.
- The loop itself, in product terms: How a turn works.
- Voice: Voice and avatar.
