Kotoba

Where to start

What Kotoba isWho she is and where she runs, in one page
InstallingOne package, two commands
First runA key, a model, her first words
The approval gateHow she asks before she acts
The two voice modesLocal voice, or the agent tunnel
The soul fileChange who she is
RoadmapWhat grows next, and what was cut on purpose

Or a section

↑↓ move openesc close124 pages
All pages

Architecture1 of 10

One engine, several front doors

About 3 minutes to read

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() returns None and 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 pointCall
kotoba.server lifespancore_engine.start(tickers=True)
kotoba.cli.session.Session.opencore_engine.start(tickers=True, refresh_oauth=False)
kotoba.discord.run.servecore_engine.start(tickers=False, refresh_oauth=False)
kotoba doctorcore_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._tick refuses to claim a job it cannot deliver, so the ticker follows the user rather than racing for them. The Discord bot still passes False, 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

  1. Open the database (Database.connect → busy timeout, WAL, migrations, views).
  2. Read SOUL_PATH and sync it into soul_config; build the tool voice patterns from it.
  3. Decrypt the saved LLM keys and the saved ElevenLabs key into the modules that read them.
  4. Migrate legacy DB memory facts into the Markdown memory store.
  5. Start the MCP manager, and connect saved servers in the background — N unreachable servers would otherwise hold startup for minutes.
  6. Apply the saved toolset toggles.
  7. 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: