System Prompts
Last verified: 2026-05-30
Kai has several distinct prompt-construction paths. Each one is built by a pure function with explicit inputs (no DI, no suspend, no resource loading, no clocks) and is covered by a unit-test suite so future edits don't silently break unrelated variations.
Paths
| Path | Builder | Test | Used by |
|---|---|---|---|
| Chat (remote) | buildChatSystemPrompt(variant = CHAT_REMOTE, …) |
ChatSystemPromptBuilderTest |
Any remote service (OpenAI, Anthropic, Gemini, etc.) via RemoteDataRepository.ask() |
| Chat (on-device) | buildChatSystemPrompt(variant = CHAT_LOCAL, …) |
ChatSystemPromptBuilderTest |
LiteRT via RemoteDataRepository.askWithLocalEngine() |
| Heartbeat | buildHeartbeatPrompt(…) |
HeartbeatPromptBuilderTest |
TaskScheduler via HeartbeatManager.buildHeartbeatPrompt() — sent as a USER message, not a system prompt |
| Splinterlands LLM picker | buildLlmPrompt(…) in SplinterlandsTeamPicker.kt |
SplinterlandsTeamPickerPromptTest |
SplinterlandsBattleRunner — fully isolated, does not use the chat prompt builder |
The on-device tool allowlist (LOCAL_TOOL_ALLOWLIST in RemoteDataRepository.kt) is locked in by LocalToolAllowlistTest. Any rename or removal of a tool in that set fails the test loudly.
Chat variants
SystemPromptVariant in ChatSystemPromptBuilder.kt controls which sections are composed. Each if (variant == …) block in buildChatSystemPrompt is the single source of truth for where a section belongs — there is no post-hoc regex stripping.
| Section | CHAT_REMOTE |
CHAT_LOCAL |
Gating input |
|---|---|---|---|
| Soul | always | always | soul param (non-empty) |
| Honesty rule | always | always | baked into DEFAULT_HONESTY_RULE constant — one inline sentence ("Do not fabricate tool outputs, file contents, citations, or completed work"). Guards observed regressions where models invented tool output and where kai-ui button labels implied operations the callback couldn't perform. No ## header — one sentence doesn't earn a section |
## Tool Use |
when tools available | when tools available | baked into DEFAULT_TOOL_USE_SECTION constant — tells the model to reach for tools to resolve ambiguity, check tool availability before declaring a capability unavailable, prefer self-lookup over asking the user, and extract signal from noisy output. Gated on hasTools: with every tool disabled (or a model that doesn't support tool calls) the section is dropped rather than telling the model to use tools it doesn't have. Soul customization still can't drop it while tools exist |
## When to Act |
always | always | baked into DEFAULT_ACTING_SECTION constant — caps clarifying questions at one, only when genuinely blocked; demands recovery after a failed first attempt; mandates seeing work through to a usable result. Always rendered (both variants), same rationale as above |
| Memory instructions (basic) | when provided | when provided | memoryInstructions param |
## Structured Learning |
when memory enabled (remote-only) | never | baked into DEFAULT_STRUCTURED_LEARNING_SECTION constant. Gated on memoryEnabled — it references memory_learn / memory_reinforce, which are absent when memory is off |
## Your Memories |
when list non-empty | when list non-empty (budget-capped) | generalMemories |
## User Preferences |
when list non-empty | when list non-empty (budget-capped) | preferenceMemories |
## Learnings |
when list non-empty | when list non-empty (budget-capped) | learningMemories (reinforcement count rendered) |
## Known Issues & Resolutions |
when list non-empty | when list non-empty (budget-capped) | errorMemories |
## Automation |
when scheduling enabled (remote-only) | never | baked into DEFAULT_AUTOMATION_SECTION constant — teaches the AI that all future/recurring/heartbeat-standing behaviour goes through schedule_task with one of three triggers (execute_at, cron, on_heartbeat). Heartbeat schedule itself is user-controlled. Gated on schedulingEnabled — it references schedule_task / list_tasks / cancel_task, which are absent when scheduling is off |
## Email Accounts |
when list non-empty (email enabled) | never | emailAccounts — connected address, unread count, last sync, or last sync error. The section also embeds a multi-sentence "Sending policy" rule instructing the model to draft a message and confirm with the user before invoking compose_email or reply_email, so the per-account summary is not the only payload |
## Scheduled Tasks |
when list non-empty | never | pendingTasks (time/cron only; heartbeat-trigger tasks live in the next section) |
## Heartbeat Additions |
when list non-empty | never | heartbeatAdditions — standing schedule_task(on_heartbeat=true) entries the AI can see/reference/cancel |
## Active skill: <name> |
when activated | when activated | activeSkill param — non-null only when the user prefixed the current turn's message with /<skill-id> matching an installed, enabled skill. Emits the skill's instruction body plus a list of any bundled files (available at ~/skills/<id>/ in the sandbox). Zero bytes on every other turn. See skills.md |
## Context |
always | always | runtime param (local time with offset + IANA zone, UTC, platform, model, provider). Local time leads so the model anchors on the user's wall clock when computing relative times |
## Dynamic UI |
when uiMode = DYNAMIC_UI |
never | uiMode param |
## Interactive UI Mode |
when uiMode = INTERACTIVE_UI |
never | uiMode param |
Memory budget for CHAT_LOCAL: the four memory category sections share a combined char budget (LOCAL_MEMORY_BUDGET_CHARS, currently 2000 chars). Entries are appended in order (general → preferences → learnings → errors); the next entry that would push the combined size past the budget is dropped, and all subsequent entries are dropped too. Truncation happens at entry boundaries, never mid-entry. If no entries in a category fit, that category's header is not emitted either.
Why two variants:
CHAT_REMOTE— full chat prompt for remote services. No limits.CHAT_LOCAL— trimmed chat prompt for on-device LiteRT. Small Gemma models (2-4B params) can't coherently attend to the full remote prompt, so Structured Learning, Automation, Email Accounts, Scheduled Tasks, Heartbeat Additions, and kai-ui sections are dropped, and memory sections are char-capped. The model still gets soul + basic memory guidance + memories (up to the cap) + runtime Context — memories matter becausememory_store/memory_forget/memory_reinforceare all in the local tool allowlist, and a memory might have been learned via a remote model in an earlier turn.
Interactive UI mode is not available on on-device services — the kai-ui component schema is too large for small Gemma models to coherently attend to, and in practice the model can't produce valid kai-ui JSON. The "Start interactive mode" button in the empty-state UI is hidden when the primary service is on-device (see ChatScreen.kt). If you have LiteRT selected and need interactive UI mode, switch to a remote service first.
Heartbeat sections
buildHeartbeatPrompt has no variant — it's a single shape sent as the user message.
| Section | Gating input |
|---|---|
| Opening prompt | customOrDefaultPrompt (custom or DEFAULT_HEARTBEAT_PROMPT) |
## Heartbeat Additions |
heartbeatAdditions non-empty — appended standing instructions from schedule_task(on_heartbeat=true) tasks |
## Previous Heartbeat Results |
recentResponses non-empty |
## Pending Tasks (with optional cron annotation) |
pendingTasks non-empty |
## Email Status (per-account unread count + last sync) |
emailAccounts non-empty |
## New Emails (unread header snapshots awaiting triage) |
pendingEmails non-empty |
## New SMS (unread message snapshots awaiting triage) |
pendingSms non-empty |
## New Notifications (captured Android notifications awaiting triage, newest first by post time, capped at 20) |
pendingNotifications non-empty |
## Promotion Candidates (with hit counts + category) |
promotionCandidates non-empty |
How to add a new section
- Decide which variant(s) need the section. For the chat builder, that's
CHAT_REMOTE,CHAT_LOCAL, or both. For heartbeat, it's always "present iff gating input is non-empty". - Add the input parameter to the builder's signature.
- Add the
if (variant == …)block or conditional in the builder body — composition is explicit, not post-hoc. - Add a focused test in the corresponding
*BuilderTestclass that verifies the section is present when gated on, absent otherwise. - Update this doc's table with the new row.
- Update the wrapper (
RemoteDataRepository.getActiveSystemPromptorHeartbeatManager.buildHeartbeatPrompt) to thread the new input through fromAppSettings/ stores. - Run
./gradlew :composeApp:desktopTest --tests '*BuilderTest'to confirm nothing else broke.
Key files
| File | Purpose |
|---|---|
composeApp/src/commonMain/.../data/ChatSystemPromptBuilder.kt |
Pure builder + SystemPromptVariant + section helpers + DEFAULT_STRUCTURED_LEARNING_SECTION |
composeApp/src/commonMain/.../data/HeartbeatPromptBuilder.kt |
Pure heartbeat builder + input data classes |
composeApp/src/commonMain/.../data/RemoteDataRepository.kt |
getActiveSystemPrompt(variant) wrapper that gathers inputs from AppSettings / MemoryStore / TaskStore and calls the pure builder |
composeApp/src/commonMain/.../data/HeartbeatManager.kt |
buildHeartbeatPrompt() wrapper that gathers inputs and calls the pure builder |
composeApp/src/commonMain/.../data/AppSettings.kt |
DEFAULT_MEMORY_INSTRUCTIONS (basic block, used by both variants). The advanced ## Structured Learning block lives in ChatSystemPromptBuilder.kt and is remote-only |
composeApp/src/commonMain/.../splinterlands/SplinterlandsTeamPicker.kt |
buildLlmPrompt (separate path, independent contract) |
composeApp/src/commonTest/.../data/ChatSystemPromptBuilderTest.kt |
Focused + golden tests for every chat variant section |
composeApp/src/commonTest/.../data/HeartbeatPromptBuilderTest.kt |
Focused + golden tests for every heartbeat section |
composeApp/src/commonTest/.../data/LocalToolAllowlistTest.kt |
Lock-in test for the on-device tool allowlist |
composeApp/src/commonTest/.../splinterlands/SplinterlandsTeamPickerPromptTest.kt |
Focused tests for the Splinterlands LLM picker |