Skip to content
saxmanPublic

About

A fully modular personal assistant application built with plain Python and designed for learning how agentic systems work

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Kokua: understandable agents

A personal AI assistant built to be understood and extended.

CI GitHub License Python Version from PEP 621 TOML uv Ruff Docs

Documentation · Design principles · Architecture · Configuration · Contributing · Changelog

Kokua (Hawaiian: help, assistance) is a real, always-on personal assistant, designed to help people understand how agentic assistants work. It is hackable and modular (OpenClaw / Hermes Agent style), built on the AIMU library: it chats with you, authors and runs its own skills, connects to remote tool services, delegates independent subtasks to isolated sub-agents, schedules its own proactive work, and remembers facts and documents across conversations. None of that is a demo, and every one of those mechanisms is built to be read, run, and extended.

Kokua extends itself: it writes and runs new skills to take on capabilities it didn't ship with, and grows its reach by connecting to remote MCP services on its own. Where it can't extend itself, you extend it: front ends and toolsets are plugins you add by installing a package, never by editing the core.

kokua                              # chat in the terminal
kokua --frontend web               # or a browser UI at http://127.0.0.1:8000
kokua --list-toolsets              # see what capability is installed
kokua agents                       # what each agent can call, holds from the start, and asks you about first
kokua config init                  # scaffold ~/.kokua/config.toml, every key documented

It runs as a single user in a single process, and can run code and connect to remote services with your privileges (see Security).

What Kokua is

Kokua is a real assistant built to be understood. It does real work, because a toy cannot teach what real work costs, and its machinery is meant to be followed rather than taken on faith: that combination is what lets you learn how an agentic assistant works from the one you are actually using. There are three ways in.

Read it

The core is small enough to hold in your head, and each module opens by saying why it is shaped the way it is rather than restating what it does. A reading order:

Start here What it teaches
core/assistant.py The composition root and the serve loop. Which AIMU primitives an assistant is actually made of, and how they are wired together.
core/turns.py What one turn is, reactive and proactive. It opens with ten concurrency invariants, each naming the bug it prevents.
registry/registry.py, then toolsets/image.py How a capability becomes a tool an agent holds: one flat namespace, then the smallest complete toolset in the repo.
channels/ui.py How a core that knows no transport still renders richly. Every optional frame is degraded once, at construction.
workflows/planning/runner.py An agentic loop with more structure than chat: draft a plan, review it, execute it, review the result.

Then architecture for the whole map, and design principles for why the boundaries fall where they do. tests/ mirrors src/kokua/ exactly, so a module's tests are its second explanation.

Run it

kokua --frontend web

Reasoning, tool calls, tool results, sub-agent cards, and plan phases are all visible by default: the core streams every one of them and leaves it to a front end to decide how to draw them. You watch the loop instead of inferring it: what the model was thinking, which tool it chose, what arguments it passed, what came back, and what it did next. Ask for something on a schedule and watch the task fire on its own. Send /plan <task> and the assistant shows you the plan it drafted before it acts on it; turn on adversarial review and Show all reasoning (see Planning and self-review) and every call in that turn, planner through reviewer through executor, streams under its own labeled phase. /diag reports the in-flight turn and the gate state even when a turn is stuck, and kokua --list-toolsets prints every capability this install can offer, grouped by what provides it.

The entire state of a running assistant is plain files under ~/.kokua, so you can read what it remembers while it is still running.

Extend it

Capability arrives through the same seam Kokua's own capabilities use, so the code you read is the code you would write. toolsets/image.py is one tool and a build() in 85 lines, registered through the same entry point a third party's package would use. skills/dice-roller/ is the same idea with no packaging at all: a SKILL.md and a script. Set up a toolset is the full walkthrough. Nothing in the core changes for either.

What keeps it that way

Kokua is small on purpose. Six principles decide what belongs in the core: a transport-agnostic core that knows a channel rather than a terminal or a socket, growth by plugin rather than core change, config.toml as the single source of settings that the app itself writes, all state under one directory you own, a single user and one process with concurrency rules written down, and security that is explicit and under your control. The first two keep Kokua readable, the next two keep it observable, the fifth keeps it runnable by anyone who clones it, and the last keeps its capability yours to bound. These sit on top of, and inherit, AIMU's six library-level principles. The reasoning behind each of Kokua's, and the patterns each one excludes, is on the design principles page; the shape that falls out of them is in architecture.

The practical consequence: the assistant core is a few hundred lines that knows nothing about terminals or browsers, and adding a transport or a capability means shipping a package, not patching Kokua.

What people run it for

None of these is a mode or a preset. Each is the same declared machinery pointed at a different job, and each one says what you have to add yourself, because a capability Kokua did not ship with arrives through the same seam its own do.

A market watch that has to check its numbers. "Every weekday at 4:30, check my positions, flag anything that moved more than 3%, and email me the digest." The assistant writes that as a [scheduling.task.*] table in config.toml through its own schedule_task, so it survives restarts and you can read and edit it by hand. Each firing gets its own conversation and can fan out to concurrent researcher sub-agents, one per ticker, each with a context built fresh for the lookup. A price is exactly the kind of fact the web toolset's guidance says to go and check "even when you think you know"; compute does the arithmetic; documents and memory carry the positions and the thesis from one firing to the next; the email-report skill delivers the digest. Turn on adversarial review and a reviewer with no conversation context verifies the recency and numeric claims before you read them. The constraint is worth watching as much as the capability: a proactive turn auto-denies every approval-gated tool, so what a scheduled run reaches is exactly what you granted it and nothing it can talk its way into. You add: the skill, an agent that declares web, and your own judgment about the result, which is sourced and checkable in the trace rather than advice.

A job hunt, with the expensive step in someone else's package. "Here is the posting. Tailor my resume and draft the cover letter." jobme is a separate project that does exactly that, and it is the first package outside this repository to register a Kokua toolset: one kokua.toolsets entry point, the same seam Kokua's own 23 arrive through, and no change to Kokua's core. The name jobme lands in the namespace and reaches nothing until an agent's [agents.<name>.tools].available list names it; its [jobme] section in config.toml is seeded from Setting declarations on the toolset itself; and because tailor_application spends several minutes and real provider money per call, it belongs in confirm under [assistant.tools], where you approve each run. It also carries its own job-application skill, teaching the model the procedure around the tool. You add: the package, one name in an agent's available list, and the gate. Install a third-party toolset walks all four steps with jobme as the worked example.

