@kaja/nasi
The agent brain: an OpenAI-compatible tool loop, a store interface and the built-in tools. Every front door (terminal, Telegram, cloud chat, widget) runs this loop. What differs is who hosts it and what it may touch.
The package has no Ink, Hono, Better Auth, sqlite or pg, and reads no config files. The host injects everything: a store, a model client, prompt context, and whether local tools are on.
Hosts
---
config:
look: handDrawn
theme: neo-dark
---
flowchart LR
subgraph N["@kaja/nasi"]
AG["Agent + run()"]
TL["tools registry"]
ST["NasiStore<br><small>interface</small>"]
end
CLI["apps/tui --local"] -->|"builds Agent directly<br>includeLocalTools: true"| AG
API["apps/api /nasi"] -->|"Nasi.open()<br>includeLocalTools: false"| AG
WID["apps/api /widget"] -->|"Nasi.open()"| AG
LITE["apps/tui cloud"] -->|"@kaja/nasi/client<br>HTTP + SSE"| API
ST -.-> SQ[("SQLite<br><small>apps/tui</small>")]
ST -.-> PG[("Postgres<br><small>apps/api</small>")]
ST -.-> MEM[("in-memory<br><small>tests</small>")]
| Host | How it runs the loop | Store | Local tools |
|---|---|---|---|
CLI --local | builds an Agent and calls run() in-process | SQLite | âś“ |
API /nasi | Nasi.open() scoped to the signed-in account | Postgres | âś— |
API /widget | Nasi.open() scoped to the key’s owner, namespaced per visitor | Postgres | ✗ |
| CLI cloud | no loop at all, just @kaja/nasi/client over HTTP | (server’s) | ✗ |
Two entry points
| Import | What you get |
|---|---|
@kaja/nasi | Nasi, Agent, run(), the store interface, the tools. Pulls in the loop |
@kaja/nasi/client | createNasiClient only, the HTTP/SSE client. Must never import the loop |
That split keeps the cloud CLI small: it ships the client, not the agent.
Inputs and outputs
The API and widget hosts don’t touch Agent/run() directly. They go through Nasi, which loads a session, runs one turn and saves it back.
const nasi = await Nasi.open({
store, // NasiStore — Postgres for API/widget
chat: { client, model }, // OpenAI-compatible client + default model id
includeLocalTools: false,
personas, // this account's/persona's roster, or []
promptContext, // environment/askUser/language overrides
owner, // null, or a namespaced widget-visitor id
deps // extra tool deps, merged over `chat` — gates dep-conditional tools
})
const response = await nasi.turnBuffered({ session, message, includeThinking, language })
// or: for await (const event of nasi.turn({ ... })) { ... }
In: NasiTurnInput has session (a UUIDv7 to resume, omitted to start fresh), message (1 to 32,768 chars), and optional includeThinking, language and personaId.
Out: NasiTurnResponse, the same shape whether you awaited turnBuffered() or drained turn()’s generator to its return value:
| Field | Meaning |
|---|---|
session | the session id, passed back on the next turn |
status | completed / needs_input / needs_approval / needs_client_tool / error |
message | the reply, or the pending question/command when not completed |
steps | ordered NasiStep[] (reasoning, messages, tool calls, handoffs) for rendering a transcript |
thinking | full reasoning text, only when includeThinking was set |
usage | promptTokens, which model actually served the request, and its contextWindow when known |
turn() yields every AgentEvent (token-level deltas included) while the turn runs, then returns that same NasiTurnResponse once saving finishes. That’s how the API can stream SSE and still end with one consistent shape.
The loop
run(agent, prompt, session, owner) is an async generator. It yields events as they happen and mutates the Session you hand it. Saving is the host’s job.
| Event | When |
|---|---|
delta | token chunks, on either the reasoning or content channel |
reasoning / message | the complete text for one round |
tool_call | the model invoked a tool |
tool_image / display_image | an image to feed back as vision / to show in the UI only |
ask_user | stop and wait: the next prompt becomes the tool result |
confirm_command | stop and wait for shell approval |
confirm_tool | stop and wait for approval of an HTTP tool or MCP call that changes something |
client_tool_call | stop so the client can run read_file / list_files on the user’s own disk (cloud only) |
persona_switch | the persona (and maybe the model) changed mid-turn |
compacted | the conversation was summarised before a round (beforeTokens, afterTokens, dropped), see Long conversations |
condensed | an oversized tool result was condensed before a round (tool, beforeTokens, afterTokens) |
final | the turn is done |
usage | prompt tokens, the model that served the request, and its context window |
Three tools are intercepted instead of executed normally: ask_user, run_command and switch_persona. That’s how the loop hands control back to the host.
A trailing ? on otherwise-final assistant text is also surfaced as ask_user, so a rhetorical question doesn’t stall an HTTP turn.
Inside one turn, run() loops rounds of “call the model, run any tool calls, call the model again”. It ends when a round has no tool calls (final or ask_user), or when a tool call needs a human (ask_user or confirm_command, which sets session.pendingAskUserId or pendingRunCommandId and returns). A round that comes back completely empty, with no text and no tool call, is nudged and retried up to 5 times before falling back to a fixed “I’m drawing a blank” message, so the app never renders a blank turn.
---
config:
look: handDrawn
theme: neo-dark
---
flowchart TD
Start(["run(agent, prompt, session, owner)"]) --> Sys{"session empty?"}
Sys -->|yes| Build["buildSystemPrompt()<br>instructions + env + tool<br>contracts + personas +<br>sticky memory + language"]
Sys -->|no, resuming| Push
Build --> Push["push prompt<br>(or pending tool result)"]
Push --> Fit["condense oversized tool results<br>compact if past compactAt"]
Fit --> Call["streamRound()<br>OpenAI chat.completions.stream<br>(too long: compact, retry once)"]
Call -->|"delta events"| Call
Call --> Empty{"empty round?"}
Empty -->|"yes, retries left"| Nudge["push a nudge message"] --> Fit
Empty -->|no| Calls{"tool_calls present?"}
Calls -->|none| Final["final or ask_user<br>(trailing '?' backstop)"] --> Done(["return"])
Calls -->|yes| Dispatch{"which tool?"}
Dispatch -->|"ask_user"| AskEv["yield ask_user<br>set pendingAskUserId"] --> Wait(["return — wait for host"])
Dispatch -->|"run_command"| Risk{"matches a safe pattern,<br>no metacharacters,<br>not dangerous,<br>no credential path?"}
Risk -->|yes| AutoRun["run immediately<br>result → messages"] --> Fit
Risk -->|no| ConfirmEv["yield confirm_command<br>set pendingRunCommandId"] --> Wait
Dispatch -->|"switch_persona"| Switch["applyPersona()<br>rewrite system message<br>maybe swap model"] --> Fit
Dispatch -->|"any other tool"| Exec["tool.execute(args, ctx)<br>ctx: owner, personaId, store"]
Exec -->|"text or images"| Result["result → messages<br>images also yielded for vision"] --> Fit
classDef decision fill:#161b22,stroke:#58a6ff,color:#e6edf3
classDef action fill:#0d1117,stroke:#1f6feb,color:#e6edf3
classDef stop fill:#161b22,stroke:#3fb950,color:#e6edf3
class Sys,Empty,Calls,Dispatch,Risk decision
class Build,Push,Fit,Call,Nudge,AutoRun,Switch,Exec,Result action
class Final,Done,AskEv,Wait,ConfirmEv stop
Long conversations
Before every round, run() keeps the request inside the model’s context window. Session.messages is never shortened. Only what the model is sent changes (contextMessages()).
- The window is
Agent.contextWindow. A host can set it (the cloud does, per model row). OtherwiseresolveContextWindow()finds it from the agent’smodelsentry:context_windowfrommodels.toml, else what the server reports (llama.cpp/props, Ollama/api/psand/api/show, an OpenAI-compatible/modelsentry, Fireworks’ model API, all asked at once), else 32,768. The answer is cached per server and model. - Oversized tool results (over a quarter of the window) are condensed once by
condenseOversizedResults(), told which call produced them and what the user asked. The model gets the condensed text (Session.toolSummaries, by call id), and the tool message keeps the full output. - Images from before the last two user prompts are sent as a short note. The model saw them when they were new, and resending the bytes every round is costly. The log keeps them. The cloud store doesn’t even download the bytes of images the latest summary covers.
- Compaction. When a character-count estimate, scaled each round to what the provider actually counted, passes
Agent.compactAt(0.8 by default) of the window,compactSession()summarises everything before a tail that fits a quarter of it. The tail starts at a user turn where it can, never at a tool result.Session.summary({ text, from }) is then appended to the system prompt, followed bymessages[from..], and acompactedevent is yielded. If the summary call fails, the older part is dropped with a note instead (dropped: true). - The summarizer is
Agent.summarizer(thesummarizetask’s model), else the chat model. Text too big for its window is summarised in parts, then the parts together. Every request it makes (compacting, condensing, thesummarizetool throughToolContext.onModelCall) goes intoSession.telemetry.modelCalls, which the stores save as model call rows. - Too long anyway. When the provider rejects the prompt as too long, the known window is lowered below it (for this process,
lowerContextWindow()), the session is compacted, and the round is retried once. A second failure surfaces asModelUnavailableError, like any provider error. - On demand.
compact(agent, session, focus?)(andNasi.compact(sessionId, focus?)for a stored session) summarises everything but the latest turn, withfocussteering what the summary keeps. It backs/compactin the terminal, both Telegram bots andPOST /nasi/compact.
Stores keep every summary and every condensed result beside the conversation, see Database.
Turn statuses
Over HTTP the same loop is buffered into one response:
status | Meaning |
|---|---|
completed | the turn finished, and message is the reply |
needs_input | ask_user is pending: send the answer as the next message |
needs_approval | a tool call waits for the user’s OK (a confirm_tool step): send approval next: "approve", "approve_session" (also stops asking about that tool for the rest of the session) or "decline". The server runs the call it saved, never one the client describes. A plain message instead skips it |
needs_client_tool | the model asked for read_file or list_files, which only the client can run on its own disk: the client runs it and sends the output as the next message |
error | the turn failed |
session comes back on every response. Send it again to continue the conversation.
System prompt
buildSystemPrompt() assembles the system message when a session’s message list is still empty. Resuming a session reuses it, with two exceptions: the skill and persona sections are refreshed at the start of every turn (so an ability change applies from the next message), and a persona switch rewrites it in place. It concatenates whichever of these blocks apply, in order:
- the persona’s
instructions(or none, for the default agent) ## Environment: the OS/home line, or the host’s override (PromptContext.environment)## Tool contract: ask_user, only when that tool is in the registry. Cloud hosts override the terminal-flavoured default withPromptContext.askUserInstruction## Tool contract: run_command, only whenincludeLocalToolsexposed it## Tool contract: memory, only whenremember_noteis in the registry## Personas: the roster and switching rules, only with more than one persona andswitch_personaavailable## Skills: the enabled skills’ names and descriptions, whenload_skillis available## Dataset collection, only when the persona is bound to a dataset topic## About the user: the answers to a profile dataset, such as onboarding- sticky memory notes (from the store, or
PromptContext.loadStickyNotes) - a reply-language instruction (
PromptContext.replyLanguageInstruction)
Every block depends on what’s actually wired up, so a cloud turn’s prompt is a subset of what a local --local session sees.
Store and ownership
NasiStore is a plain interface over sessions, memory notes and dataset answers. Nasi never opens a database itself. Three implementations exist: SQLite (CLI), Postgres (API) and in-memory (tests). A session is stored as rows (a message per row, a row per tool call), with each assistant step’s model, tokens and latency. The Database page compares the two real schemas.
owner separates rows inside one store: null for a terminal session, a namespaced id for a Telegram user or widget visitor. Resuming a session whose owner doesn’t match raises SessionNotFoundError, which is what stops two widget visitors on one account from reading each other’s chats.
Abilities
Skills, HTTP tools, MCP servers and personas come from an AbilityStore the host provides. The CLI reads the marketplace/ folder through createFolderAbilityStore, and the API reads its Postgres copy of the catalog. loadAbilities turns a store’s enabled abilities into extra tools for the host to append. One that can’t load (a broken manifest, a required key the user hasn’t saved) is left out instead of stopping the agent. See Skills, Tools and Personas for what each kind does.
Warnings
Nasi never logs. A recoverable problem (a skipped ability, a missing key, a failed MCP connection, an unreachable fetch proxy) goes to a handler the host installs with setWarnHandler, silent until set. The API prints them, and the terminal appends them to its opt-in log file, since Ink owns the screen.
Tool exposure
createTools({ includeLocalTools }) decides the registry. The default is off: only an explicit allowlist of cloud-safe built-ins is returned, so a newly added tool is never cloud-exposed by accident. Turning it on adds file, shell, MCP and code tools. See Tools for the resulting list.
Some built-ins also need a dep, and only register when the host supplies it: generate_image needs imageGeneration, and cloud fetch_url needs fetchProxy. A local registry exposes fetch_url unconditionally, since it fetches from the user’s own machine. The cloud egresses from the server, so without a proxy the tool is left out instead of fetching directly. Failing closed is deliberate: a proxied fetch that can’t reach its proxy raises ProxyUnavailableError instead of retrying direct, which would silently defeat the point of configuring one.
read_file and list_files reach a cloud turn only as stubs that pause it with client_tool_call, for the terminal to run on the user’s machine. clientTools: false (on createTools and Nasi.open) leaves them out for hosts with no such client: the API’s widget and Telegram turns.
Next: