Skip to content

Design principles

Why Kokua exists

Kokua exists so people can learn how agentic systems work.

It is a real assistant, not a demo. It authors and runs its own skills, connects to tool services, delegates to sub-agents, and schedules its own work, because a toy cannot teach what real work costs. It is designed to be understood: the machinery is meant to be followed, not taken on faith.

Read it. A core small enough to hold in your head, each module saying why it is shaped as it is. Run it. Reasoning, tool calls, results, and sub-agent cards are on by default, so you watch the loop instead of inferring it. Extend it. Capability arrives through the same seam Kokua's own capabilities use, so what you read is what you would write.

The six principles

Kokua is an application, not a library. It is built on AIMU and inherits AIMU's six library-level principles wholesale: plain Python, plain data, uniform interfaces, progressive disclosure, direct paths, loud failures. What follows are the six that are Kokua's own, the ones that decide what belongs in this repository at all.

Each of them serves the vision above: 1 and 2 keep Kokua readable, 3 and 4 keep it observable, 5 keeps it runnable by anyone who clones it, and 6 keeps its capability yours to bound. Every principle carries a how this cashes out paragraph naming the files that back it, because a principle you cannot check against code is a slogan.

They are ordered from foundation to consequence: read top to bottom and the shape of the app falls out.

1. A small, transport-agnostic core

The assistant knows a Channel, not a terminal and not a socket. Every transport-specific behaviour lives on the channel or degrades, once and visibly, to plain text. A new front end implements AIMU's Channel and works immediately; implementing more of the rich-frame surface makes it nicer, never functional-versus-broken.

This is also what keeps the core readable. The runtime a newcomer has to hold in their head is the assistant, the turn runner, and one channel contract; everything about terminals, sockets, and browsers sits outside it.

How this cashes out: Assistant takes a Channel, and frontends/cli.py and frontends/web.py share it unchanged. ChannelUI probes each optional frame once at construction and resolves it to exactly one documented fallback, so no caller asks getattr(channel, "send_x", None) for itself. channels/protocol.py declares the rich surface for documentation and typing, and nothing does isinstance against it. There is no isinstance(channel, WebChannel) anywhere in core/ or workflows/. Where a capability changes what the core does rather than how it renders, it is a named boolean -- supports_conversations, supports_phases, supports_streamed_activity -- not an inline getattr.

2. Grow by plugin, not by core change

Capability arrives as an entry-point-registered FrontEnd or Toolset, from any installed package. Kokua's own front ends and toolsets register exactly the way a third party's would; if the built-in path and the plugin path ever diverge, the plugin path is the broken one. That symmetry is what makes a built-in toolset worth reading as a template: the code you study is the code you would write.

How this cashes out: pyproject.toml's kokua.frontends and kokua.toolsets groups list kokua.frontends.web:FRONTEND and kokua.toolsets.image:TOOLSET in the same table a third party's entry would go in. plugins.py is the only loader, and it treats every registration alike: a toolset that fails to import, or whose build() raises, takes startup down naming itself, whoever wrote it. Kokua ships no third-party code, so it carries no special handling for code it does not ship, and the symmetry above is what makes that defensible rather than harsh. plugins imports the built-in front ends lazily, and kokua/__init__.py exposes Assistant through PEP 562, so import kokua never pulls in aimu.aio or starlette for a caller that only wanted to list plugins. toolsets/image.py exists as the template.

This reaches Kokua's own capabilities, not just third-party ones, and it reaches all of them: every toolset Kokua ships is one file under toolsets/, named for the toolset it declares, registered in the same kokua.toolsets entry-point table a third party writes into. Some wrap one of Kokua's own subsystems as agent tools (capabilities, config, conversations, mcp, scheduling), some wrap an AIMU tool group or store (web, fs, memory, skills, and the rest), one wraps a Workflow instead of tools (planning, a named turn strategy an agent earns by declaring the toolset exactly the way it earns a tool -- see architecture.md), and the rest are capabilities of Kokua's own (benchmark, github_backup, image, aimu_agents). Nothing in that list arrives by a different route than any other, which is what makes the claim above testable rather than aspirational. kokua --list-toolsets prints the namespace grouped by provider, and from the outside the grouping is the only way to tell a shipped toolset from a third party's, a configured server, or a skill: an agent's tools list names "scheduling", "image" and a skill's own name identically, so a capability can change provider without touching an agent. Inside, no asymmetry remains: every toolset keeps the build its author wrote, and one that fails to import or to build stops startup naming itself, whoever wrote it. Kokua ships no third-party code, so it carries no special handling for code it does not ship.