A machine that looks after itself. "Check disk and the log directory every morning, and back yourself up nightly." This is the security model made concrete instead of described. Reading a file and writing one are two toolsets, fs and fs_write, because with a single name there would be no way to declare "read a file and do not write one". run_command and every writer in fs_write are gated per call, a y/N in the terminal or Allow/Deny in the browser. Switch on [security.auto_approval] and a declared reviewer can answer that prompt in your place on a call it can justify, with no deny verdict, so it can never widen what you could have approved yourself. And github_backup's one tool takes no arguments at all, deliberately: that is what lets it stay ungated and therefore run unattended on a schedule, where jobme's tool above cannot. You add: a private repository and a token (back up to GitHub); the rest ships.

A reading desk that remembers. "Read these six papers and tell me where they disagree." "What did we decide about this last month?" The document store is a folder you drop files into, semantic memory is facts that outlive the conversation, and both are shared by every agent declaring them. The synthesize-documents skill exists because the obvious path, search and then summarize the hits, produces a fluent answer built from first pages, and nothing about that answer looks wrong. search_conversations makes a thread from last month an input to this one, and when the question is about how a run went rather than what it said, export_conversation hands back a path, so the whole transcript lands in the introspector sub-agent's fresh context instead of swallowing yours. Context is a budget, and these are the tools that make the budget visible. You add: kokua skills install synthesize-documents.

Install

Kokua needs Python 3.11+ and AIMU 0.38.0 or newer.

uv sync --all-extras --no-sources        # AIMU from PyPI; what you want to just run Kokua

The web extra (included in --all-extras, or pip install '.[web]') adds the browser front end; without it the CLI works alone.

Why --no-sources? Kokua and AIMU are developed together, so [tool.uv.sources] points AIMU at a sibling ../aimu checkout, installed editable, which lets architectural changes move across the boundary without a release round-trip. --no-sources ignores that table and resolves AIMU from PyPI instead. If you are developing both, clone AIMU as a sibling of this repo and drop the flag:

git clone https://github.com/saxman/aimu    # sibling of kokua/
uv sync --all-extras                        # installs ../aimu editable; picks up your edits live

The aimu>=0.38.0 requirement governs the PyPI path only: uv installs a path source without checking it against the specifier, so a sibling checkout is not constrained by it. If yours falls behind, startup says so and names the fix rather than failing on an import.

Quick start

kokua --model ollama:qwen3:8b

The model string is AIMU's provider:model_id form, so any provider AIMU supports works here. Omit --model and AIMU resolves a default: $AIMU_LANGUAGE_MODEL if set, else the first already-running local model found (Ollama, then a local OpenAI-compatible server). A cloud model is never auto-selected, and startup fails with an actionable message if nothing resolves. Chat at the prompt; Ctrl-D exits.

Useful flags: --list-toolsets (every capability name this install accepts, grouped by what provides it), --mcp <url> (repeatable), --system (overrides the entry agent's system message for this run only; a worker's own declared message is untouched), --config <path>, --host / --port (web). What each agent can do is not a flag: it is that agent's [agents.<name>.tools] table in config.toml (see Configuration), and kokua agents prints what each declared agent holds and what asks first.

Five commands worth knowing. /new starts a fresh conversation, /conversations lists every one you have with a short id and a * on the one you are in, and /switch <id> moves to another (the web UI's sidebar does all three with the mouse; typing them works there too). Switching never cancels a reply already running: it keeps going and saves to the conversation it started in, which the reply to your command says out loud when it applies. /stop cancels a reply that's still streaming and keeps the partial turn, so the conversation continues (the web UI has a Stop button for the same). /diag reports the in-flight turn, the gate state, and a stuck turn's async stack; it never takes the turn gate, so it answers even when a hung turn is holding it. (In the web UI it does wait behind an earlier control that is itself waiting on the gate, because the page's frames are applied in the order they arrive; reloading clears that, and always can.) Rotating logs go to $KOKUA_HOME/data/logs/kokua.log, and kill -USR1 <pid> dumps all thread stacks there.

Key features

The turn loop, in the open

  • The loop is the interface. A turn arrives as a sequence of typed frames rather than a finished answer: the model's reasoning, each tool call with its arguments, each result, a card per sub-agent, a header per plan phase. Those frames are optional, and every one of them degrades exactly once, in ChannelUI, to a documented fallback, so the CLI shows the same loop with less decoration rather than showing less of the loop.
  • A reactive turn runs as a background handle. core/turns.py opens with ten concurrency invariants, each naming the bug it prevents. The turn runs as an AIMU RunHandle while the channel keeps reading, and that one decision is what makes the rest work: /stop can cancel a reply mid-stream and keep the partial turn, and a web Allow/Deny click can reach the tool call still waiting on it.
  • A message you type while it is working reaches that turn. It no longer queues behind the reply you are watching: the turn picks it up at its next model call, so "actually, use the cache" lands while the work it is about is still happening rather than after you have paid for the work it was meant to redirect. Every spawned worker gets it too, declared in config.toml or composed for a single task, since a redirection that only reaches the supervisor redirects nothing. The bubble is marked as having joined the turn above it rather than opening one of its own, and carries none of a turn's own controls, because there is no separate turn there to branch or delete from. In the terminal the words print as the turn reads them. A message the turn ends before reading is not lost: it runs as the next turn instead, and /stop is the one case where it is neither delivered nor re-run, which the stop notice says out loud. What does not reach a running turn this way is anything the assistant answers without a turn (/stop, the conversation commands, a reply to an approval prompt) and a message carrying an image, which runs as its own turn as it always has.
  • An agent can message another agent the same way. The mechanism is the one above, generalized: the user's mid-turn message is one address on a per-turn bus, and the messaging toolset lets an agent send to another, by exact address (researcher#2), by label (researcher reaches every researcher running now), or to everyone, with list_agents to see who is reachable. An agent's message is marked as a machine's wherever it is read, so a worker cannot arrive wearing your role, and a receipt says a message was accepted for an address, never that it will be read. Which directions can carry anything is decided by the execution model rather than by the bus: a parent is blocked for as long as its children run, so worker-to-sibling is the only direction this adds between two agents already running, and a worker the orchestrator is already waiting on cannot be redirected at all. It can still be briefed, which is the interesting part: a worker's cursor opens at zero, so a broadcast sent before a spawn is still in front of that worker's first drain. Agent messaging opens on that matrix, with a captured run of both halves of it.
  • Switching conversations does not cancel a turn. Each conversation owns its own agent and model client, so a reply you walk away from keeps running: it persists to its own conversation, mutes its stream, and raises a dismissible alert card when it lands. Switch back into one still working and it catches you up (the reasoning, tool calls, and answer so far, with a spinner counting off how long that turn has been going and Stop in the composer) then keeps streaming from where it left off. That is the web UI; in the terminal /new and /switch background a turn the same way, and since a terminal has no second place to put it, the reply keeps printing where you are now, which the command's own answer warns you about.
  • You can open what the model saw. In the web UI each model call puts a { } button on its own output. It opens a side panel summarizing the request (messages, tools, size, how the call stopped) without fetching anything until you ask, and showing the round's two halves, the exact request (browsed one level at a time) and the response the model returned (reasoning, tool calls, text), each downloadable as JSON; very large requests (images) are download-only. A loop that ended on a forced wrap-up or an error tints its last round's button, and the panel says which rule stopped it. Each user message carries its turn number.
  • Tool calls show their results. A tool card carries what the call returned as well as what it was called with, in a nested foldable labeled with the result's size and clamped behind a "show all" button. It renders as plain text rather than markdown, because a tool result is untrusted input: whatever a web page or an MCP server just returned is now in the model's context, and it does not get to be markup. Results show on live cards, on a background turn's catch-up, inside a sub-agent's card, and on reload, where a stored call is rejoined to its result by tool_call_id so a replayed card shows what the live one did.
  • Replayable transcripts. Reloading replays the conversation, reasoning and tool calls included: what streamed live is what comes back. Auxiliary blocks (thinking, tool calls, phases, sub-agent cards, drafted plans) come back collapsed behind a one-line header carrying the call, its condensed arguments, and a result size, while messages and prompts stay open.
  • Export a conversation to Markdown. kokua export writes one saved conversation as a Markdown file you can diff, paste into a review, or keep once the terminal is gone: every turn's reasoning, tool calls with their arguments and results, and what it cost. It reads the session store directly and builds no assistant, so it works with the model server down. kokua export <id-or-prefix> -o - writes to stdout instead of a file; --full lifts the truncation a plain export applies to long tool payloads. Each agent's system prompt appears the first time it is used and whenever it changes, and --turn N exports one turn alone with every request the model was sent and every response it gave. The web UI's sidebar has the same export as a download button beside each conversation's delete button, so you don't need the command line to get the file. The assistant holds the same export as export_conversation, which is how you ask it a question about a run instead of reading the file yourself.

Tools, and how an agent gets one

  • One namespace, and nothing is implicit. Every capability an agent can hold is a named toolset in a single namespace: AIMU's built-in tool groups, Kokua's own memory / documents / skills / config / MCP admin / scheduling / conversation / capability-discovery capabilities, each installed plugin toolset, and each configured MCP server by its name. kokua --list-toolsets prints the lot, grouped by what provides it. A capability is declared, never defaulted: no code path adds a tool an agent did not name, not even the clock, so an installed toolset or a connected server reaches nothing until some agent's available list names it. An agent may instead declare available = "all", every installed toolset in trust order (Kokua's own, then plugins, then skills, then MCP servers), which still has to be written in that agent's table and requires a resident list; skills written mid-session reach such a worker on its next spawn, and MCP servers added at runtime at the next restart (persisted ones; a bearer-token server never). Tool access explains why that is still a declaration. An unknown name is a startup error listing the valid ones, and two providers claiming one name is one too.
  • Part of an agent's capability loads on demand. Every tool schema an agent holds is re-sent on every model call, so an agent's resident list says which of its available toolsets are in its context from the first turn; the rest it can list with available_tools and load with load_tools when a turn needs them, for the rest of the conversation. The prompt names what is loadable in one line, a toolset's guidance arrives with its tools, and a load builds the toolset then, so a failure (including an MCP server that is down) surfaces at load, with an alert, and the toolset stays loadable for a retry. A conversation's agent rebuilt later preloads what it had loaded. The shipped entry agent keeps seven of its twelve toolsets resident, which cuts its tool block from 30,560 bytes to 16,244 (46.8%) on every round that loads nothing. Leave resident out and everything available is resident. Tool access explains what a load costs and checks.
  • Self-authored skills. Built on AIMU's SkillAgent: the assistant writes SKILL.md skills (the same format Claude Code uses), and bundles runnable Python/shell scripts it can author and execute in the same turn, so it takes on capabilities it did not ship with. It can also revise a skill it already wrote, prose and scripts both, which is what makes the loop a loop: a procedure learned wrong the first time is fixed in place rather than duplicated under a second name.
  • Remote MCP services. Kokua wires AIMU's MCPClient to config and to the agent. Connect a server with --mcp <url> or [[mcp.server]], or let the assistant connect one itself with its add_mcp_server tool. OAuth is handled by posting the authorization link into the chat and persisting the tokens; bearer-token servers read their secret from an env var named by token_env, never from the config file.