Corollary: a capability is declared, never defaulted

An agent's capability is exactly what its [agents.<name>].tools table declares. No code path adds a tool an agent did not name, and no flag can disagree with a declaration.

How this cashes out: [assistant].memory and the --memory / --tools flags were removed rather than kept alongside the memory and documents toolsets, because a second switch for one capability means one of the two is lying. time is a toolset every agent that wants a clock declares, where it used to be added to every agent in code. The shared state a toolset draws on is a lazy property on LiveState, so the memory and document stores are opened because some agent declared the toolset that needs them, and not otherwise. An unknown name raises rather than being dropped (registry/registry.py's select), since a dropped name is a declaration the code silently overruled.

The one exception is a composed sub-agent. compose_subagent (toolsets/capabilities.py) draws from the whole registry rather than from a table, which is a code path granting a capability no [agents.*] table declared. It is an exception at one level and not at the next: only an agent whose own table names capabilities holds the tool at all, so the exception is still entered by declaration. What the rule protects is a persistent agent's reach, and a sub-agent composed for one task is not an agent the config describes. Its reach is constructed per call and dies with the call. The call-time gate does not move either: a composed sub-agent's execute_python is routed to the user by [security] confirm_tools exactly as a declared worker's is.

cross_cutting is not an authorization boundary, and reading it as one would be a false security conclusion. It decides exactly one sentence of prompt guidance (whether an agent is told it is a lean supervisor that must delegate) and nothing else. A worker whose table declares tools = ["config"] really does get update_config; a worker declaring compute really does get execute_python. That is intentional: filtering a declaration in code is precisely the behavior this design removes. The security boundary is elsewhere, and it is two things: [agents.*] is locked by default in [security].locked_config_keys, so a human writing the table is the consent under the shipped policy, and [security] confirm_tools gates the dangerous calls at call time no matter which agent makes them (a worker's gated call is routed to the user for approval, and an unattended turn auto-denies).

update_config refusing to touch [agents.*] by default is not the only thing standing between it and being rewritten by the assistant itself: the entry agent's add_skill_script and a compute worker's execute_python both have the machine access to overwrite config.toml directly, bypassing update_config's refusal entirely. confirm_tools gating both by default is what makes "locked by default" hold in practice rather than only in update_config's own code, and it is why [security] confirm_tools is itself one of the locked-by-default keys. config.example.toml documents confirm_tools = [] as the way to turn approval off, so this backstop is a default a user can remove, not a wall.

3. config.toml is the single source of settings, and the app writes it

One settings file, hand-authorable and app-writable, with its comments preserved across the app's own writes. No parallel store, no process-only overrides, no settings that exist in memory and nowhere else. One file is also what makes a running system's whole configuration legible at once: read config.toml and there is nothing else to know.

How this cashes out: config/store.py does comment-preserving tomlkit writes; two writers (add_mcp_server and the assistant's own update_config tool) land in that one file. A runtime-settings.json store used to exist and was retired in favour of the file itself. [security].locked_config_keys is a user-set list of patterns naming what update_config refuses even behind the approval prompt, and it ships locking four things: [security] confirm_tools (the gate itself), [email] to (the locked recipient), [paths] data_dir (where all state lives), and the whole [agents.*] section. That last one is matched by section prefix rather than by a key entry, since agent names cannot be enumerated ahead of time, and it is locked by default for the obvious reason: update_config is a tool the assistant holds, so a writable agent table would let the assistant widen its own reach. locked_config_keys itself is the one entry the list can never remove; everything else, agents.* included, is a hand-edit away from being unlocked.

config.toml also holds Kokua's declared scheduled tasks, one [scheduling.task.<name>] table per task, and [scheduling.task.*] is app-written the same way [[mcp.server]] is: the assistant's own scheduling tools write it, so a hand-written or hand-edited task is still just TOML with the app's comments preserved. It is locked against update_config too, but for a different reason than [agents.*]: not capability but routing. A task write has to be paired with the scheduler (un)arming that accompanies it, and a bare update_config write would land the TOML change while leaving the running scheduler firing (or not firing) the old schedule. The parent [scheduling] section stays ordinary and hot-appliable; only the per-task tables route through the scheduling tools instead.

One of Kokua's own runtime-mutable settings is one entry in config/table.py's CORE_RUNTIME_SETTINGS; a toolset's is one Setting on the toolset itself, in its own [<name>] section. SettingsTable, built at startup from both, is what drives the TOML schema, the incoming-payload sanitizer, the hot-apply set, the live-apply loop, and the persist path at once -- and tests/config/test_table.py fails if a CORE_RUNTIME_SETTINGS entry is not also a real config field and documented in config.example.toml under its own [section].

CORE_RUNTIME_SETTINGS holds exactly one entry, and the bar it had to clear says what the section is for. Nearly every runtime setting Kokua ships belongs to a capability, so nearly every one is that capability's own declaration; the entries before this one were the display flags, and what they controlled turned out to be a front end's decision rather than a setting at all (see principle 1). [assistant].generate_titles qualifies because no capability owns it: whether a conversation's title is written by the model is read in one place in the core (Assistant._spawn_title), and there is no toolset it could be declared on. Two questions gate an entry here, and a candidate has to answer both: does the core read it, and does a change to it apply without a restart? [assistant].model answers the first and fails the second, which is why it is startup-only despite reading like the most obvious setting in the file.

4. All state under one directory the user owns

$KOKUA_HOME (default ~/.kokua): config.toml at the root, content under data/. Nothing is written inside the installed package, into a hidden cache, or split across XDG directories. Which makes an agent's entire state inspectable with ls: the conversations it has had, the facts it remembers, the skills it wrote for itself, the images it made. Nothing the assistant knows is hidden in a store you cannot open.

How this cashes out: config/paths.py holds exactly three locations -- the root, data/, and config.toml -- because those are the only ones that must resolve before the settings file can be read. Every leaf below data/ is a derived property on AssistantConfig (sessions_path, skills_dir, memory_path, images_path, logs_path, ...), so a single [paths] data_dir override moves all of them; adding a leaf function to config/paths.py would silently bypass that, which is why the module docstring says not to. tests/conftest.py redirecting KOKUA_HOME to a temp directory is sufficient isolation for the entire suite.

Scheduled tasks are the one stated exception to "all state under data/": a [scheduling.task.<name>] table lives in config.toml itself, not in data/. A task is a declaration, the same kind of thing an [agents.*] table or [[mcp.server]] entry already is, and a user should be able to write and comment one the same way -- which content under data/ is not meant to be.

5. A single user, one process, with concurrency rules written down

One process serving one person is what makes a live scheduler, per-conversation agents, and last-writer-wins config writes reasonable. Kokua is not multi-tenant and does not pretend to be. But turns are concurrent, and every rule that makes that safe is named, next to the code, with the bug it prevents. One process is also what makes a single turn followable end to end: one asyncio loop you can attach a debugger to, rather than a request landing in whichever worker happened to pick it up. The invariants block is a teaching artifact as much as a safety one.

How this cashes out: core/turns.py opens with a ## Concurrency invariants block -- seven rules, each stating what breaks without it, including a deadlock that a regression test still guards. TurnGate is a documented writer-preferring readers-writer gate: turns read, a settings change writes, and which side an operation belongs on follows from its reach rather than from whether it mutates (a conversation delete mutates and still reads, taking only the deleted conversation's slot). AgentRegistry gives each conversation its own agent and model client, with LRU eviction and a pin held for the duration of any in-flight turn. A background or scheduled turn auto-denies a gated tool because nobody is watching it. Every human decision is a lock-guarded single slot (core/interaction.py), so concurrent turns cannot clobber each other's prompt. The web front end refuses a second connection. config/store.py states last-writer-wins rather than adding file locking.

6. Security is explicit and user controlled

Kokua's capability is real and stays real. It runs code as ordinary subprocesses with your privileges, and it calls whatever tools the MCP servers you named expose. Taking that away would make the program safe and useless, and a toy cannot teach what real work costs. So safety here is never bought by removing capability. It is bought by putting a control beside every dangerous thing, and making that control yours: declared where you can see it, bounded by a value you wrote, and loud when it is doing nothing. That balance is the principle. Capability is not reduced; control is added next to it.

A security control is a value in config.toml, never a constant in the source. Which tools need your confirmation before each call is [security] confirm_tools. Which keys the assistant's own update_config may not write is [security] locked_config_keys, a list you author. What any agent may call at all is that agent's own tools list. Kokua ships a default for each, but a default in a file you can read and edit is not the same thing as a rule compiled in.

A control that would do nothing fails startup. This is the failure mode a security setting has and an ordinary setting does not: silence. A gate naming a tool that does not exist prompts for nothing, and a lock pattern matching no real key locks nothing. You notice a prompt you did not expect; nobody notices a prompt that never comes. Both are hard startup errors naming the offending entry, because the alternative is a user who believes they are covered and is not.

You may loosen, not only tighten. locked_config_keys is yours to empty. Strike agents.* from it and the assistant really can rewrite its own capability table on the next restart. The documentation states that consequence plainly rather than preventing it, because a control you cannot release is not yours. The one exception is the key holding that list, locked whatever the list says, since a policy the assistant can rewrite for itself is not a policy.

How this cashes out: config/store.py's locked_by matches a write against the user's own patterns, and LOCK_AXIOM beside it is the single unconditional lock. core/interaction.py's HumanGate.approve is a bare name match against confirm_tools, which is what gates a worker's call identically to the entry agent's, and a proactive turn auto-denies rather than running a gated tool unattended. core/agents.py's validate_confirm_tools and config/file.py's lock-pattern checks are the two startup errors above. SECURITY.md names which barrier a vulnerability report is about, and which behavior is the program working as documented.

What follows from these principles

Each one excludes things, and most of what is absent from Kokua is absent for a reason on this list.

A small, transport-agnostic core rules out transport branches in the core, and rules out a Channel contract that a new front end must implement twelve methods to satisfy. Grow by plugin rules out a hardcoded tool registry, an if config.enable_x switchboard, and importing a front end's dependencies at core-import time. Its corollary, declared and never defaulted, additionally rules out a tool an agent gets without asking, a flag that grants capability, and a code path that filters a declaration it disagrees with; the composed worker qualified under that corollary is the one exception, and it is entered by declaration. config.toml as the single source rules out a second settings store and rules out environment variables as a settings mechanism -- the three that exist (KOKUA_HOME, KOKUA_CONFIG, KOKUA_EMAIL_PASSWORD) are exactly the ones that cannot live in the file: the file's own location, twice, and a secret. One directory the user owns rules out writing inside the package, XDG-split state, and per-feature configurable paths. Single user, one process rules out multi-tenant session keying, authentication on the web front end, file locking, and a job queue. Security explicit and user controlled rules out a security policy hardcoded in the source, a control that silently does nothing when it is misspelled, and a privilege tier among agents that the config file does not state.

None of these are missing because they were hard. They are missing because they would make Kokua a different kind of program, and most of them would make it a harder one to learn from.

What this means for contributing

Two questions, in this order. First: does this make Kokua easier to learn from? A capability whose behavior can only be discovered by running it, an abstraction that saves lines at the cost of a reader's jump, a subsystem that grows what a newcomer must hold in their head without demonstrating anything new about agentic systems: each of those fails this question before any principle is consulted.

Second: which principle does it serve? A change that serves none of them is probably a plugin, not a core change, and principle 2 exists to make that an easy answer rather than a rejection.

The harder question is which principle a change violates. Some concrete forms that question takes here:

  • A new transport is a FrontEnd. A new capability is a Toolset. Neither is a core change, and neither reaches an agent until an [agents.*] table names it.
  • A new runtime setting for a toolset is one Setting on the toolset and nothing else. For Kokua's own it is one CORE_RUNTIME_SETTINGS entry and one AssistantConfig field. If either takes more edits than that, the table needs fixing, not working around.
  • Anything that writes state derives its path from AssistantConfig, never from a new function in config/paths.py.
  • Anything touching turn concurrency updates the invariants block in core/turns.py, in the same commit, with the failure it prevents.
  • Anything a test cannot reach without a live model is not finished.

If you cannot tell which principle applies, the principles are not doing their job; open a discussion.

Not (yet) a principle

"Failures reach the user, not just the log." This is a goal, not a description: today a bad model string or a malformed config can still surface as a stack trace or a silent failure rather than a message in the chat. It is on the backlog rather than in the code, which is why it is here and not above: six honest principles beat seven with one aspirational. It matters more than a polish item under the vision at the top of this page, since a failure you cannot see is a failure you cannot learn from. When it lands, it becomes the seventh.

See also