Agents and delegation

  • Every agent is declared, none is baked in. One [agents.<name>] table per agent in config.toml, each with a description, a system_message, an optional model, thinking, and max_iterations, a [agents.<name>.tools] subtable (an available list of toolset names or the string "all", and optionally a resident subset and its own confirm and review), and a delegates_to list. [assistant].model, [assistant].thinking, and [assistant].max_iterations are the model every agent runs on, the reasoning effort it runs at, and the most model calls its turn may make (default 10); an agent naming its own runs on that instead, so a lean supervisor and an expensive researcher need not share any of the three. Resolution is per agent and never inherited, so a delegator that pins a model, reasons hard, or raises its own cap does not drag its workers along. thinking takes false (do not reason), true (reason at the model's own default), or "low" / "medium" / "high"; unset, nothing is sent and each model keeps its own behavior. [assistant].agent says which one you talk to. kokua config init writes assistant, researcher, coder, and introspector, and you edit, delete, or add to them freely. At least one agent is required, so a config with none is refused at startup rather than run as an assistant that can do nothing.
  • And you can change the effort for one message. The composer's Think picker sets the reasoning effort for the messages you send after it (off, low, medium, high, or the configured default), and /think <level> does the same in the CLI. It is per request, not a setting: nothing is written to config.toml, and a reload returns to what the config declares. It reaches the agent you are talking to and nothing else, so a sub-agent still runs at the effort its own table names, and a planned turn ignores it entirely (the picker greys out while Plan is on). Each turn records the effort it actually ran at, so a transcript says how its answers were produced.
  • Sub-agents. A non-empty delegates_to is itself the declaration: that agent gets AIMU's spawn_subagent(agent_type, task) offering exactly the agents it names, each running on its own [agents.<name>].model and .thinking (or the [assistant] defaults) with the tools its own table declares. Delegation nests, since an agent you delegate to that delegates in turn gets its own menu of its own targets; the graph has to be acyclic, and a cycle is a startup error that prints the path. Independent spawns in one turn run concurrently, and a worker's approval-gated call is routed to you rather than run unattended. In the web UI each spawn gets its own foldable card in the conversation that spawned it, holding the sub-agent's thinking, tool calls, and live-streamed answer as the same collapsible blocks the assistant's own turn uses, and the card replays on reload.
  • Sub-agents composed on the spot. When no declared role fits, the assistant can build one, which is why the shipped config declares two specialists and no catch-all role: compose_subagent assembles a sub-agent carrying exactly the capabilities a task needs, runs the task on it, and discards it. This is the one code path that draws on the whole registry instead of a table, and it is still entered by declaration, since only an agent whose own available names capabilities can do it at all. [capabilities].max_depth (default 3, 0 to switch composing off) bounds how far composition nests, and a composed sub-agent's gated calls are routed to you like any declared one's.
  • Generation parameters. [assistant.generation] sets temperature, top_p, top_k, min_p, presence_penalty, repetition_penalty, max_tokens, and context_length for every agent; an agent's own [agents.<name>.generation] overrides it per key, so naming only temperature still inherits the default's context_length. Only what you set is sent: AIMU layers this tier over a model card's own tuned sampling profile, so a key you leave out keeps the card's value. A parameter the backend cannot take is dropped with a warning naming the remedy, written to the rotating log rather than shown in the chat. Resolved once per agent, never inherited down the delegation graph, and read at startup only, like model and thinking above.

Memory and context

  • Persistent memory. AIMU's semantic store for facts and document store for text, shared across every conversation and every agent that declares the memory and documents toolsets, and surviving restarts. Both live under data/ as plain files you can read while the assistant is still running. Drop those names from an agent's tools and it has neither; drop them from every agent and no store is opened at all.
  • Every capability an agent declares is spent from its context. A tool is a schema in the prompt on every turn, so the reach you grant an always-on agent is paid for in tokens before it does any work. That is why the shipped tables make the agent you talk to a lean supervisor: the cross-cutting toolsets it needs to manage itself, no domain tools, and everything specialized delegated to a worker whose context is built fresh for the task. Nothing in the code enforces this. It is what the config declares, and giving that agent "compute" makes it run Python itself.
  • What it will not answer from memory. A model that believes it knows an answer never reaches for a tool, so the prompt names the trigger as a property of the question rather than as the model's own confidence, which is the signal it is worst at reporting: an answer that could have moved since training, or one you could check against a source (current events, prices, releases, published figures, who holds a role, what a page says today), goes to a worker or to the web tools "even when you think you know". Both halves are declared, not hardcoded behavior: the delegating agent's clause arrives with a non-empty delegates_to, and the look-it-up clause is the web toolset's own guidance, reaching whoever declares it.

Knowing and changing itself

  • It can read its own history. list_conversations, read_conversation, and search_conversations give the assistant sight of every saved conversation, so it can answer "what did we decide about this last week?" and carry context out of a past thread into a sub-agent's task. A transcript comes back as bounded plain text (what was said, no reasoning or tool calls), oldest messages dropped first behind a count when it does not fit, which is the context window made visible as a rule you can read. Reads come from the last saved snapshot, so a conversation still streaming is flagged as having unsaved messages, and the one you are in is flagged too, since its current turn is not saved yet.
  • It can analyze how another conversation ran, not just what it said. The three reading tools drop reasoning, tool calls, and tool output on purpose. export_conversation keeps all of it: ask why a turn failed, which tool returned what, or what a run cost, and the assistant renders that conversation to the same Markdown kokua export writes and answers from it. It hands back a path, not the transcript, because a run's full tool output would swallow the context of the conversation asking about it: a long export goes to the shipped introspector sub-agent, whose own fresh context absorbs the file and returns only the findings. You get the file too, in ~/.kokua/data/downloads/, so you can check the answer against the evidence it came from. That sub-agent, introspector, is the one shipped worker that reads history, because it is the one with a conversation as its subject: ask it to evaluate a past run against criteria you name and it finds the conversation, exports it, and reports per criterion with the transcript lines each judgment rests on. It says which criteria it chose when you give none, and reports a criterion the transcript cannot settle as unassessable rather than guessing.
  • It can name a conversation, and so can you. rename_conversation is the one tool in that group that writes, and all it writes is a title. In the sidebar a pencil on each row edits the name in place, and the editor it opens carries a ✨ that asks the model instead, reading the whole conversation and naming what it turned out to be about, which is worth more than the automatic title a conversation got from its opening message alone. The assistant's rename lands when its turn finishes rather than immediately, and its reply says so, because the write waits for the turn slot that turn is holding.
  • It can enumerate what it cannot do. list_capabilities lists every capability installed on the machine other than capability discovery itself, whether or not the agent holds it. An agent that can name its own gaps is the precondition for compose_subagent filling one, and it is why "no tool for that" can become a composed sub-agent instead of a refusal.
  • It can rewrite its own settings, up to a ceiling it cannot raise. read_config returns the live config.toml so the assistant can diagnose a configuration problem before touching it, and update_config changes one setting, preserving your comments. The distinction it has to reason about is hot versus cold: a display or planning flag applies to the next turn, everything else (the model and the reasoning effort among them) is saved and waits for a restart, and the tool says which happened so the assistant knows a saved setting is not yet in force. A model string is checked against the providers actually installed before it is saved, so a name that does not resolve is refused there rather than breaking the next startup. Four things are refused outright, [agents.*] chief among them, so the assistant can adjust how it behaves but never widen what it may call (see Security).
  • It can measure how fast its own model is. benchmark_model streams a fixed short prompt and reports two numbers the model can relay to you: time to first token, which is mostly prompt processing and queueing, and output speed in tokens per second, which is decoding. Separating them is the point, since a slow reply is one or the other. It discards a warmup request first (on Ollama a first request can carry a cold model load worth many seconds) and reports the warmup's own figure on its own line, so you can tell "this model is slow" from "this model was not loaded yet". Token counts come from the provider where the provider reports them and from counting stream chunks where it does not, and the report says which. The tool takes no arguments: it measures the model config.toml gave the agent holding it, with that agent's declared sampling and reasoning effort, so the numbers describe what you actually run. The shipped config gives it to the agent you talk to. What it measures is its own holder, so a worker declaring it and running on a model of its own is timed on that model rather than on the assistant's, and the report names which agent the figures describe.
  • And you can diagnose it while it is stuck. /diag reports the in-flight turn, the gate state, and a hung turn's async stack, and it never takes the turn gate, so it answers in exactly the case it exists for. kokua --list-toolsets prints every capability this install can offer, kill -USR1 <pid> dumps all thread stacks to the rotating log, and a startup preflight checks the AIMU underneath and prints the fix rather than failing on an import.

Planning and self-review

  • Plan before doing. When you ask for it, the assistant drafts an explicit plan first (which tools, skills, and MCP services it will use, what it will search for, where it needs to build a skill or connect a server) and then carries it out. Planning is per request, not a global mode: use the Plan toggle beside the message box, or send /plan <task>, which also works in the CLI.
  • Human review. Enable Review the plan before executing to pause a planned turn for your Approve / Edit / Reject; otherwise the plan runs automatically.
  • Adversarial review. An independent reviewer agent with no conversation context can critique the plan (Kokua re-plans on rejection) and/or the final answer before you see it (Kokua revises, up to [planning].review_rounds). Both are off by default and combine with human review, whose prompt shows you the critique. Reviewing the result means the answer cannot stream live: the agentic loop still streams, but the answer appears only once it passes.
  • Reviewers check their claims. Each reviewer is a tool-using agent that runs a bounded assessment over a curated verification toolset (current date/time, web lookup, arithmetic) before returning a verdict, so it can confirm recency and numeric claims instead of rejecting what it cannot verify from the request alone. The toolset excludes your memory, documents, skills, and MCP servers, keeping the reviewer an independent critic with no access to your state, and excludes execute_python and run_command because a reviewer cannot be approval-gated (see Security).
  • Show all reasoning. Turn it on for the full trace: every LLM call in a planned turn (planner, each reviewer, executor, each revision) streams live under a labeled phase header, and every intermediate version is shown. This overrides result review's hide-until-vetted gate; only the final answer is saved. The raw trace is recorded per turn, so reloading replays exactly what you saw rather than a summary.

Acting without you

  • A scheduled task is a turn with no human in it. Ask for something on a schedule ("every weekday at 9am, summarize my calendar") and the assistant persists it as a [scheduling.task.<name>] table in config.toml through its own schedule_task / list_scheduled_tasks / cancel_scheduled_task tools, so it survives restarts and can equally be hand-written and commented. Schedules are one-shot, interval, daily, or weekly. Because nobody is present to approve anything, a scheduled run auto-denies the approval-gated tools rather than waiting on a click that will not come.
  • Every firing gets its own conversation, nested under the task in the sidebar. Ask for a number and that task keeps only its newest N runs, deleting older ones as it goes (1 means each run replaces the last, 0 keeps everything); a task that names no number follows [scheduling] max_task_conversations, 3 by default. Nothing is deleted until a firing succeeds, so a failed run never costs you the last good one.
  • Pause, dry-run, stop, and edit in place. disable_scheduled_task stops a task firing while keeping it and enable_scheduled_task resumes it; run_scheduled_task fires it now without touching its schedule, reproducing exactly what the scheduled run would do; stop_scheduled_task ends a run under way without ending the task, leaving whatever it produced in its own chat. get_scheduled_task shows one task in full, prompt included, and update_scheduled_task revises any subset of its fields, keeping its id, its past runs, and everything you leave out, so changing a task's wording or its time edits that task rather than rewriting it from memory.

Reaching outside the chat

  • Images in and out. Attach an image and the assistant reads it (needs a vision-capable model): the composer's paperclip or a paste in the web UI, /attach <path> in the CLI. It can also generate images when $AIMU_IMAGE_MODEL is set (e.g. gemini:nano-banana, or a HuggingFace diffusers hf:<repo>); without it, no generation tool is offered at all. Images live in data/images/ and are served at /images/<name>; a conversation stores only a short reference, so sessions.json stays compact.
  • PDFs. The markdown-to-pdf skill renders Markdown to a PDF in data/downloads/, handing back the /download/<name> link the web UI serves. Install it with kokua skills install markdown-to-pdf and name it in an agent's available list. It ships as a skill rather than a toolset because its script declares fpdf2 and markdown inline (PEP 723) and uv resolves them per run, so neither is a Kokua dependency. Give it to an agent that also has fs and compute, which is what runs the script.
  • Email. The email-report skill mails information to you (digests, summaries, reports), written in Markdown and delivered as formatted HTML with a plain-text fallback, optionally attaching files already in data/downloads/ or data/images/. It can only email you: the address comes from the host's configuration and the script has no recipient flag at all. Configure [email] (host, port, from, to, use_ssl) and put the password in $KOKUA_EMAIL_PASSWORD, never in the config file (for Gmail, an App Password). Kokua passes those settings to the script's environment (by both routes a skill script can reach an agent: the entry agent's own and a spawned worker's), so the script never re-derives your config. Without host, to, and the password it sends nothing and says so. Install with kokua skills install email-report, name it in an agent's tools, and give that agent fs and compute so it can run the script.
  • AIMU agents. The aimu_agents toolset mounts AIMU's prebuilt orchestrators (code_review, research_report, create_content) and is the worked example of wiring an agent built with AIMU into Kokua: any Runner exposes .run(task) -> str, so a toolset is the whole bridge and the core learns nothing new. Nothing is mounted until you ask for it: name it in an agent's available list in config.toml. They are synchronous, so a nested run gets no sub-agent card, no /stop, and no approval gate on its workers; and an agent declaring fs + compute is a stronger reviewer than the tool-less CodeReviewAgent. Copy the shape, not necessarily the agents.
  • Backup. The github_backup toolset copies Kokua's own durable state (config.toml, memory, documents, authored skills, conversation transcripts) into a private GitHub repository as a git commit, and makes no commit when nothing changed. The tool takes no arguments at all: the repository, branch, and file list come from [github_backup] in config.toml, which is what makes it safe to run without per-call approval, and therefore usable from a scheduled task (a proactive turn auto-denies gated tools). The token comes from $GITHUB_BACKUP_TOKEN, never the config file, and never reaches a command line. Kokua refuses a public repository, and never force-pushes: a diverged remote is reported for you to reconcile. Logs, downloads, and generated images are left out. Restore is manual and documented. See Back up to GitHub.

In the browser

  • What the web front end adds on top. The sidebar lists conversations, each titled by the model from the message that opened it (the truncated message shows immediately and the written title replaces it a moment later, or stands as it is if the model has nothing to say, or if you set [assistant] generate_titles = false), with new/delete, a duplicate control that copies a whole conversation into a new one titled "Copy of " without moving you off the one you are reading, and a collapsible, resizable rail whose state is remembered per browser. The list sits under a foldable Conversations title carrying the count of rows it shows, the same furniture the tasks section below it wears, and each section remembers its own fold. Each turn carries two controls on the message that opened it: a branch control that forks a new conversation holding everything through that turn and switches to it, so you can take one exchange two ways, leaving the original in the list untouched; and a delete-from-here control that removes that turn and everything after it, so a transcript is a record you can tidy rather than one that only grows, with the turns before it and the conversation itself left as they were. Below it a tasks section lists each task's schedule and next firing with pause/resume, run-now (Stop, while a run is under way), and delete on each row, and that task's own conversations nested under it, so the chat list above holds only conversations you started. It hides when you have no tasks and collapses when you do. Every row carries a localized datetime caption, revealed on hover, that survives reloads. Replies render as GitHub-flavored markdown while they stream, via vendored marked + DOMPurify (no CDN): the page reparses the whole accumulated answer on a 50ms timer rather than committing each finished block, because a newline is not a block boundary in CommonMark and a committed prefix could be wrong in a way no later token could repair. LaTeX ($...$, $$...$$) is typeset by vendored KaTeX after sanitization with trust disabled, and only once the turn completes, since half-typed TeX is noise. If the server goes away, from a restart or a dropped connection, the page reconnects on its own with backoff and resyncs, rather than sitting on a dead socket until you reload, and the notice it shows counts down to its next attempt with a Retry now button for when you know the server is already back; a turn that was in flight when the socket dropped comes back as whatever it persisted, not as a live stream, and a tab refused because another one holds the session says so and stays put instead of retrying. The header names the model that is answering, beside the conversation title, with the whole record (the entry agent's model, and every agent whose own table declares one) in the label's tooltip: a model is read at startup and is not a runtime setting, so a running session otherwise has no surface that says which one it is, which is what /diag answers in the terminal. There is no settings window: the header's theme button is the one control the browser owns, cycling auto / light / dark and applied before first paint, and it is a per-browser preference rather than a config.toml setting. The CLI renders the same loop in plain text, one conversation on screen at a time, and reaches the others with /new, /conversations, and /switch.

Configuration

Settings come from a TOML file, so you don't repeat flags. Precedence, highest first: command-line flag > config file > built-in default. The file is read from --config <path>, else $KOKUA_CONFIG, else $KOKUA_HOME/config.toml (default ~/.kokua/config.toml).

The file is required. Kokua will not start without one, and will tell you to run kokua config init. The [agents.*] tables exist nowhere else and the assistant cannot work without at least one agent, so there is no useful "no config" state to fall back to. Every individual key still has a built-in default, so you set only what you want to change.

kokua config init           # writes the documented example; --force to overwrite

The scaffold comments every key at its default, so changing a default in a later release still reaches keys you left commented, and it ships the four [agents.*] tables live rather than commented, since an agent is the one thing Kokua cannot default. See config.example.toml for the full set at a glance, the configuration reference for what each key accepts and when it applies, and set up a toolset for the walkthrough of declaring an agent.

An agent table is a handful of keys:

[assistant]
agent = "assistant"          # which table below is the agent you talk to

[agents.assistant]
description = "The assistant the user talks to."
system_message = "You are a personal assistant running on the user's own machine. Be concise and helpful."
delegates_to = ["researcher", "coder", "introspector"]

[agents.assistant.tools]
available = ["memory", "documents", "skills", "config", "mcp", "scheduling", "conversations", "planning", "capabilities", "time", "benchmark", "messaging"]
resident = ["memory", "config", "conversations", "planning", "capabilities", "time", "messaging"]   # the rest load on demand

config.toml is also app-written: the assistant's own update_config tool and a runtime add_mcp_server write back to it, with your comments preserved. There is no second settings store. Which keys update_config refuses is [security] locked_config_keys, a list of patterns that is yours to set; it ships locking eight patterns, the first being the whole [security] section rather than any one key in it: the [security.auto_approval] sub-table, the never_auto_approve floor beneath it, and the lock list itself are all hand-edit-only by default. The default approval policy, [assistant.tools], has a pattern of its own (assistant.tools.*). Then [email] to, [paths] data_dir, the whole [agents.*] section (each agent's own available, confirm, and review included), and [reviewers.*] (a reviewer's prompt decides how the assistant's own work is judged), so which capability an agent holds stays your decision by default. locked_config_keys itself is always locked, whatever the list says, so the assistant can never unlock itself in one call. [scheduling.task.*] is refused too, but for a different reason that removing it from the list does not change: the assistant writes scheduled tasks through its own scheduling tools instead, because a bare update_config write would edit the file without arming or disarming the scheduler to match. [compute] command_env_passthrough is locked for a security reason of its own: without it the assistant could name one of its own credentials there and read it back out of a run_command child's environment.

State

All state lives under ~/.kokua (override the root with $KOKUA_HOME). The root holds config.toml; a single data/ directory holds all content:

~/.kokua/
  config.toml            # all settings; app-written as well as hand-edited
  data/
    sessions.json        # conversations
    skills/              # authored skills
    memory/              # semantic facts
    documents/           # saved documents
    downloads/           # generated files (e.g. PDFs), served at /download
    images/              # uploaded + generated images, served at /images

Point data/ elsewhere with [paths] data_dir. Nothing is written to your working directory or inside the installed package. Scheduled tasks are the one exception to "content lives under data/": each is a [scheduling.task.<name>] table in config.toml itself, since a task is a declaration you should be able to write and comment like any other setting.

Extending Kokua

Kokua discovers two kinds of plugin at runtime through Python entry points, so a third party adds capability by publishing a package, with no change to Kokua's core:

  • Front ends (kokua.frontends group): how the assistant runs -- terminal, web, a future Telegram or Slack. A front end is a kokua.plugins.FrontEnd whose run(config, args) drives the assistant.
  • Toolsets (kokua.toolsets group): one named capability an agent can declare. A toolset is a kokua.plugins.Toolset whose build(ctx) returns @aimu.tool callables, plus an optional guidance string appended to the prompt of any agent holding it. Installing it puts the name in the namespace; an agent still has to declare it.

The built-in cli / web front ends and Kokua's three built-in toolsets are registered exactly this way in Kokua's own pyproject.toml -- if the built-in path and the plugin path ever diverge, the plugin path is the broken one. To add your own from another package:

# in your package's pyproject.toml
[project.entry-points."kokua.toolsets"]
weather = "my_weather_pack:TOOLSET"

pip install it, and kokua --list-toolsets shows it. Its tools do not appear automatically to any agent your config describes: name it in an agent's [agents.<name>.tools].available list in config.toml, since a capability is declared and never defaulted. (An agent declaring available = "all" holds it from the next start, because every installed toolset is what that agent declared.) An agent that declares capabilities can compose a worker holding it for the length of one task with no config edit, which is the one route that does not go through a table. See toolsets/image.py for the template (one tool, and a build that returns nothing when its prerequisite is missing), and toolsets/aimu_agents.py for the same shape carrying a whole AIMU agent rather than a plain function. For something simpler than a toolset, add a skill instead: a directory with a script needs no packaging at all. Set up a toolset is the full walkthrough: what is in the namespace, how an agent declares its available toolsets and delegates_to, and exactly which mistakes fail at startup.

Security

Kokua can author and run Python/shell scripts as real subprocesses with your user privileges (no sandbox), and connect to remote MCP servers and run whatever tools they expose. Real capability is the point of a personal assistant, but it means a prompt-injected or mistaken model can run arbitrary code on your machine and call arbitrary remote tools. Only run Kokua with a model, inputs, and MCP servers you trust. The CLI prints a notice on startup.

Tool approval. The riskiest tools require confirmation before each call: a y/N prompt in the terminal, Allow/Deny buttons in the web UI. By default this gates skills.add_skill_script, mcp.add_mcp_server, compute.execute_python, compute.run_command, fs_write, and config.update_config; the shipped config puts the first, second, and last in [assistant.tools].confirm (asks you) and the rest in review (asks a reviewer first, if you have switched auto-approval on, and you otherwise). Every entry names the toolset providing the tool, so a gate can name a whole capability (fs_write gates every file writer AIMU ships, including one added in a later release) as readily as one call into it (compute.execute_python, leaving compute.calculate alone), and the reserved core prefix holds the tools no toolset provides, spawn_subagent and activate_skill, plus available_tools and load_tools once an agent defers a toolset. An entry that would gate nothing fails startup and says which of the five ways it does: a bare tool name with no prefix, the wrong toolset in front of a real tool, a toolset the policy's own agent cannot reach, a toolset no agent declares, or a toolset that provides no tools. The one entry startup cannot finish checking names a toolset an agent loads on demand that is not built yet (no agent holds it resident, and it is not a skill the entry agent already holds), since its tools do not exist until it is built: startup checks its shape, and the build checks the rest, refusing to load the toolset while the entry matches nothing (fix it and restart), so a misspelled gate never lets a tool through ungated. Adjust with [assistant.tools] (the default every agent inherits), an agent's own [agents.<name>.tools] confirm and review (a declared pair replaces the default whole), or --confirm-tools config.update_config,skills.add_skill_script (replaces only [assistant.tools].confirm for one run, so it must not name anything in review, and an empty value clears confirm but leaves review prompting; turning approval off means emptying both lists). Gating is by tool name at call time and resolved per agent: each agent's calls are judged by its own policy, a worker's gated call is routed to you, and the prompt names the agent asking. Tool access explains the shape. Proactive and backgrounded turns auto-deny these regardless, so the assistant never runs a full-access tool unattended, and an approval prompt only ever appears for the conversation you are currently viewing. A backgrounded turn's auto-deny raises an alert card naming the tool and linking the conversation, so a turn quietly losing a capability because you switched tabs is something you find out about; a proactive firing still denies in silence, since it reports itself when it ends.

A reviewer can answer an approval prompt for you, and it ships off. With [security.auto_approval] enabled, a gated call to a tool in a review list is first shown to a declared model reviewer ([reviewers.approval] in config.toml, prompt and model included, so you can read and change the standard it is held to). It answers three questions and writes one sentence; Kokua computes the outcome from those answers, and both outcomes appear as a card. This is convenience, not a boundary: a review can only turn a prompt into an approval, never widen what you could have approved yourself, there is no deny verdict, and every way a review can fail lands on the ordinary prompt, except switching conversations during the review itself, which denies rather than prompting since nobody is left watching the turn the prompt would appear on. Three tools are floored in [security].never_auto_approve because they grant rather than act (update_config, add_skill_script, add_mcp_server), and an unattended turn is never auto-approved. There is no sandbox under any of this and no shell parsing in front of it, so read Auto-approval before switching it on.

Capability is granted by hand. [agents.*] decides what every agent can call and what asks first, and it is locked by default in [security] locked_config_keys: update_config refuses it. So the assistant can connect an MCP server, but it cannot give itself or a worker a new server's tools in the session that connected it. After a restart, an agent with a list still needs an edit you make, while an agent declared available = "all" holds the server from the next start, so with such an agent in your config, approving add_mcp_server is granting the server to it. Removing agents.* from locked_config_keys is a hand-edit that hands the assistant its own capability table, so leave it in place unless that is what you want. Note the flip side, since it is easy to misread: there is no privilege tier among agents. An agent whose table declares config really does get update_config, and one declaring compute really does get execute_python and run_command. Your hand-edit is the consent, and the agent's approval policy is the gate at call time. Which is also why reading a file and writing one are two toolsets: fs reads, fs_write writes, and they are separate names because with one name there would be no way to write down "read a file and do not write one". The shipped introspector declares fs to read a transcript it was asked to evaluate, and evaluating a conversation is no reason to be able to rewrite one.

Injection crosses conversations. The assistant can read every saved conversation, and a transcript is untrusted text (a worker may have pasted web content into it), so an injection that lands in one conversation can influence what the assistant does in another. The read tools are ungated by default, since they only read and gating them would make an unattended scheduled run that reads history fail silently; add conversations.read_conversation and conversations.search_conversations to [assistant.tools].confirm if you would rather approve each one (or a bare conversations for the whole capability). rename_conversation is ungated too, and it is the one that writes: the worst an injection gets from it is a conversation misleadingly named, since a title is the only field it can touch and no history is lost. Gate it as well if a sidebar you can trust at a glance matters to you. export_conversation is ungated as well, and its risk is a different one: it changes no conversation and can only write inside data/downloads/ under a name taken from the conversation's own id, but what it leaves there is a whole transcript in the clear, in the folder the web UI serves unauthenticated. Gate it if that folder is what you are protecting.

The reviewer needs no gate. With adversarial review on, the reviewer is a tool-using agent, and an autonomous critic cannot pause to ask you mid-review. Rather than exempt it from the approval gate, its verification toolset holds nothing the gate exists to cover: web lookup, calculate, and the clock, with no execute_python or run_command, no memory or document access, and no MCP mutation. A test pins this against the shipped [assistant.tools] default, so adding a name there fails the suite until the reviewer's toolset is re-checked.

Development

uv sync --all-extras                                  # installs the editable ../aimu + all extras
uv run ruff check . && uv run ruff format --check .
uv run pytest -q                                      # mock-only: no model, network, or keys
uv run pytest -m e2e                                  # opt-in browser tests (playwright install chromium)
uv run --with build --with twine python -m build && uv run --with twine twine check --strict dist/*

That last line is what CI's package job runs before installing the wheel into a clean environment. An editable install imports straight from src/, so it cannot catch a resource missing from [tool.setuptools.package-data] or a console script pointing at a moved function; only a built wheel can.

src/kokua/ is grouped by subsystem, and tests/ mirrors it exactly:

core/         the transport-agnostic runtime: assistant, conversations, turns, interaction,
              settings_runtime, diagnostics, build, agent_registry, turn_gate, messages, errors
config/       the settings schema, the TOML file, the writers, the runtime-settings table
workflows/    the workflow protocol (the two tiers) and planning/, the /plan pipeline it carries
mcp/          remote MCP servers and their OAuth
scheduling/   recurrence math, the durable task lifecycle over config.toml, the agent-facing tools
channels/     ChannelUI plus the concrete channels
frontends/    cli, web        -- registered as plugins, exactly like a third party's would be
registry/     the Toolset type, name resolution, and the live state a toolset is built against
toolsets/     one file per toolset, and nothing else

Outside src/, the repository also carries skills/: Agent Skills Kokua ships as content rather than as Python, so they are not in the wheel. kokua skills install copies them into your skills folder.

scripts/ holds one-off maintenance scripts, also outside the wheel: run one by hand from a checkout, never as an installed command. scripts/migrate_subagent_payloads.py rewrites a sessions.json written before the payload-file cap in core/subagents.py existed, moving each oversized sub-agent tool response it already stored inline out to a payload file, matching what a current Kokua writes for a new one. It is safe to delete once every installation has run it once. Run --dry-run first to see what it would change before running it for real; it backs up sessions.json before its first write. Stop Kokua before a real run (including --prune-orphans) and pass --confirm-kokua-stopped: the script cannot reliably detect a running instance, and a live Kokua writing to the same files at the same time can silently lose data on either side, in a way the backup would not show (see the script's own module docstring and --help for the two specific failure modes). --prune-orphans also refuses a custom sessions-file argument, since it always deletes from the one configured payloads directory.

The stable public import surface is kokua.plugins, kokua.config, kokua.core, kokua.channels.web, and kokua.images. Everything else is internal and may move.

Resources

Kokua

AIMU

Kokua is a thin application over AIMU, so most capability questions are really AIMU questions. The primitives Kokua wires together:

Contributing

See CONTRIBUTING.md for setup, checks, where a new module goes, and PR conventions.

License

Apache 2.0. See LICENSE.

About

A fully modular personal assistant application built with plain Python and designed for learning how agentic systems work

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages