# Nekko Agent documentation > Every page of https://nekkoagent.com/docs in one file. Generated from the Markdown in https://github.com/nekko-labs/nekko-agent. # Getting started Nekko Agent is AI help on your computer, for coding and everyday work. It reads, edits, searches and runs things in your projects, using AI that runs on your own machine, an online service you already pay for, or both. It is free, open source and MIT licensed. ## Who we are Nekko Agent is made by [Nekko Labs](https://nekkolabs.com). We develop it in the open on [GitHub](https://github.com/nekko-labs/nekko-agent): the code, the plan and the task list are all public. We care about three things: - **Your computer, your choice.** You pick which AI runs, what it can touch and when it needs your approval. Work that uses only local models stays on your machine. - **Honest software.** The app tells you what a model will cost, whether it will fit in memory and what it is about to do. These docs mark what is planned as planned. - **Fast and calm.** The interface never waits on the agent, and responsiveness is measured on every change. ## What Nekko Agent does - **Use AI on your computer or online.** Run models with the built-in engine, connect Ollama, LM Studio or vLLM, or sign in with a ChatGPT subscription. Mix them in one workspace. - **Pick the right AI for each job.** Use a capable model for hard work and a small local one for simple tasks. Local models have no per-token bill. - **Work in your real projects.** The agent reads, edits and runs code in the folders you add, with diffs to review, guardrails and an optional sandbox. - **Automate repeat work.** Schedule tasks and save workflows, with retry limits and a visible history. - **Take it with you.** Pair your phone to drive the agent on your computer from anywhere, end-to-end encrypted. - **Plug it into your tools.** Use it from the terminal, or as an MCP server inside Claude Code, Codex, Cursor and other agents. ## Install the app The quickest way to start is the installer. 1. Download the installer for your computer from [nekkoagent.com](https://nekkoagent.com/#download) or the [latest GitHub release](https://github.com/nekko-labs/nekko-agent/releases/latest): - **Windows:** the `.exe` installer - **macOS (Apple silicon):** the `.dmg` - **Linux:** the `.AppImage`, or the `.deb` for Debian and Ubuntu 2. Run it. On macOS, drag Nekko Agent into Applications. 3. Open Nekko Agent, add a project folder and choose a model on the **Models** tab. macOS builds are signed and notarized, so they open normally. If Windows shows an "unknown publisher" prompt for an unsigned build, choose **More info**, then **Run anyway**. ## Run it from source Running from source gives you the newest code and is how you work on Nekko Agent itself. It takes four steps: install the prerequisites, clone the repo, install the dependencies, and run `npm run dev`. ### 1. Install the prerequisites You need three tools. Check what you already have: ```bash git --version node --version cargo --version ``` **Git.** Any recent version. Get it from [git-scm.com](https://git-scm.com/downloads), or on macOS run `xcode-select --install`. **Node.js 22.12 or newer**, which includes npm. Install the LTS release from [nodejs.org](https://nodejs.org), or use a version manager such as [nvm](https://github.com/nvm-sh/nvm) or [fnm](https://github.com/Schniz/fnm). The project uses npm workspaces, not pnpm or yarn. **Rust**, through [rustup](https://rustup.rs). The engine daemon (`nekkod`) is written in Rust and `npm run dev` compiles it. You do not need to choose a version: the repo pins one in `rust-toolchain.toml` and rustup installs it on the first build. ```bash # macOS and Linux curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh ``` On Windows, download and run `rustup-init.exe` from [rustup.rs](https://rustup.rs). Rust also needs a C and C++ toolchain on each platform: | Platform | Install | | --- | --- | | Windows | Visual Studio Build Tools with the **Desktop development with C++** workload (rustup offers to install it) | | macOS | Xcode Command Line Tools: `xcode-select --install` | | Debian, Ubuntu | `sudo apt install build-essential pkg-config libssl-dev` | | Fedora | `sudo dnf install gcc gcc-c++ make pkgconf-pkg-config openssl-devel` | On Linux, the engine's model runtime needs the GCC 13 C++ runtime or newer (Ubuntu 24.04, Debian 13 or later). ### 2. Clone the repo ```bash git clone https://github.com/nekko-labs/nekko-agent cd nekko-agent ``` ### 3. Install the dependencies ```bash npm install ``` ### 4. Start the app ```bash npm run dev ``` This builds the Rust engine daemon and the app, then opens the desktop app. The first run compiles the daemon in release mode and takes several minutes; later runs only rebuild what changed. To stop it, press **Enter** or **q** (or Ctrl+C) in the terminal. The app quits normally and stops the engine and any model servers it started. ### Other ways to run it | Command | What it does | | --- | --- | | `npm run web` | The same app in your browser at `http://localhost:1440` | | `docker compose up` | The Local Web UI inside Docker, with your workspaces mounted as volumes | | `npm run build` | Build everything without launching | | `npm test` | Run the tests in every workspace | ## Next steps - Take the [walkthrough](https://nekkoagent.com/docs/walkthrough) for a tour of the app. - Set up [local models](https://nekkoagent.com/docs/local-models) or [sign in with a subscription](https://nekkoagent.com/docs/subscriptions). - [Pair your phone](https://nekkoagent.com/docs/remote) to use the agent away from your desk. - Want to help build it? Read [Contributing](https://nekkoagent.com/docs/contributing) and the [architecture](https://nekkoagent.com/docs/architecture). ### Closing the desktop UI and notifications Closing the desktop window hides it to the tray; the engine and running work remain alive. This is not Quit. **Quit Nekko Agent** stops the service and managed processes; **Stop Nekko service** stops the service while leaving the UI available. This does not promise execution during OS sleep, shutdown or logout. **Settings > Notifications > Desktop notifications** is on by default. The desktop's main process listens independently of the page for completed replies, failures, questions and approvals. A user Stop is not a failure. Clicking a native notification restores the UI and opens the chat. Turn the toggle off to opt out; notices can include the chat title and question, error or approval text. Browser editions use browser notification permissions instead. The 2026-10-13 batch is implemented and unit/fixture interaction tested, not yet landed. Image inspection and native toast delivery/click/permission testing remain pending. Known issue under review: the native listener currently also posts for a focused visible chat. ### Create an agent, media session or terminal Hover anywhere on the left navigation panel to open it and see each destination's name. On macOS, **Nekko Agent → Settings…** (⌘,) opens Settings and **About Nekko Agent** shows the About page. Hover over the sidebar **+** to choose **Agent**, **Create or Edit Photos and Videos** or **Terminal**. Moving away closes the menu after a short grace period. Terminal uses your default shell; choose its type in **Settings → Terminal → Default shell**, or leave **Auto-detect** selected. Create or Edit Photos and Videos opens an image session; video generation is not available. ### Approvals and delegated work When a tool needs permission, its approval card appears in the chat window, not the separate wall composer. An unrelated tool finishing does not dismiss that request. Read the exact input before choosing Approve or Deny. For an identified subagent, **Open ↗** beside its activity row opens the child chat using the existing workspace navigation. The chevron still expands task details separately. If the app cannot safely associate a delegation with one child (including older activity after a restart), use the Agent Monitor subagent list instead; the row does not guess which chat to open. ### Delegated agents in the Agents panel Delegated subagents appear as compact rows beneath their parent. Opening a child transcript does not add a duplicate full agent card. Closing the parent's windows keeps the saved parent and its nested children accessible. --- # Nekko Agent walkthrough A guided tour of Nekko Agent, from install to your first agentic edit. Screenshots are from the running app. - [1. Install](#1-install) - [2. Connect a model](#2-connect-a-model) - [3. The unified chat](#3-the-unified-chat) - [4. The Context Inspector](#4-the-context-inspector) - [5. Guardrails & sandbox](#5-guardrails--sandbox) - [6. Projects & the codebase index](#6-projects--the-codebase-index) - [7. Memory](#7-memory) - [8. Connectors](#8-connectors) - [9. The mascot](#9-the-mascot) --- ## 1. Install **Download a build** for your platform from the [Releases page](https://github.com/nekko-labs/nekko-agent/releases/latest): | Platform | Artifact | | --- | --- | | Windows | `NekkoAgent--x64.exe` (NSIS installer) | | macOS | `NekkoAgent--arm64.dmg` (Apple Silicon) | | Linux | `.AppImage` or `.deb` | **Or run from source:** ```bash git clone https://github.com/nekko-labs/nekko-agent cd nekko-agent npm install npm run dev # launches the desktop app ``` Nekko Agent stores everything locally (under your OS app-data dir): settings, sessions, memory, and a usage log. No account, no telemetry. --- ## 2. Connect a model Open the **Models** tab. This is where Nekko Agent differs from most tools, local model servers are first-class, not an afterthought. ![Models tab](https://nekkoagent.com/docs/media/models.png) - **Auto-discover local** probes `localhost:11434` (Ollama), `:1234` (LM Studio), and `:8000` (vLLM). Anything running is added with one click. - Connected local servers and cloud providers are grouped, and the groups sit side by side when the window has room. The provider groups use the full window width, and each sits in a container with a faint border and tint in its colour (green for local, blue for cloud). A group is never split: when it has more cards than fit on a line, they wrap inside its own container. - **Add provider** lets you configure a server manually, or add a cloud provider - Anthropic, OpenAI, OpenRouter, or any OpenAI-compatible endpoint (just paste the base URL + key). - For **Ollama**, you can **pull** new models and **load / unload** them into memory right from the card. - The **Token usage** panel charts input/output tokens over time and breaks them down per model and per provider. > Tip: set a default provider + model in Settings so new chats start ready to go. > Can't find a setting? Click the search icon at the right of the **Appearance** heading in Settings and type a word such as "prompt". A second after you stop typing (or straight away when you press Enter) the page scrolls to the first match and highlights it. Use the up/down arrows (or the arrow keys) to step through the rest, and the X or Esc to close the search. ### Example: connect an LM Studio server 1. In LM Studio, load a model and start its server (Developer → Start Server). 2. In Nekko Agent: **Add provider → LM Studio**, set the **Base URL** to your server, e.g. `http://10.5.0.2:1338` (you can paste just `host:port`, Nekko Agent appends `/v1` automatically). No API key needed. 3. **Test connection** → the model dropdown fills with whatever LM Studio is serving. 4. Pick the model in the chat header and go. **Reasoning models** (Gemma, DeepSeek-R1, Qwen-thinking, etc.) are first-class: their chain-of-thought streams into a collapsible **Thinking** block above the answer, so you see progress during long reasoning and the answer stays clean. --- ## 3. The unified chat ### Defaults for new agent windows Choosing a model in a chat or in **Add window** remembers that model and its provider for the next agent window. Choosing **Auto** remembers Auto, not whichever model it resolves to. Browsing a provider or opening an existing chat does not change this default; existing chats keep their own model choices. A successfully changed **Permissions** mode (Ask, Guardrails or YOLO) becomes the default for new chats. **Effort** is set per chat (a chat that never picked one follows the app default), and the saved **Execution** mode is reused through the existing setup flow. **Incognito** and **Offline** are different: they apply only to the chat where you enable them and are never turned on globally for new windows. The **Mode** button opens the Mode panel. Saved modes run down its left side; the settings sit in three columns: **Environment** (run on your machine or in a Sandbox, and whether edits go to a separate worktree), **Model** (where this chat's models run, and its effort), **Privacy** (Incognito, internet access, folders, approvals, tools, MCP servers and connectors) and **Approach** (skills, project rules and memory). Every setting has a line underneath saying what it does. **Tools**, **MCP servers**, **Connectors** and **Skills** open a list out the panel's side. The **MCP servers** and **Connectors** lists switch each one on or off for this chat only. A server switched off here keeps running for your other chats, and a connector switched off here is simply not read for this chat's context. Neither list can turn on a server that is off in Settings or a service that is not connected; links at the bottom go to those pages. Sub-agents a chat starts inherit its effort, servers and connectors. Click a saved mode to apply all of its settings to the chat at once. Change anything afterwards and the panel offers **Revert** or **Update** for that mode, or **Save mode** to keep the combination under a new name. Click the star beside a mode to start every new chat in it; click it again to go back to your last-used defaults. A mode also remembers the chat's effort, MCP servers, connectors, project rules and memory. You start with four modes: - **Cloud only**: the chat runs on cloud providers only. Its model menu lists only cloud providers, and the engine refuses a model on this computer. - **Fusion**: Auto picks the model for each turn and may move a turn between your local and cloud models. It moves only to a model that is as capable or better, either because it is cheaper (a local model counts as free) or because the current provider's usage limit is spent. The turn says why it moved. - **Local only**: models on this computer only, with the internet off: no web tools, connectors or MCP. Nothing leaves your computer. - **Risky business**: for code you don't trust. The chat runs in a Sandbox container with no network, and asks before every action. With a local model the whole chat stays on this computer; with a cloud model, only the conversation with that model goes out. **Models run** in the Model column sets the same thing by hand: click *Cloud only*, *Fusion* or *Local only*, and click the highlighted one again to stop pinning. *Local only* needs a local model and *Cloud only* a cloud one; when the chat's model doesn't fit, the panel says so and leaves the setting as it was, so pick the model first. A mode never includes your folders, and applying one that runs in a Sandbox opens the Sandbox setup first. Under **Approach**, **Skills** holds the work modes: *Default*, *Better review*, *Design* and *Best output*. Picking one changes how the agent works in that chat and brings the matching skills with it (for example Better review applies `/review`, `/security-review` and `/review-council`), so you don't have to remember or type them. A work mode applies to that chat only, costs more tokens and time than Default, and never changes Permissions, tools, Offline or Incognito. Rows marked *Coming soon* (model router, system prompt) are not available yet. **Project rules** decides how much of a repository's own agent instructions the chat reads: - *Rules* (the default): the root `AGENTS.md`, `CLAUDE.md`, `.cursorrules`, `.windsurfrules` and `GEMINI.md`, plus the rule folders other agent tools use: `.agents/rules`, `.claude/rules`, `.cursor/rules`, `.kiro/steering`, `.windsurf/rules`, `.github/instructions` and `.github/copilot-instructions.md`. A rule that applies everywhere goes into every turn. A rule scoped to some files (Claude's `paths:`, Cursor's `globs:`, Copilot's `applyTo:`, Kiro's `fileMatchPattern:`, Windsurf's `glob` trigger), a rule with only a description, and an `AGENTS.md` in a subfolder are listed in a short **Project rules index** instead; the agent reads one when it works on matching files, and its full text goes in when you attach a matching file. Manual rules are left out until you attach them. - *Root files*: only the root files, as before rules folders were read. - *Off*: no project guidance at all, for a repository whose instructions you do not trust. **Memory** decides which of your memory notes the chat reads: *Everything* (global and this chat's folders), *Project* (only the folders') or *Off*. An incognito chat reads no memory notes whatever this says. Sub-agents inherit both settings. A successfully completed reply should not retain a stale **Reply interrupted** warning. When the final transcript records successful completion, that terminal record takes precedence over missing older tool bookkeeping. Explicit interruption markers, provider failures, and intentional stops remain visible; the app does not rewrite your transcript to dismiss them. The left navigation panel curves into its tails about 10px beyond the icon rows, with matching curves at both ends. Opening a wall window reveals the incoming window and slides existing windows aside, without a separate corner-resize animation. Reduced motion disables the wall transitions. On the Agents wall, the agent, image, and terminal icons with small bottom-right **+** indicators create new windows. A subtle divider separates them from the **Focused** and **Dynamic** view modes. Dynamic uses a lightning bolt icon. For an even **Grid**, open **Settings > Command Center**, choose Grid under Wall layout, and set Grid rows and Grid columns (1 to 6 each). These dimensions are remembered when switching layouts. The existing **Ctrl/Cmd+Shift+1**, **2**, and **3** shortcuts still select Focused, Dynamic, and Grid respectively; saved Grid layouts reopen unchanged. In Focused view, **New agent** fades straight into the new chat's prompt; once you send your first message, the composer expands into the full chat. When the wall has no windows left, it switches to Focused and shows that same new-chat prompt (reusing an unused chat if you have one) on a glass composer, so there is always somewhere to type. Focused mode hides the individual window title strip and outer border; Dynamic and Grid retain their window controls. Agents and Monitors use short reversible transitions, disabled with reduced motion. The right-hand dock is headed **Monitors**. Inside a window header, the larger accent-colored **Focus** icon sits immediately beside Minimize and opens that window in Focused view. Minimize removes only the wall window, keeping the chat in the Agent panel. The left navigation keeps its position and tint, with broad curved wings above and below the destinations. The **Chat** tab is the heart of the app. There are no separate "chat", "cowork", and "code" modes, it's one thread. Ask a question, or hand off a task and let Nekko Agent act on your machine through tools (read / write / edit files, glob, grep, list dirs, run shell commands). ![Unified chat](https://nekkoagent.com/docs/media/chat.png) - Pick the **provider and model** from the header dropdowns at any time. - Assistant messages render **markdown and code**; every **tool call** the model makes shows up as a card so you can see exactly what it did. - Streaming is live; hit **Stop** to abort a turn. - **Tools** shows a friendly action label, the literal tool name and a full wrapping description. Search matches labels, names and descriptions. Use the checkboxes to enable tools; keyboard focus and Space activation are supported. The menu (in the agent window's footer) lists the agent's tools by category: File system, Shell & processes, Desktop, Online, Agent and MCP servers. Turn single tools or a whole group on or off for this chat. - The **globe** beside it blocks the internet for this chat (local models only). Online tools (fetching pages, the browser), MCP servers, connectors and sub-agents are turned off; file, shell and desktop tools keep working. Shell commands are not network-sandboxed, so a command can still reach the network. - In the model picker, a local model shows its short name with the file it lives in beneath. - Answers to the agent's clarification questions stay visible in the chat as Q/A replies, including when reopening an existing conversation. Selected choices and your typed note remain visible; skipped questions say they were not answered. These reply rows do not create another prompt or offer edit/rewind actions. **Keyboard shortcuts**: Nekko Agent has a command palette (think Cmd-K everywhere): | Shortcut | Action | | --- | --- | | `Ctrl/Cmd + K` | Open the command palette | | `Ctrl/Cmd + N` | New chat | | `Ctrl/Cmd + F` | Find in this chat | | `Ctrl/Cmd + \` | Toggle the context panel | | `Ctrl/Cmd + Shift + E` | Complete the focused agent chat | | `Ctrl/Cmd + Shift + M` | Minimize the focused agent or terminal board window | | `Ctrl/Cmd + Shift + Delete` | Delete the focused chat or close its terminal, with confirmation | ![Command palette](https://nekkoagent.com/docs/media/command-palette.png) **Find in this chat**: click the magnifier in the chat window's header (the first icon, left of Changes and Files), or press `Ctrl/Cmd + F`. A small bar opens at the top right of the conversation. Type a word or phrase and every place it appears is highlighted, with the one you are on in solid yellow and a count that always starts at **1 of 29** (the first match from the top). Matching is by whole word and ignores case, so `scroll` finds *Scroll* and *SCROLL* but not *scroller* or *autoscroll*. Use the up and down arrows (or **Enter** / **Shift+Enter**, or the arrow keys) to step through matches; they wrap around at the ends, and a match far up the conversation is brought into view. **Esc** or the **X** closes the bar and clears the highlights. It searches the message text you see, not tool steps, reasoning or link addresses. On the Agents wall each window has its own magnifier in its title bar, and the shortcut from the shared composer searches the window it is talking to. **Copy and paste in a terminal.** Right-click a terminal for **Copy**, **Paste**, **Select all** and **Clear terminal**. On Windows and Linux the keys work the same way as in Windows Terminal and VS Code: | Shortcut | Action | | --- | --- | | `Ctrl + C` | Copy the selection; with nothing selected it interrupts the running program, as always | | `Ctrl + Shift + C`, `Ctrl + Insert` | Copy the selection | | `Ctrl + V`, `Ctrl + Shift + V`, `Shift + Insert` | Paste | On macOS, `Cmd + C` and `Cmd + V` copy and paste, and `Ctrl + C` always interrupts. The read-only Agent commands log can be copied but not pasted into. **Clear terminal** clears the screen and scrollback in the window only; the shell keeps running. --- ### Add windows and read the dock The Agents toolbar provides direct **New agent**, **New image session** and **New terminal** actions, with no ellipsis or overflow button. The empty wall can still open the full window picker. Its Chat, Media and Terminal categories remain horizontal; opening it does not change the wall layout. Each repository heading in the Agent panel also has a **plus** button for a new agent chat and a **terminal** button for a new shell in that repository. These actions use the repository you clicked, not another selected or default folder. In Focused view, the new window becomes visible; other layout modes stay unchanged. On narrow screens, the agent list closes after successful creation so it does not cover the result. If a wall filter would hide the window, it switches to All. The controls wait for creation to finish before accepting another click for the same repository, and failures show an error you can act on and retry. Utilization lists each enabled cloud provider on its own row. In Budget, choose today, 1wk, 1m, 6m, 1y or all-time; the active range is tinted. Spend, tokens, top agent, local tokens, savings and compared model membership all follow that UTC range, not lifetime totals. See [usage limits and prices](https://nekkoagent.com/docs/usage-limits). These 2026-10-13 changes are implemented and unit/fixture interaction tested. Matching captures exist, but image/motion inspection remains pending; earlier walkthrough pictures are not visual proof of this batch. ### Find a chat The magnifier beside **+** at the top of the Agents panel opens a chat finder. It starts with your latest chats, newest first, each with how long ago it was used (days up to 30, then months). Type to filter by title, first prompt or latest reply; the search runs once you pause typing, or straight away with Enter or the Nekko button. Click a chat (or press Enter) to open it. **X** or Esc closes the finder. Incognito chats are never listed. ### Complete, minimize, and delete Hover a window's lifecycle buttons to see the action and hotkey. Shortcuts target the window you are using, not every chat. The **X** in a chat window's top-right corner (and on its card) is **Complete**: it closes the window and keeps the chat under its group's **Show completed** control in the left Agent panel for 60 days, where you can read or restore it. Right-click a completed chat there for **Restore chat** or **Delete forever** without opening it. Completing never deletes; **Delete** is a separate trash action. Agents you have not touched for a month are completed for you the same way; change or turn this off under Settings → **Complete idle agents after** (1 week, 2 weeks, 1 month, 3 months or Never). Pinned, running, waiting and sub-agent chats are never completed automatically. **Minimize** removes only the board window: the item remains in the Agent panel, and a terminal keeps running. Terminals do not have completed history; **Close** ends their process. Settings has **Show tips**, enabled by default. Your first successful completion highlights Show completed with a dismissible explanation. On a narrow screen, the existing agent list drawer opens automatically to reveal Show completed without taking focus. If you close it, the highlighted Agents button lets you return to the destination. Opening or closing the drawer does not dismiss the tip. Turning tips off does not disable deletion warnings. The in-app delete dialog offers **Skip future deletion confirmations**; only choose it if you want permanent deletions without another prompt. Re-enable **Confirm deletions** in Settings at any time. General chats no longer display a redundant missing-project label. ### A composer that follows your draft In the Command Center, one docked composer targets the selected agent window. Click another agent window to write in that chat's context, or use the dock picker to move the composer above or below the wall. The reply editor grows with new lines and wrapped text, then shrinks when you delete them. In the bottom dock it grows upward, leaving the agent board above it in its own scroll area. Once the draft reaches the height cap, only the editor scrolls; the header, send controls, queued prompts, images and skill rows stay inside the composer ring. A saved manual height remains a preferred minimum, not a ceiling that prevents a longer draft from growing. Drag the line above the composer to resize it. It snaps to two points: its default size and full size. Release near either one and it lands exactly there. Snapping back to the default clears the saved height. Double-click the line to reset it any time. ### Continue after an interrupted tool After Stop or an interrupted delegation, either type a follow-up or use Resume. Both paths repair incomplete tool-result history before the next model request. Saved real results are retained; missing results are marked interrupted rather than silently rerunning the tool. A typed follow-up stays your own saved message. While a reply is running, queue your follow-up or interrupt the current reply; a second direct send is rejected without discarding the queue. Delegation still waits synchronously for the child. Parent-callable child status and cancellation while waiting are planned, not available through this repair. ### Follow delegated work without opening every detail Delegation cards start collapsed, including while a child is working or returning a large result. Their compact, plain-text activity sentence shares its status with the plan's sub-agent list and the working-subagent indicator: - **Working**: the child is thinking, writing or using a tool. - **Waiting**: it needs an answer or approval, or is waiting to retry. - **Completed**, **Failed** or **Stopped**: recorded terminal events, not guesses based on the presence of a stored reply. Expand a card to read the full task and result in bounded scroll areas. This does not resize the entire agent window. Summaries use existing local events, without an extra model request or additional transmission. Live card-to-child matching is best effort, using the parent, title and creation time. There is no authoritative spawn-call child ID yet. Ambiguous matches and historical calls without observed events do not borrow another child's status; they retain a task summary or report that no current run status is recorded. ### Attach photos, videos and any file The **+** button beside the message box opens a menu with an icon per row: **Photo / Video**, **File**, **Folder** and **Skill**. Photo / Video follows the model you picked: a model that takes images (Claude, GPT-4o and later, Gemini, Llama vision, LLaVA, Qwen-VL and friends) gets the row enabled; a text-only model greys it out and the menu says why, so a picture is never attached to a model that cannot see it. Video input is rarer (Gemini, and OpenRouter models that list it); when only photos are accepted the row says *photos only*. For a local model the app cannot classify, the row stays enabled and the first send tells you. You can also **paste** (Ctrl/⌘+V or right-click → Paste) or **drop** anything onto the message box: a screenshot, a clip, a PDF, a Markdown file, a spreadsheet, a file copied from Explorer or Finder. Photos and videos the model can see travel with your message. Everything else becomes a file attachment: a file copied from disk is attached in place, a pasted blob with no home (an image copied from a browser, a PDF from an email) is saved under the app's data folder first. Files whose bytes the model will not read (PDFs, Office documents, archives, or a photo for a text-only model) still appear in the strip above the message box, marked **name only**, so nothing disappears silently. Videos are capped at 40 MB in the message; larger ones can still be attached as files. ### Diagrams, tables and code in replies Replies render Markdown: tables, code blocks with a copy button, images, and fenced ```` ```mermaid ```` diagrams drawn inline. Hover a diagram for **Copy** (the Mermaid source) and **Expand**, which opens it in a full window where the mouse wheel zooms, dragging pans, and a double-click resets; Escape or Close returns to the chat. The assistant is asked to reach for a diagram when it explains a flow or a structure, a table when it compares, and a code block for anything you might copy. ### Photos from the web When the agent finds a picture while searching or browsing ("what does the HAMILeX MH-853 look like?"), it can show it in the reply. Several photos in a row appear as a gallery. Photos load as you scroll to them, a few at a time. Click a photo (or any image in the chat) to open the **photo viewer**: the picture as large as the window, with **Copy**, **Save…** and **Close**, and for a gallery the **‹ ›** buttons or the arrow keys to step through. Right-click a photo for **Copy image**, **Save image…**, **Copy image address** and **Open original in browser**. How it stays safe: the chat never loads a web address itself. Nekko fetches the picture for it, only from public websites (never this computer or your home network), only real image files (PNG, JPEG, GIF, WebP, AVIF) up to 10 MB, and without your cookies or logins. A photo loads by itself only when its address came from a search, a page or your own message in this chat. If the agent wrote an address out of nowhere, the photo shows a **Load image** button naming the site it would contact, so a made-up link cannot quietly send anything from your chat to someone else's server. ### Plan and capacity at a glance The plan icon in an agent window opens or closes only that chat's plan, sub-agents and queue panel. Each chat remembers its own choice locally. Narrow windows fold the panel automatically without changing that choice. The context-window gauge has a subtle green background below 60% full, yellow from 60% to below 85%, and red at 85% or higher. Counts remain estimated; an unknown window limit stays neutral. In Agents, the Hardware panel names each detected GPU and shows its own utilization. Missing readings say Unavailable rather than zero; switching monitoring off says Monitor off. VRAM remains the aggregate memory reading across devices. Where your machine reports them, CPU, Memory and each GPU row also show a temperature at the right of the label, orange from 80°C and red from 90°C. NVIDIA cards report theirs through their driver; Linux reads its hardware sensors; on Windows the CPU reading comes from the ACPI thermal zone, which many desktops do not publish, so it can be missing there. **Using more than one screen.** In Dynamic view, the screens button at the right end of the wall toolbar shows how many screens are in use. Hover it to see your screens in a row: click one to open a full Nekko window on it (it swipes in from the left edge), click again to close it (it shrinks into the right edge). The main window's screen is always on. Each screen's window has its own left navigation, toolbar and wall, so one screen can show agents while another shows Settings. Under **View options**, choose **Prioritize empty windows** to send new agents to an empty screen first, or **Prioritize the main window** to keep new agents on the main screen. A window always keeps the agents you start from it, and your screens are reopened the next time you launch. ## 4. The Context Inspector The right-hand panel (toggle it with the panel icon) is Nekko Agent's signature feature. It shows **exactly what is entering the prompt this turn**, grouped by where it came from: - **Guidelines**: `AGENTS.md` / `CLAUDE.md` / `.cursorrules` detected in your workspace folders, rules from `.agents/rules`, `.claude/rules`, `.cursor/rules` and the other rule folders, and a **Project rules index** of the scoped ones (see Project rules in the Mode panel) - **Files**: anything you've attached - **Memory**: global / per-project notes, as the chat's Memory setting allows - **Connectors**: issues, messages, or docs pulled in from Linear/Slack/etc. Each item shows a **token estimate**, can be **toggled off** to exclude it, or **pinned** to always include it. The bar at the top shows how much of the model's context window you're using. No more guessing what the model "saw", you can see and shape it. --- ## 5. Guardrails & sandbox Open **Settings**. Two layers keep Nekko Agent from doing something you didn't intend. ![Guardrails & sandbox](https://nekkoagent.com/docs/media/guardrails.png) **Sandbox mode** controls how the agent may touch your machine: | Mode | Behavior | | --- | --- | | Workspace jail *(default)* | File access confined to your added folders; symlinks and junctions that point outside them are refused | | Ask everything | Every write or command requires approval | | Docker | Run shell commands inside a container if Docker is present | | Off | No restrictions (power users) | **Guardrails** classify risky shell commands and decide whether to **allow**, **ask**, or **deny**, per rule, each toggleable. Ships with sensible defaults: recursive force-delete, raw disk writes, `git push --force`, `curl | sh`, privilege escalation, secret/`.env` access, and more. When a command matches an "ask" rule, the chat shows an **approval bar** with the exact command and its severity before anything runs. --- ## 6. Projects & the codebase index The **Projects** tab manages your workspace folders, and yes, **multiple roots** are supported. Each folder is indexed for files and code symbols (functions, classes, interfaces, types) so the agent and search have a fast map of your code. ![Projects & index](https://nekkoagent.com/docs/media/projects.png) - **Add** a folder and it indexes in the background. - See **file + symbol counts** and index status per project. - **Search** across the indexed files instantly, or browse the file list with per-file symbol counts. --- ## 7. Memory The **Memory** tab is a simple, durable knowledge base stored as plain markdown. Keep **global** memories (preferences, conventions) and **per-project** memories. Entries are surfaced in the Context Inspector and injected automatically, so Nekko Agent remembers how you like to work across sessions. The Mode panel's **Memory** setting limits a chat to its project's notes or to none, and an incognito chat reads none. --- ## 8. Connectors The **Connectors** tab links external sources so their content can be pulled into context: **Linear**, **Slack**, **Discord** (token-based), and **Gmail** / **Google Drive** (OAuth). Tokens are stored locally. Connect one, fetch a sample to confirm it works, and the results become available to the assistant. --- ## 9. The mascot **Nekko Agent** is the 8-bit cat that lives at the edge of the window. It waves to say hello, peeks in from the side, and **"makes cat biscuits"** (kneads its paws) while the model is thinking. Don't want it? Toggle it off in Settings → Appearance. --- ## 10. Appearance and page recovery Nekko Agent starts in your system's light or dark mode. Leave the theme on **System** in **Settings → Appearance** to follow system changes, or choose a light or dark preset to keep that look. The pixel Nekko in the desktop's top bar stays the same as you move between pages; the heading names the page. If Settings or Model Server cannot load, the page shows the problem and a **Try again** button. On desktop, start a stopped service from the tray menu, then retry. In the Local Web UI, check that the Nekko service is running. Existing content stays visible when a refresh fails. Agent shell commands are noninteractive: stdin closes immediately, so prompts receive end-of-file rather than waiting for an answer. The shell tool has a 120-second execution limit and up to five seconds of stop cleanup; use `start_process` for long-running jobs. On Windows, `date /t` displays the date; bare `date` asks to change it. If a command appears stuck, Stop cancels its turn and requests process-tree termination. An unresponsive OS process may still need separate inspection even after the tool reports cancellation. A failed chat reply preserves completed steps. **Retry** continues from saved history, and you can send a new message instead. For Anthropic requests, Nekko bounds supported outgoing image copies to 1568 pixels per side, including old attachments and screenshot evidence. This prevents the stricter image-dimension limit in requests with more than 20 images from trapping subsequent replies. Saved originals are not resized or removed. Oversized animated images may be sent as a static PNG. Corrupt or unsupported files, image-count limits, payload size and context limits can still cause rejection; retrying those unchanged inputs is not a guaranteed recovery. If such an error persists, use a new chat with valid, smaller attachments and include the exact error in a bug report. If a page or section cannot be displayed, use its **Try again** button to reopen it. Navigation stays available so you can move to another page. If the problem persists, include the displayed error and your app version and OS in a GitHub issue. --- Questions or ideas? Open an issue on [GitHub](https://github.com/nekko-labs/nekko-agent/issues). ### Reordering project groups Drag a project group header in the agent panel onto another group to place it before that group. A quick gap opens above the hovered group (beside it in the horizontal panel) and closes when you move away. You can drop on either the header or an agent card in that group. The project order is saved. Dragging an individual agent card still reorders or moves that agent, rather than the whole group. ### Agent window controls Changes is the leftmost header control. When the agent edits files it uses a muted background without an outer glow and shows added/deleted line totals plus the changed-file count. Click it to review the diff. Zen focuses that window; a small divider separates this action from the Files, Browser and Logs panel buttons. The activity icon is at the top left before the numbered agent icon. **Agent Monitor** opens the plan, sub-agents and queued prompts rail. Its control-panel button has a visible label and shows only while the rail is minimized; the conversation starts below it so it never covers your first message. With the rail open, the minimize button at the far right of its header puts it away again. Its **Project** group lists the work your project tracks: open items from a connected Linear, Jira, Asana, Trello, Todoist or ClickUp (one tab each), or, if none is connected, the unchecked items in the chat folder's `TASKS.md` (or `TODO.md`, `BACKLOG.md`, `ROADMAP.md`). Click an item to put it in the message box; nothing is sent until you send. The old header context button is removed; use Ctrl/Cmd+\ or the command palette's Toggle context panel command instead. ## Reports and artifacts in chat Local document links in assistant replies appear as artifact cards beneath the response. The descriptive report title appears above its filename, so a README can still be recognizable. Select a card or document link to open it in the originating chat—not another chat that happens to have the file open. Local SVG, PNG, JPEG, GIF and WebP image markdown and image links also count as artifacts. Image markdown previews in the message; ordinary image links get a preview card below it. The same image does not get a second response card if it is already shown in image markdown. Cards fill the available width up to 768 px, contain images within 640 px height, and retain **Open source** to open the file in the originating chat. Local images load automatically outside Sandbox; unreadable, oversized or unsupported images show a fallback. SVG runs only as an inert image, with no scripts or external subresource requests. Web photos are shown through Nekko's safe fetch (see *Photos from the web*) and are not listed as artifacts; network shares and unsafe URL schemes are not previewed. The desktop navigation rail stays 52px wide at rest. Hover a destination or Tab into it to reveal the labels in a 198px body; the extra 10px is right-side breathing room, not narrower buttons. Its subtle background extends equally above and below the menu along the left edge. At full available height, each straight shoulder runs from 40px wide at 95px beyond the nearest button to the 198px-wide body at that button, with slowly narrowing tails rather than curved, outward-flaring wings. Header clearance lets the upper tip extend beside the mascot. Short windows shorten both tails equally and keep the destinations scrollable. The decorative tails do not intercept clicks, and phones continue to use bottom navigation. Implementation is geometry-tested; matched visual and interaction verification is pending with the parent task. The **Artifacts** tab on the left rail collects what agents made across all your chats: documents, web pages, designs, diagrams, images, data and office files, grouped by kind. Pick one to preview it, **Open file** to hand it to its usual app, or **Open chat** to see the chat it came from on the Agents wall in Focused view. Source code the agent edits is not listed. At the top of **Agent Monitor**, expand **Artifacts** to see the session’s linked documents. It starts collapsed and always displays a count, including 0. A newly discovered artifact path expands just this section and highlights it with an accent border/background and a visible, screen-reader-announced **New artifact** status for ten seconds. Another new path restarts that deadline, including when one path replaces another without changing the count. The whole monitor never opens automatically. Initial history, switching chats and history loaded after a missing session do not trigger the highlight. Repeated links and previously seen paths do not retrigger it. You can collapse the section during the highlight; it stays collapsed until another new path arrives. When the highlight expires, the section keeps its current open or closed state. Non-image documents are not read until you open them; image cards load automatically when rendered. Host previews remain unavailable in Sandbox. ### Plan step status icons In Agent Monitor, each active plan step has a static **Working** glyph and tooltip. Multiple steps can be working at once, even between streamed replies. If a reply was explicitly interrupted and no turn is running, active entries show pause bars labeled **Paused** until work resumes. Completed steps show a check, pending steps a hollow circle, and skipped steps a slashed circle with a struck-through title. --- # Browser automation ## Dedicated browser The desktop `browser` tool opens hidden, sandboxed Electron Chromium windows owned by the current chat, never your personal browser. Each chat has a separate in-memory session and up to eight tabs. Every browser action goes through Nekko's existing approval policy. Explicit visibility shows the owned window without activating it. Navigate HTTP(S), inspect, click/fill the first match, type, press keys/chords, wait for content, scroll, extract text/links, evaluate page JavaScript, screenshot, manage tabs, read logs and take a snapshot. A **snapshot** lists what the agent can act on: the page's links, buttons, fields and other interactive elements, one per line, each with a short ref such as `@e3`. The agent can then click `@e3` or fill `@e5` instead of guessing a CSS selector or writing a script to find the field. Pass a CSS selector to snapshot only part of the page. Refs belong to the page as it was; after the page changes, the agent takes a new snapshot. Approved screenshots go to the selected model. Evaluation runs in the page, not the app; pages can see text you enter. Popups, downloads, JavaScript dialogs and device permissions are blocked. Local file navigation/uploads are blocked. Idle hidden sessions close after ten minutes. Requires a local desktop chat, not web/server/CLI or sandboxed execution. ## Native default (2026-10-17, release verification incomplete) The default now uses pinned native Rust agent-browser 0.39.1 against Electron's existing Chromium. No extra browser download or global driver. Each adopted tab has a native process and a single-client authenticated loopback CDP relay restricted to its Electron debugger. There is no app-wide remote debugging port and no silent Playwright fallback. Missing native resources produce an explicit error. The unmodified upstream Windows command daemon is unsafe for this boundary: it accepts unauthenticated TCP commands, and MCP stdio delegates to it. The user selected a maintained patch instead. Its only binary entrypoint uses inherited stdin/stdout, authenticates every command using a random capability delivered on inherited stdin, rejects all argv, clears upstream configuration and denies implicit browser launch and extra entrypoints. The CDP relay's distinct capability is also delivered privately on inherited stdin, not environment/argv. Build the exact patch with `node scripts/build-agent-browser.mjs` (Rust toolchain and native linker required). Dist/release call this builder and package the native binary, license and pin outside asar. Source/dependency pinning is verified, not bit-identical output across toolchains. Windows x64 has been tested; installer/signing and macOS/Linux/arm64 remain unverified. ## Speed (2026-10-18) A hidden window that has gone idle renders only about one frame a second, so clicks used to wait about a second and screenshots about two. The browser now keeps the hidden window's frames coming for just the duration of a click or screenshot; the window stays hidden. On a sample signup task (open the page, find the form, fill it, submit, check the result, take a screenshot) a new chat's browser finished in about half a second instead of about three and a third, on Windows. Opening the browser itself takes about 60 ms. A chat that keeps its browser open skips that and reuses the page cache. See [performance history](https://github.com/nekko-labs/nekko-agent/blob/main/docs/perf-history.md) and the [benchmark report](https://github.com/nekko-labs/nekko-agent/blob/main/reports/agent-browser/flow-benchmark.md). ## Checks and evidence - `node --test scripts/agent-browser-native.test.mjs`: native framing/auth/argv/no-listener security. - `node scripts/agent-browser-native-smoke.mjs [outDir]`: isolated hidden Electron native action/security fixture plus 20 cold-chat and 20 warm-inspect samples. Set `NEKKO_SMOKE_ELECTRON` to a matching owned Electron executable when needed. - `apps/desktop/src/main/agentNativeRelay.test.ts`: wrong/missing auth, Origin, second-client and owned-target negative checks. - `apps/desktop/src/main/agentCdpTransport.test.ts`, `agentBrowserActions.test.ts`, `agentBrowser.test.ts`: transport/page/bridge behavior. - `node scripts/agent-browser-flow-benchmark.mjs [out.json] [--rounds 20] [--launches 12] [--drivers native,native-nowake,playwright]`: whole-task benchmark (navigate, locate, fill, submit, verify, screenshot) through the real bridge in hidden Electron, with first-in-process, fresh-chat and warm-session scenarios, tool calls, retries, reply bytes and startup phase timings. A `-nowake` driver runs without the hidden-frame wake. - `scripts/agent-browser-benchmark.mjs`: explicit Playwright baseline-only helper; not the production default. - `scripts/perf/lib/browser.mjs`: existing separate renderer-performance helper, not yet migrated by this task. Full workspace typecheck/build and all 1362 desktop tests pass. Native fixture covers four wait states, duplicate-selector first match, keyboard submission, extraction, logs/page errors, tabs and popup/dialog/permission/file-navigation boundaries. Matching screenshot dimensions are 1359x873; parent actual-pixel review and public evidence publication are pending. All 956 host tests pass with explicit worktree-owned debug daemon (1 skipped); Rust browser/capture golden parity passes. Alternating benchmarks include native-child memory: cold navigation is slower, warm inspect slightly slower, working-set snapshots lower. This is a trade-off, not a speedup. Unpacked Windows native binary/license/pin hashes match staging; full signed installer and unrelated daemon resource staging remain unverified. See [performance history](https://github.com/nekko-labs/nekko-agent/blob/main/docs/perf-history.md), [raw baseline](https://github.com/nekko-labs/nekko-agent/blob/main/reports/agent-browser/playwright-baseline.json), [initial candidate](https://github.com/nekko-labs/nekko-agent/blob/main/reports/agent-browser/native-candidate-v2.json), [patch documentation](https://github.com/nekko-labs/nekko-agent/blob/main/vendor/agent-browser/README.md) and [original feasibility](https://github.com/nekko-labs/nekko-agent/blob/main/reports/agent-browser/feasibility.md). --- # Privacy Shield Privacy Shield controls what a chat sends to a cloud model. With it on, Nekko Agent replaces private values with placeholders like `[PII_EMAIL_1]` before a request leaves your computer. When the model answers, the real values are put back before anything runs or is shown. The model never sees the email address. The agent still writes the real one into your file, and you still read it in the reply. Everything happens on your computer. Nothing is sent to another service to decide what is private. ## Turning it on In the composer's privacy row, next to **Offline** and **Incognito**, click **Shield** and switch on **Shield this chat**. The dot lights up, and the number beside it is how many values are being replaced. The switch applies to that chat. Its sub-agents inherit it. To shield every chat that has no setting of its own, set `"privacyMode": "redact"` in `settings.json` (a Settings page for this is coming). The shield only acts when the chat's model is off your computer: Anthropic, OpenAI, OpenRouter, Bedrock, Vertex, Foundry, ChatGPT, or a model server on another machine. A local model on this computer (the built-in engine, Ollama, LM Studio, vLLM) needs no shield, because nothing leaves the machine. ## What it replaces | Kind | Example | Placeholder | |---|---|---| | Email addresses | `jane.doe@example.com` | `[PII_EMAIL_1]` | | Phone numbers | `+81 90-1234-5678`, `(415) 555-0100`, `03-1234-5678` | `[PII_PHONE_1]` | | Card numbers (Luhn-checked) | `4111 1111 1111 1111` | `[PII_CARD_1]` | | US Social Security numbers | `123-45-6789` | `[PII_SSN_1]` | | My Number (check digit verified) | `1234 5678 9018` | `[PII_MYNUMBER_1]` | | Public IP addresses | `8.8.4.4` (never `127.0.0.1`, `10.x`, `192.168.x`) | `[PII_IP_1]` | | API keys, tokens and passwords | `sk-ant-…`, `ghp_…`, `AKIA…`, `"password": "…"`, `DB_PASSWORD=…` | `[PII_SECRET_1]` | | Your account name in file paths | `/Users/jane/…`, `C:\Users\jane\…` | `/Users/[PII_USER_1]/…` | | Your own terms | `Acme Corp`, `Project Falcon` | `[PII_TERM_1]` | **Your own terms** are the way to make a request generic: add your client's name, a codename or a person's name once, and every shielded chat sends a placeholder instead. ## Seeing what will be sent Open the **Shield** panel at any time, on or off, to see: - every value it would replace in your draft and the chat so far, what the model reads instead, and whether it is in your draft, the chat, or both; - **Your message as the model reads it**, with the placeholders in place; - the kinds it hides in this chat, each of which you can switch off; - your terms, which apply to every chat. Click **Send** on a value to send it as written in this chat (for example, a public support address you want the model to see). It moves to **Sent as written**, where **Hide again** undoes it. ## What it does not do The shield finds values by pattern. It is a strong guard for structured data and secrets, not a guarantee: - **Names and addresses in ordinary sentences** are not found unless you add them as terms. - **Combinations can still identify someone.** A rare job title, a small town and a date can point to one person even with every name removed. Placeholders are pseudonyms, not anonymization. - **Images** you attach are sent as they are. - **Claude Agent chats** are sent by Claude Code itself, so the shield cannot reach them. The panel says so. - **A version number like `1.2.3.4`** looks like a public IP and is replaced. The agent gets it back, but the model reads a placeholder. Read the list before you send anything sensitive. ## Where the real values live - The map from placeholder to value exists in memory for one reply and is then discarded. It is never written to disk. - Your chat is saved with the real values, as before. Use **Incognito** if it should not be saved at all. - The engine log records how many values of each kind were replaced, never the values. --- # Code mode Code mode changes how a chat's tools reach the model. Normally the model is handed every tool as a separate definition (its name, what it does, and a JSON Schema of its inputs) on every request, and it calls tools one at a time, reading each result before deciding what to do next. In code mode the model gets two tools instead: one to look tools up, and one to run JavaScript that calls them. The model writes a short script, the script calls your tools in a sandbox, and only what the script returns goes back to the model. The idea comes from Cloudflare's [Code Mode](https://blog.cloudflare.com/code-mode/) and Anthropic's [code execution with MCP](https://www.anthropic.com/engineering/code-execution-with-mcp). Both found that models write calls against typed code more reliably than they make tool calls, and that most of the tokens an agent spends go on tool definitions and intermediate results the model never needed to read. ``` Tool list Code mode ───────── ───────── Every request: 40 tool definitions Every request: search_tools + run_code model → grep → 30 KB result model → run_code: model → read_file(a.ts) → 8 KB result const hits = await tools.grep({ pattern: 'TODO' }); model → read_file(b.ts) → 9 KB result const files = [...new Set(hits.split('\n').map(l => l.split(':')[0]))]; model → (reads all of it, then answers) return files.length; ← "12" ``` ## Turning it on Open the composer's **Mode** panel, click **Tools**, and pick **Code mode** under *How tools reach the model*. It applies to that chat, and its sub-agents inherit it. Save the chat as a mode to keep it. The **Local only** starter mode turns code mode on for you. Small local models are where long tool lists hurt most: every definition takes context the model needs for your work, and a model that writes a short loop is often more reliable than one making a dozen calls in a row. Sandbox chats always use the tool list. Their few tools already run in the container. ## What the model sees - **`search_tools`** returns the tools that match a query as TypeScript signatures, for example: ```ts /** Search file contents. */ tools.grep(input: { /** A regular expression. */ pattern: string; path?: string; }): Promise; ``` - **`run_code`** runs JavaScript as the body of an async function. `await tools.(input)` calls a tool and returns its output as a string, and a failed tool throws a `ToolError` the script can catch. `return` sets what the model sees, alongside anything sent to `console.log`. Its description lists the available tools, one line each. A few tools stay as direct calls because they are about the conversation itself: asking you a question, the plan, the chat's title, finishing the chat, and sub-agents. ## Safety Code mode adds no new permissions: - The script runs in an embedded QuickJS engine with nothing in it: no network, no files, no modules, no timers, no environment variables. The only way out is a tool your chat already has. - Every call a script makes goes through the same checks as a direct call: the chat's tool switches, guardrails, hooks and approvals. In **Ask** mode you approve each call the script makes, one by one. - A script stops after 5 minutes (time spent in tool calls counts), when you press **Stop**, if it uses more than 64 MB of memory, or after 100 tool calls. Output over 40,000 characters is cut. ## How much it saves Each reply made in code mode shows an estimate on its stats line, for example *≈14k saved by code mode*. Hover the line to see where the estimate comes from: - **Tool definitions not sent**: the definitions code mode stood in for, less its own two tools, times the number of model calls in the reply. - **Results kept out of context**: what the script's tool calls returned and were sent, less what the script returned to the model. - **Tool lookups**: what `search_tools` added. This is code mode's own cost, and it is subtracted. For a sense of scale: with no MCP servers, Nekko Agent's own tools take about 2,660 tokens of definitions on every model call as a list, and about 1,860 in code mode, so a 10-call reply saves about 8,000 tokens on definitions alone. Each MCP server adds its whole schema to the list but only one index line to code mode, so the gap grows with every server you connect. The bigger saving is often results: a script that reads twenty files and returns a count sends the model a number, not twenty files. The figures are estimates at about 4 characters a token, the same rule the Context Inspector uses. They are not your provider's billed counts. If code mode costs more than a tool list on a reply (a very short tool list, or a single small call), the line shows nothing and the hover says by how much. ## When to use the tool list instead - You want to watch each tool call appear in the transcript as it happens. In code mode a script's calls show up as approvals and in the script's output, not as separate rows. - The chat has only a handful of small tools, and replies make one or two calls. - The model writes TypeScript annotations. Scripts must be JavaScript; a syntax error goes back to the model, which usually fixes it on the next try. ## Coming next - **Hypergate code mode**: the same `search_tools` and `run_code` served by [Hypergate](https://github.com/nekko-labs/hypergate), so any agent that connects to your MCP servers through it (Claude Code, Cursor, Codex) gets code mode too. - Parallel tool calls from one script, code mode in sandbox chats, and a script's calls as their own transcript rows. --- # Running models locally Nekko Agent runs models two ways: with its **own engine**, which needs nothing else installed, or by driving a server you already run (Ollama, LM Studio, vLLM). Either way it loads a model with the settings you choose and tells you honestly whether it will fit before you wait through the load. This is what the Models page shows you and why. ## The Nekko engine The engine is llama.cpp, managed by Nekko Agent. You press one button, a build comes down from llama.cpp's own releases (about 30 MB for Vulkan or CPU, several hundred MB for CUDA), and the app can serve models. Nothing is downloaded before that press. **Which build.** Picked from what the GPU probe actually found, not from the platform: CUDA where `nvidia-smi` answers and the card can run it, Vulkan for any other GPU, Metal on Apple Silicon, CPU where there is no evidence of anything else. A slow engine that runs beats a fast one that fails to start. llama.cpp's CUDA build is CUDA 13, which needs a GTX 16 / RTX 20 series card or newer (compute capability 7.5) and driver 580 or newer, so an older card or driver is recommended Vulkan and told why. The picker says CUDA is roughly 15-20% faster than Vulkan on NVIDIA (a rough public figure, not yet measured on our hardware); it is also the bigger download, about 550 MB on Windows with NVIDIA's CUDA runtime against Vulkan's 30 MB. A supported NVIDIA machine on a Vulkan or CPU build is offered **Switch to CUDA** on the Model server card. Every other build for your machine stays in the list if you disagree. If you already have a `llama-server`, point at it instead: Nekko Agent will run it and never update or remove it. **No Visual Studio, ever.** The Windows builds of llama.cpp and stable-diffusion.cpp, and Nekko's own engine daemon, need the Microsoft Visual C++ Runtime (`MSVCP140.dll`, `VCRUNTIME140.dll`), not Visual Studio. The installer carries those DLLs beside the daemon (about 1 MB), and on a PC without the Visual C++ Redistributable they are copied in beside the engine before it starts. If an engine still dies for a missing DLL, the error says so and links Microsoft's 25 MB redistributable. **Best for this machine.** The Models page opens on a pick per task (chat, coding, a small fast model for routing, embeddings, vision) made for this machine's GPU and memory. Each pick says what it costs before you download it: the memory it takes out of what the machine has for models, the download size, and the speed to expect at the start of a conversation. A speed with no `~` was measured with that file on that kind of hardware, a `~` speed is estimated from the hardware's memory bandwidth, and "rough" means the estimate was calibrated on a different kind of GPU. Once you have used a model, "here" marks the speed your own replies ran at (the median of the last 20), which then also decides whether it is fast enough to stay the pick. A GPU we have no figures for gets no speed rather than a guess. When nothing fits that is fast enough for a task, the pick is the fastest that fits and says so. The list comes with the app and works offline; **Check this machine again** re-reads the hardware after a change. Once you have models the block folds to one line. When a new GPU, or a newer version of the list, puts a better model within reach than the one you have for a task, a card above it says so ("A better coding model now fits your 16 GB GPU") with the model it would replace; **Not now** puts it away until the next change. **Where models come from.** A short curated list answers "what should I run"; search covers everything else, straight against Hugging Face with no account. Each model expands into its real builds with their real download sizes. Downloads resume after an interruption and only land once the file parses as a GGUF, so a half-finished model never shows up looking complete. A GGUF you already have is adopted without being copied, and pointing the models folder at an existing library adopts everything in it. **One address, several models.** The engine serves on `http://127.0.0.1:11500/v1` by default, with a `llama-server` process per loaded model behind it. Several models stay resident; a request for one that is not loaded loads it; an idle one is evicted on a timer; and the address survives every load and unload. It is OpenAI-compatible, so point Claude Code, Cursor, Continue or anything else at it and name a model from `/v1/models`. **Two ways to configure a load.** *Simple* asks one question, how much of this machine the model may use, and solves it into a real context length, GPU offload and KV cache type, then says what it chose and what it costs. *Advanced* is every llama.cpp flag: context, GPU layers, KV cache type, parallel slots, flash attention, batch and physical batch, threads, mmap and mlock, RoPE base and scale, seed, idle TTL. They are the same state, so moving one moves the other, and either can be saved as that model's default. **Seeing what is loaded from anywhere.** The Agents view's **Panels** dock has a **Local models** monitor, under Hardware: each model the engine holds in memory, whether it runs on the GPU, split across GPU and CPU (with the layer count, in amber), or on the CPU, plus its context and footprint. **Settings** on a row opens Model Server with that model's drawer unfolded on *Advanced*, where **Runs on** (GPU only, Hybrid, CPU only) lives. The chat's right panel has the same jump as **All settings** inside each model under Model settings. Switch the monitor off in the dock's **Configure** menu. Changing a setting reloads the model, which interrupts a reply it is generating. **The Server tab** carries port, whether it is reachable from your network or only this computer, an API key, allowed browser origins, how many models may be resident, the idle timeout, load-on-demand, start-with-the-app, and the models folder. The defaults are loopback-only with no key, which is safe; binding to the network is a deliberate act and the app says what it means next to the setting. **A network bind requires a key**: the engine refuses to listen on your network with the key empty, and picking **This computer and my network** with no key set fills one in for you (a key you already typed is kept). Send it as a Bearer token from the apps you point at the engine. Because the engine holds the file, it reads the GGUF header directly, so the projection below is **exact** for its own models where it has to report parts of itself as unknown for somebody else's. ## Servers you already run ### What each one can do They are not the same shape, and the app does not pretend they are. | | Nekko engine | Ollama | LM Studio | vLLM | |---|---|---|---|---| | Start / stop from here | yes | yes | yes | **no**, connect only | | Load a model on demand | yes | yes | yes | no, one model per process | | Set context per load | yes | yes | yes | at launch only | | Set GPU offload | yes | yes | yes | at launch only | | Parallel slots | **per load** | restart required | no | at launch only | | KV cache type | **per load** | restart required | no | at launch only | | Reports per-model VRAM | **measured** | **yes** | no | no | | Layer geometry for the planner | **from the file** | **yes** | no | no | | Batch, threads, flash attention, rope | **yes** | no | no | at launch only | | Installs the engine for you | **yes** | no | no | no | | Downloads models for you | **yes** | yes | no | no | Of the servers you bring yourself, **Ollama** is the most controllable and the only one that reports its own CPU spill. `/api/ps` gives both the model's total size and how much of it is in VRAM, and the gap between those two numbers is exactly what did not fit. `/api/show` publishes the layer count, KV head count, and embedding length, which is what lets the planner compute an exact KV cache rather than an estimate. **LM Studio** keeps most of its control surface in the `lms` CLI, so Nekko Agent drives that: `lms server start` and `lms server stop` are a real lifecycle, and `lms load` carries the context length and GPU share. It publishes no layer geometry anywhere, so its projections are partial (see below). Driving it needs the CLI on the same machine as the server, so a remote LM Studio can be read but not controlled. **vLLM** is deliberately connect-existing only. It serves one model per process with everything fixed by the launch command, and it is Linux plus NVIDIA plus Python in practice. Nekko Agent detects it, shows its live `/metrics`, and can stop it, but never starts it: instead the fit drawer renders the exact `vllm serve` command for you to run. Nothing that could not be verified against real hardware ships as a working button. ### When a card lists no models A card whose model list fails says why instead of "No models found." A server that does not answer reads *"Ollama didn't answer at `http://localhost:11434`. Is it running?"*; any other failure shows the provider's own message, such as a 401 from a refused key, unless the connection test above it already says why. **Retry** re-tests the connection and asks for the list again. A card with more than eight models gets a filter box. ## Reading the fit bar The bar stacks what the model needs against what the device has free: - **Weights**, the model's actual size. Taken from what the server reports or the file on disk, never computed from a parameter count times a quantization width, which is wrong for mixed-precision builds like `Q4_K_M`. - **KV cache**, the conversation's memory. For a model the Nekko engine runs, it is read layer by layer from the file, the way llama.cpp allocates it: the attention layers hold the whole context, a sliding-window layer (Gemma 4, gpt-oss) holds only its window, and the recurrent layers of a hybrid model (Qwen3.5 and later) hold none. For Gemma 4 12B at 32k that is about 2 GB, where counting every layer the same way would say 48 GB. For a server that does not give us the file, it is `2 x layers x kvHeads x headDim x context x bytesPerElement x parallelSlots`. - **Runtime overhead**, compute buffers and the server's own reserve. The marker line is where the device's free memory runs out. Anything drawn past it has to live in system RAM instead. ## The verdicts | Verdict | What it means | |---|---| | **Fits** | Everything runs on the GPU with room to spare. | | **Tight** | It fits, but a long conversation may start spilling. | | **Spills to CPU** | Part of the model runs on the CPU. It works, several times slower. | | **Will not load** | It does not fit even using system memory, or the context exceeds the model's own maximum. | | **Unknown** | The server did not report enough to project it. | **Unknown is a real answer, not a bug.** Nekko Agent will not infer a model's memory use from its name. If the layer count is missing, it says so and names what is missing. A confident wrong number costs you a long load and every number after it. Where the weights are known but the geometry is not, which is the usual LM Studio case, the answer is partial rather than absent: the real weights figure is shown, and if the weights alone overflow the machine the will-not-load verdict is still certain. The planner never blocks a load. A will-not-load verdict warns clearly and still lets you proceed. The estimate can be wrong, and it is your machine. ## Why a model spills to the CPU Three things push a model off the GPU, in roughly this order of surprise: 1. **Context length.** The KV cache grows linearly with context. Going from 8k to 64k multiplies it eightfold, and past a point the cache is larger than the model. Models with sliding-window or recurrent layers grow much more slowly. 2. **Parallel slots.** On Ollama every concurrent slot gets its own full KV cache: raising `OLLAMA_NUM_PARALLEL` from 1 to 4 quadruples the cache at the same context, which is why a model that fit yesterday can stop fitting today with nothing else changed. The Nekko engine shares the context between its slots instead, so more slots cost only the extra sliding windows. 3. **Something else already resident.** The planner measures against *free* memory, not total, so another loaded model counts against you. The suggestion chips fix these in that order, and each one is re-planned before being offered, so clicking a suggestion always improves the verdict. ## Apple Silicon A Mac shares one memory pool between the CPU and GPU. The budget is therefore system memory, and Nekko Agent never adds a "VRAM" figure on top of RAM, which would make a 64 GB Mac look like it has 128 GB. The fit drawer says so explicitly when it applies. ## Multiple GPUs Two 12 GB cards are not one 24 GB card. Without tensor parallelism a model has to fit on a single device, so the planner measures against the roomiest one and says that it is not pooling them. Only vLLM can split a model across cards, through `--tensor-parallel-size`. ## Settings that need a restart Some settings are read only when the server starts. Ollama's parallelism (`OLLAMA_NUM_PARALLEL`) and KV cache type (`OLLAMA_KV_CACHE_TYPE`) are both like this. The Advanced section shows them with the exact environment line to set and marks them restart-required, rather than offering a control that would quietly not apply. `OLLAMA_KV_CACHE_TYPE=q8_0` roughly halves KV cache memory, which is often the cheapest way to fit a longer context. ## Stopping a server A server Nekko Agent started is stopped with its own handle, cleanly, and it will not outlive the app. A server that was already running when Nekko Agent found it is somebody else's process: stopping that one asks first and names what it would end, because the thing holding port 11434 might be a service you depend on. LM Studio is the exception that needs no such warning, since it has a real shutdown command rather than a process to kill. ## Long tasks on a small context window A local model is often loaded with an 8k or 32k context, and an agent fills that in a few steps: one large file read or one noisy build log is most of it. Nekko Agent keeps the work going rather than stopping when the window fills: - **It uses the window the model was loaded with**, not the model's advertised maximum. LM Studio and the Nekko engine report it in their model list, and Ollama reports it for a loaded model. For anything that reports nothing, it falls back to a guess from the name. - **Older tool output is left out of the request** once the conversation nears 80% of that window. The oldest large results (file reads, command output) are replaced, in what is sent, by a one-line note saying how much was there; the chat itself keeps everything, and the model can simply run the tool again if it needs that output back. - **If the server still says the prompt is too long**, the request is sent once more with all of the older tool output left out. - **If that is not enough, the chat compacts and carries on by itself.** The older part of the conversation is summarized, and the agent is asked to continue from the summary. This happens at most twice in a row before the chat waits for you, so a model that cannot make progress does not loop. Giving the model more room is still the best fix. Raise its context when you load it (the Models page shows what that costs in memory), and `OLLAMA_KV_CACHE_TYPE=q8_0` roughly halves the memory a longer context needs. ## Pictures and models that cannot see Only a vision model with its projector file (`mmproj-*.gguf`) beside it can look at a picture. The Nekko engine tells the app which of its models have one, so for a text-only model such as Qwen3 4B the composer greys out **Photo / Video** and says why. A picture can still end up in a text-only chat: you attached it while a vision model was picked and then switched, or you used a server the app cannot classify. Such a server rejects every request that carries an image, and the image stays in the conversation, so before this guard the chat failed on every later message too. Now, when the model is known not to take images, each picture is sent as a one-line note ("1 image attachment omitted: this model does not accept image input") and the chat keeps working. The picture stays in the chat itself, so switching back to a vision model sends it again. A server that does not say what its models take still receives the pictures. If such a model refuses them, click **Delete last message** in the failure notice (or **Delete** on the message itself). That removes the message with the picture and anything after it, and the chat answers again. You can then ask again without the picture, or switch to a model with vision input. ## Dictation models The microphone in the message box turns speech into draft text on this computer; nothing is sent anywhere. The speech models it can use sit under **Models → System models** (the same rows are in **Settings → Dictation** and in the microphone's own setup popover), each with one action: **Use**, **Install language pack** or **Download**, and **Remove** or **Uninstall**. - **Apple speech** on macOS 26 and later: the recognizer built into the Mac. The language pack for your recognition language is often already installed, so the first click on the microphone just asks which engine to use. It is the default on a Mac that has it. - **Whisper Tiny English** (Nekko Voice) on Windows and Linux: a 32 MB model plus the whisper.cpp runtime, downloaded once and loaded on demand. Say "Nekko, open settings" (or "Nekko, new chat") and the app shows the command for confirmation instead of typing it. ## What is not here yet Browsing and downloading models from inside the app, and a bundled inference engine so a fresh install can run a model with nothing else present, are both planned as separate pieces of work. Today Nekko Agent manages the runtimes you already have. --- # Subscription sign-in Nekko Agent can run chat turns on your existing ChatGPT (Plus/Pro/Business) subscription instead of a metered API key, and can mint an OpenRouter API key for you through OpenRouter's own sign-in. Both are optional: API keys remain available for Anthropic, OpenAI and OpenRouter, and local model servers (Ollama, LM Studio, vLLM) still cost $0. ## Claude Nekko Agent does not sign in to Claude plans (Free, Pro or Max). Anthropic's terms do not let third-party apps offer Claude.ai login, send requests on Claude plan credentials, or collect Claude credentials, so Nekko Agent does none of these. Earlier builds did; on its first start after the update, Nekko Agent deletes any saved Claude sign-in from this computer, turns off the providers that used one, and shows a one-time notice. Your chats are kept. To use Claude in Nekko Agent, choose **Add provider → Claude**. One table compares the two ways to connect, and you pick by clicking a column: - **Subscription** - Nekko Agent runs the Claude Code you already have installed, on your Pro, Max or Team plan. You sign in inside Claude Code itself; Nekko Agent never sees that sign-in and never reads Claude Code's credentials. Chat, code edits, approvals and the phone remote work. Scheduled tasks, Nekko's own tools inside the chat, and usage limits in Nekko are off for now, because Anthropic's terms don't clearly cover them for third-party apps. - **API** - An Anthropic API key, or Claude through Amazon Bedrock, Google Vertex AI or Microsoft Foundry. Every Nekko Agent feature works, billed per token to that account. Below the table, **How Nekko works with Claude** draws the option you picked: with API, everything runs inside Nekko on your computer and only the API call goes out; with Subscription, Nekko hands each turn to Claude Code, which runs beside it and calls Claude on your plan. When you pick Claude, Nekko first checks whether Claude Code is installed (it runs `claude --version` and `claude auth status`). If it isn't, **Install Claude Code** opens a terminal inside Nekko and runs Anthropic's own installer (`curl -fsSL https://claude.ai/install.sh | bash`, or `irm https://claude.ai/install.ps1 | iex` on Windows). If it isn't signed in, **Connect** opens Claude Code in that terminal so you can type `/login`. Once it's connected, the panel says so in green, with the account and plan it uses. You can add more than one Claude account, subscription or API. See [Accounts and switching](https://nekkoagent.com/docs/accounts) for how, and for what Anthropic's terms allow with several Claude accounts. ## How it works The OAuth service in the engine daemon (`crates/nekkod/src/oauth.rs`) runs the sign-in flow: 1. **ChatGPT / Codex** - We use the public OAuth client that the Codex CLI uses. The app opens `https://auth.openai.com/oauth/authorize` and tries to listen on `http://localhost:1455/auth/callback` (the registered redirect for that client). After authorization, OpenAI returns an access token, a refresh token, and an `id_token`. The daemon decodes the `id_token` to extract your ChatGPT account id, which the backend requires. 2. **OpenRouter** - OpenRouter's PKCE flow listens on any free local port. After you approve, OpenRouter hands back an API key created for this app, billed to your OpenRouter balance. The daemon keeps the actual tokens in `tokens.json` inside the data directory. At chat time, it resolves a fresh access token and injects it into the request. The renderer only ever sees a sanitized status object: connected, account id, token key, and expiry. It never sees or stores the token itself. ## More than one account The model picker switches a chat between your accounts in one click; see [Accounts and switching](https://nekkoagent.com/docs/accounts). You can sign in more than one ChatGPT plan, for example a personal plan and a work one. Each gets its own provider, its own token and its own usage limits. To tell them apart, the app shows the email each one is signed in as next to its name, as in `ChatGPT · you@example.com`, in the model picker, the Utilization panel and the Capacity list. The email comes from the ChatGPT `id_token`, stays in `tokens.json` next to the token, and is not part of a settings export. A plan signed in before this was added shows no email until you sign it in again. API keys and OpenRouter keys carry no email, so they show just the provider name. ## Caveats The ChatGPT OAuth client is the same first-party client the Codex CLI uses. OpenAI's consumer terms are written around its own apps, so the flow may stop working at any time if OpenAI rotates the client id, changes a scope, or blocks the client. The API-key options are unaffected. ## Fallbacks If port 1455 is busy, the ChatGPT sign-in switches to manual mode: after you authorize, copy the address the browser lands on and paste it into the app. You can also import a sign-in you already did with the Codex CLI: `codex login` writes `~/.codex/auth.json`, and the app can read it and import the same token shape. If a subscription session expires, the provider card shows the expiry and a "Re-authenticate" button to sign in again. ## Security - Tokens are stored in `tokens.json` under the Nekko Agent data directory. The file is created with mode `0600` and the permission is re-applied on startup. - `tokens.json` is never included in a settings export. The export contains only provider configuration (base URL, label, and the opaque `tokenKey`), not the access or refresh tokens. - The renderer never receives token values. It can only request status, sign out, or re-auth. - Removing a subscription provider also deletes its stored token. - A CI check (`scripts/check-claude-compliance.mjs`) fails the build if Claude sign-in, Claude credential reading or Claude plan limits come back. --- # Accounts and switching Nekko Agent can hold as many accounts as you have, for any provider: two ChatGPT plans, a personal and a work Anthropic API key, an OpenRouter key, a Bedrock account, and two Claude Code logins, all in one window. Every chat picks its account, chats on different accounts run side by side, and you can move a chat to another account in one click. ![One Nekko Agent window with several accounts](https://nekkoagent.com/docs/media/accounts-one-window.svg) ## What counts as an account Each account is a provider on the **Models** page. Adding a second one of the same kind gives you a second account: | Provider | How you add another account | How Nekko tells them apart | | --- | --- | --- | | ChatGPT plan | **Sign in with ChatGPT** again, with the other account | The email the plan is signed in as | | Anthropic, OpenAI, OpenRouter API key | **Add provider** again with the other key | The label you give it | | Bedrock, Vertex, Foundry | **Add provider** again with the other account's credentials | The label you give it | | Claude subscription (your Claude Code) | **Add provider → Claude → Subscription → + Another subscription account** | The account name you give it, plus the email and plan Claude Code reports | Local model servers (Ollama, LM Studio, vLLM, the built-in engine) are computers, not accounts, so they never get a switcher. ## Switching accounts When the chat's provider has more than one account, the model picker opens with an **Account** row at the top. Click another account and the chat moves to the same model on that account. An account greys out when it does not offer the chat's model. ![Switching a chat to another account](https://nekkoagent.com/docs/media/accounts-switching.svg) - **API keys, ChatGPT plans and cloud accounts** run on Nekko's own engine, which keeps the chat's history. The next message goes out on the new account with the whole conversation, as if it had been there all along. - **Claude subscription accounts** run your own Claude Code, which keeps its sessions inside each account's folder. A switch therefore starts a **new Claude Code conversation** on the other account: the chat still shows everything above, but Claude Code on the new account does not see it. Switching back later starts another new one. - **New chats** start on the account you used last. To pin one, right-click a model in the picker and choose **Set as default**. Nekko does not switch accounts on its own unless you ask it to: in a chat on **✨ Auto** you can turn on **Follow capacity & cost**, and then a turn whose provider is spent runs on an equivalent model on another provider, which can be another of your accounts. Claude subscription accounts are never part of that: Auto never moves a turn onto a Claude plan, and a Claude subscription chat has no Auto. When a Claude subscription account hits its plan limit, the chat says so and waits for you. ## Several Claude accounts **Claude** is one provider with two kinds of account, and the Models page keeps them together in a **Claude** group. Each card says where it comes from: - `Subscription · Claude Code 2.1.263 · ~/.claude-work · you@work.com · Team plan`: a Claude plan, through Claude Code on this computer. - `API · Anthropic API key`, or `API · Amazon Bedrock · us-east-1`: an API or cloud account Nekko calls directly. **API accounts** (an Anthropic API key, or Bedrock, Vertex or Foundry) work like any other API provider: choose **Add provider → Claude → API → Connect** once per key or cloud account. **Subscription accounts** run the Claude Code you already have installed. Each one is a separate Claude Code login: 1. On the Models page, choose **Add provider → Claude**, then the **Subscription** column. Your usual Claude Code login connects first. 2. Choose **+ Another subscription account** and give it a name, such as `Work`. Nekko shows the folder Claude Code will keep that login in: `~/.claude-work`. 3. Choose **Sign in to Work**. A terminal opens inside the panel with Claude Code running on that folder. Type `/login` and finish signing in with Anthropic, in Claude Code's own flow. 4. Nekko reads who the new login is (`claude auth status` prints its email, organization and plan) and checks it against the accounts you already have: - **A different person or organization** (say, your company's Team seat beside your personal Max plan): **Add Work** is ready. - **Another personal plan** beside a personal plan you already connected: Nekko explains that Anthropic's plan limits "assume ordinary, individual usage" and asks you to confirm the account is for separate work, not for adding up your limits, before **Add** turns on. - **The same login again** (same email and organization as an account you already connected): Nekko won't add it. A second copy of one login could only be used to rotate between them around plan limits, which Anthropic's terms rule out. Sign in with a different account instead. Your usual login (`~/.claude`) stays as it was, so the first subscription account keeps working. The folder is an ordinary Claude Code config folder, the one Claude Code's documented `CLAUDE_CONFIG_DIR` variable points at, so you can use the same account from any terminal: ```bash CLAUDE_CONFIG_DIR=~/.claude-work claude ``` **What Nekko does and does not touch.** Nekko starts Claude Code with `CLAUDE_CONFIG_DIR` set to the account's folder, and asks `claude auth status` whether it is signed in, and as whom (email, organization and plan, which the CLI prints itself). It never opens the folder, never reads or copies the login inside it, never sets a token, and never changes the Claude Code binary. The folder name comes only from the account name (`a-z`, `0-9` and `-`), never from a path you type. **Usage.** Claude Code shows each account's plan usage itself (`/usage`). Nekko shows none for a Claude plan, because Anthropic has no approved way for another app to read it. ## What Anthropic allows Anthropic's rules for Claude Code ([Legal and compliance](https://code.claude.com/docs/en/legal-and-compliance)) separate two things: **you** signing in to the unmodified Claude Code with your own plan, which is allowed even when another app runs Claude Code for you, and **an app** offering Claude sign-in or handling Claude credentials, which is not. Nekko is built on the first and does none of the second. ![What Anthropic's terms allow with several Claude accounts](https://nekkoagent.com/docs/media/claude-accounts-allowed.svg) | What you want to do | Allowed? | Notes | | --- | --- | --- | | Use your own Claude plan through the Subscription option | Yes | You sign in inside Claude Code with `/login`; Nekko never sees the login. | | Keep a work and a personal Claude account, each for its own work | Yes | Each is a separate subscription account with its own login. | | Teammates each using their own account | Yes | Each person signs in to their own Claude Code, in their own Nekko. | | Any number of API keys or cloud accounts | Yes | Billed to whoever owns each key or account. Every Nekko feature works on them. | | One person buying several Pro or Max plans to add up their limits | Ask Anthropic first | Anthropic says plan limits "assume ordinary, individual usage". Nothing in its terms authorizes pooling plans, so treat it as a gray area and get written confirmation before a business depends on it. Nekko asks you to confirm a second personal plan is for separate work before adding it. An API key or a cloud account has no such question. | | Switch Claude accounts automatically when one runs out | No | Getting around rate limits is a compliance risk. Nekko only switches when you click. | | Add the same Claude login twice | No | Nekko refuses it: two copies of one login only serve to rotate around plan limits. | | Share one Claude login between people | No | Anthropic's consumer terms forbid sharing account credentials. | | Sign in to Claude inside a third-party app, or let it read your Claude Code credentials | No | Nekko removed its old Claude plan sign-in for this reason; see [Subscription sign-in](https://nekkoagent.com/docs/subscriptions). | This page describes how Nekko works, not legal advice. If something matters to your business, ask Anthropic. ## Accounts on another computer Nekko runs Claude Code on the computer it runs on. To use an account that lives on another machine (for example Claude Code inside WSL on a Windows desktop), open a terminal to it from **Terminal → SSH hosts** and run `claude` there: it appears as a terminal tab in the same window, with that machine's login, but not as a Nekko chat. Running a Claude subscription chat on another computer, with its approvals and transcript in this window, is not built yet. Your phone works the other way round: it drives the Nekko on your computer, with whatever accounts that computer has. See [Phone remote control](https://nekkoagent.com/docs/remote). --- # Usage limits and price estimates This page explains where Nekko Agent's usage numbers come from and what they mean. ## Where the limit numbers come from The app does not guess your usage. It asks the provider: - **Claude (Anthropic API key)** - Every response on an API key carries the standard `anthropic-ratelimit-requests-*`, `-input-tokens-*` and `-output-tokens-*` headers: that key's own per-minute limits. The engine daemon reads them after each turn. Nekko Agent shows no Claude plan (Pro/Max) limits: Anthropic does not let third-party apps offer them, and there is no sanctioned source to read them from. Check a plan's usage in Claude Code itself. - **ChatGPT (Codex subscription)** - The host polls `GET https://chatgpt.com/backend-api/wham/usage` with the current Bearer token and `ChatGPT-Account-Id`. The endpoint returns optional `primary_window` and `secondary_window` usage, plus plan type and credits. These are slots, not fixed periods: a primary window can be weekly, and either slot can be absent or null. Each window's `limit_window_seconds` determines its identity and label. Reported durations of 18,000 and 604,800 seconds become `5-hour` and `7-day`, respectively, regardless of slot; other positive integer durations get explicit duration labels. Missing or invalid durations retain the usage/reset data under neutral `Primary usage` or `Secondary usage` labels; neither the slot nor time until reset is used to guess a period. Source: OpenAI's public Codex source at commit `b741e480e203f037ca726bc2a76d99a8e8668e66`: [nullable window slots](https://github.com/openai/codex/blob/b741e480e203f037ca726bc2a76d99a8e8668e66/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_details.rs#L21-L34), [window duration field](https://github.com/openai/codex/blob/b741e480e203f037ca726bc2a76d99a8e8668e66/codex-rs/codex-backend-openapi-models/src/models/rate_limit_window_snapshot.rs#L14-L22), [duration conversion](https://github.com/openai/codex/blob/b741e480e203f037ca726bc2a76d99a8e8668e66/codex-rs/backend-client/src/client.rs#L741-L753), and [duration-driven labels with neutral fallbacks](https://github.com/openai/codex/blob/b741e480e203f037ca726bc2a76d99a8e8668e66/codex-rs/tui/src/chatwidget/rate_limits.rs#L103-L145). Nekko uses exact reported durations rather than Codex's approximate label matching. All of these are normalized into the same `SubscriptionLimits` shape in `packages/shared/src/limits.ts`. That shape has no unknown/general-duration scope: ChatGPT durations of at least seven days use `weekly`, shorter durations use `session`, and unknown durations use `session` only as a compatibility fallback. The displayed label, not that fallback scope, describes the reported period. ## Live reads, not estimates The numbers in the chat header chip and the Models card are live reads from the provider. They update after each subscription response and on a throttled poll. Nekko Agent does not extrapolate or predict your usage locally; if a provider does not return a window, that window is simply not shown. ## Utilization and Budget on the Agents wall Utilization lists every enabled cloud provider on its own row. A Claude plan card left over from an older build says the plan sign-in is no longer available and points to adding an API key in Models. Budget defaults to **1m**. Its ranges use inclusive **UTC** days: today, 1wk (7 days), 1m (30), 6m (180), 1y (365), and all-time through today. The active button has an accent tint and pressed state. All recorded details follow that selection: estimated API spend, input/output tokens, top agent by API cost, local tokens, local/subscription savings, fallback and unpriced-token counts, and which used models appear in the pricing comparison. Catalog unit prices and the selected fallback are reference/configuration values, not range totals. Older hosts without daily detail show unavailable details rather than substituting lifetime totals. Savings are estimated API equivalents before subscription fees, hardware and electricity, not actual net savings. Local and subscription usage has $0 incremental recorded API spend. Unknown local models use the explicitly chosen fallback or remain unpriced; unpriced tokens are excluded from savings. The 2026-10-13 batch is implemented, unit and fixture interaction tested with TS/Rust daily-summary golden parity coverage. Image and motion inspection are pending; parent-owned landing is still outstanding. ## Off-label caveat The ChatGPT usage endpoint used here is reverse-engineered from the Codex CLI's subscription flow. They can change, move, or stop returning data at any time. Nekko Agent is not affiliated with Anthropic or OpenAI, and the readouts are provided as a convenience for subscribers, not as an official feature of those services. ## Prices are list estimates, not a bill `MODEL_PRICING` in `packages/shared/src/limits.ts` is a table of published list prices, matched by model id substring. The per-model labels (`$X/$Y per MTok`) and the session total in the chat header are estimates only. Your actual provider bill may differ because of taxes, discounts, cached-token pricing, batch pricing, enterprise agreements, image or tool-token costs, or mid-rate changes. Local models (Ollama, LM Studio, vLLM) and subscription providers are shown as `Free` or `Included in plan` because they are not metered through an API key in Nekko Agent. ## Limits are provider-side Nekko Agent can only read and surface the limits that Anthropic, OpenAI and OpenRouter return. It cannot raise, reset, or bypass them. If a window is reported as rate limited, that state comes from the provider; Nekko Agent does not create or remove the limit. --- # Phone remote control Drive the Nekko Agent running on your computer from your phone: start and manage chats, watch and steer training runs and goals, approve risky commands, and get a push when a long run finishes. Inference and tools always execute on **your** machine, under its guardrails; the phone is a thin client. ## How it works ``` [ Phone ] --TLS--> [ relay ] <--outbound WSS-- [ your computer (agent) ] ``` - Your computer **dials out** to a relay and claims a room. No inbound ports, no port-forwarding. - Your phone connects to the same relay with the pairing credentials. - Every payload is **end-to-end encrypted** (AES-GCM, key derived from the pairing secret). The relay routes ciphertext; it cannot read prompts, replies, files, or model output. - On top of the encrypted transport, every device must pass a **handshake against your computer's device registry**: new devices enroll only through a **single-use pairing code that expires in 10 minutes**, and your computer answers each device individually (never broadcast). ## Pairing a phone 1. On your computer: **Settings → Phone pairing** (second in the left-hand list), then in **Remote access** pick how your phone connects: **Use Tailscale** (free, needs the Tailscale app on both) or **Use Nekko Cloud** (works anywhere, nothing to install). A self-hosted relay goes under **Use your own relay**. Then **Pair a device**. The QR appears right under the button. 2. On your phone: scan the QR in the Nekko Agent app (**Computers → Pair a computer**), or with the phone's camera app, which hands the `nekko-agent-pair:` link to the Nekko Agent app. Without the app, the pairing link also opens the web app in a browser over the relay, but it is limited to what a phone may do (below), so most desktop screens there are unavailable. 3. The phone shows up under **Paired devices**, with a live connection dot. ## The phone app > **Native apps.** The phone apps are moving to platform-native code: Kotlin / Jetpack Compose in > `apps/android` (pairing, chats, live replies, approvals and questions; no phone models yet) and > SwiftUI in `apps/ios`. The Expo app described below stays as the reference until they reach parity. > > The Android app looks like your desktop by default: **Settings → Appearance → Same as computer** > uses the computer's light or dark theme and its accent color, and follows a change on the > computer right away. Pick **Phone setting**, **Light** or **Dark** to choose on the phone instead; > that choice is kept on the phone. Only the theme, theme preset and the two accent colors are sent > for this (the `appearance:updated` event), never the rest of the computer's settings. > > The iPhone app has the desktop's themes too: **Settings → Appearance → Theme** offers **Match my > computer** (the computer's preset, mode and accent), **Match this iPhone**, **Light**, **Dark** and > every desktop preset (Nebula, Terminal, Nord, Solar, Lilac, Lavender, Ember, Spooky and the > experimental families). In October, **Spooky** also switches the app icon to a limited fall > version; iOS confirms the change, and the usual icon comes back with any other theme or after > Halloween. The iPhone app speaks the desktop's languages (English, Spanish, French, German, > Portuguese, Japanese and Chinese): it follows the iPhone's language, and **Settings → Appearance → > Language** opens iOS Settings to pick one for Nekko Agent alone. > > On the iPhone, **New chat** also picks the **Effort** (the levels the chosen model takes, or the > computer's default) and, when the model is a local one (llama.cpp, Ollama, LM Studio, vLLM), > **Run on**: which paired computer the chat and its model run on. Choosing another computer > connects to it and shows its models and folders; what you typed stays. > > The Android app's **Settings** also has a **Computers** row, which opens the list of paired > computers (rename, switch, forget, pair another), and an **About** section with the installed > version and the open-source licenses of the app and the libraries it ships. > > **New chat defaults** (Android): **Settings → New chats** picks the computer the app connects to > when it starts (default: the one used last) and, for each computer, the model New chat opens with > (default: the computer's own default model). They are saved on the phone; the computer's settings > don't change. If that model is no longer offered, New chat says so and uses the computer's default. > **Share to Nekko** (Android): in any app's share menu, pick **Nekko Agent** to start a new chat > with the shared text or link. You can edit it on the New chat screen; after you start the chat it > waits in the message box and you send it yourself. Nekko never sends shared text on its own, and > it is kept only in memory until then. Text and links only for now, up to 20,000 characters. > **Vibration** (Android): while the app is open, the phone vibrates when the agent needs your > approval (two pulses) or asks you a question (one long pulse). In **Settings → Vibration** you can > also turn on a short tap when a reply finishes and three taps when it fails, or switch any of them > off. It never vibrates in silent mode or Do Not Disturb, or while the app is in the background. > > **Your computer's settings from the phone** (Android): **Settings → On ** changes three > of the computer's own settings: the chat mode for new chats (Ask, Guardrails or YOLO; YOLO asks once > before switching), what a long-press on a chat does (Complete, which moves it to Completed, or > Delete), and whether deleting asks first. They are saved on the computer, the same settings as in > its Settings. A long-press on a chat in the list then completes or deletes it on the computer. > > **Write offline** (iPhone): with no connection to your computer you can still write. What you > type in a chat's message box, and in New chat, is kept on the phone, so leaving the screen or > the phone closing the app loses nothing. **Send when online** puts a prompt in a queue on the > phone, shown in the chat as **Waiting to send** (Edit, Delete, Send now); a new chat started > offline waits under **Waiting to send** in the list. When the computer is back they go in order, > and each prompt runs once even if the connection dropped mid-send: the computer recognises a > prompt it already has by its id. If the computer refuses one (the chat was deleted, say), it comes > back to the message box with the reason. Approvals and answers to questions are never queued; > they need the computer to be reachable. The widget and the Watch show how many prompts are waiting. > The drafts and the queue are encrypted on the phone until it is first unlocked, aren't backed up, > and are deleted when you forget the computer. Sending prompts written offline needs this version > of Nekko Agent on the computer. > > **Read offline** (iPhone; the chat list on Android too): the phone keeps each computer's chat list > and, on iPhone, the last 20 chats you opened (up to 5 MB per computer; a very long chat keeps its > newest messages). Opening the app or a chat shows them at once, and while the computer can't be > reached they stay readable under **Saved list from 10:42** and **Saved copy from 10:42**. When the > computer answers, the live list and chat replace them in place. A saved chat can't be changed > (model, tool policy, rename, complete) until the computer is back, but what you send waits as > above. Photos are never kept, only how many a message had. Both are on unless you turn them off > in **Settings → Offline** (Android: **Settings → Connection**); turning a switch off deletes what > it kept. Like the drafts, the copies are encrypted until the phone is first unlocked, aren't > backed up, and are deleted when you forget the computer or a chat is deleted on it. > > **Beyond the chat** (iPhone): the phone is a remote for the agent, not a second desktop. > - **Edit the queue:** a follow-up sent while a reply runs waits under **Queued** with Edit and > Remove. An edit only lands if that prompt hasn't started yet; otherwise nothing changes. > - **Fork:** touch and hold a message. On your prompt, **Edit in a new chat** starts a new chat with > the conversation before it and the prompt in the message box; on a reply, **Fork from here**. > - **Photos:** tap a photo in a message to see it full screen; **+ → Paste image** adds one from the clipboard. > - **Saved prompts:** the prompts you saved on the computer show in `/` and under **+**, run like skills. > - **`/` after what you've typed:** typing `/` after some words (after a space or on a new line) > opens the same menu. Picking a skill keeps your words and adds the skill; picking a saved prompt > puts its text in place of the `/`. > - **Continue and search:** the list opens with your most recent chat; search finds completed chats too. > - **Automations:** **Computers and settings → Automations** lists the computer's tasks and > workflows with how each last went, and **Run now**. You set them up on the computer. > - **Pull requests:** a chip shows the chat's pull request and its checks. **Commit these changes**, > **Open a PR** and **Fix the failing checks** send a prompt to the chat, so the agent does the git > work on your computer and asks you before anything that needs approval. Or do it yourself from > the phone (below). > - **Git:** **More → Git** in a chat (or the pull request chip → **Git: status, commit, push**) > shows the chat folder's branch, how far it is ahead or behind, and its changed files, staged and > not, each with its diff. Swipe a file to stage or unstage it, or use **Stage all**. Type a commit > message or tap **Write message** to have your computer's model write one for what is staged (it > uses that model's tokens), then **Commit** and **Push** (a new branch is published to its remote > the first time). **History** lists commits with each one's message and files; **Branches** > switches branch or makes a new one; **Compare** shows what one branch has that another doesn't; > **Open a pull request** takes a title, a description, a base branch and Draft. A pull request's > **Checks, files and actions** lists its checks with links, its changed files, and **Approve**, > **Merge**, **Close** or **Reopen**. **Abort merge** shows while a merge stopped on a conflict. > Every one of these changes asks for Face ID or your passcode (once per 5 minutes) and shows in > the phone's *Activity* on the computer. > - **Files:** **More → Files** in a chat opens that chat's folder (its worktree when the chat is > isolated); **Computers and settings → Files** lists the computer's saved folders. Tap through > folders, or search by file name. A file opens in monospace with line numbers (Markdown can be > shown formatted, images as pictures); a long file shows its first 256 KB with a note. **Edit** > a text file up to 256 KB and **Save**: Face ID or your passcode is asked (once per 5 minutes) > and the save shows in the phone's *Activity*. If the file changed on the computer after you > opened it, the phone says *Changed on the computer* and you choose **Reload** (your edits are > dropped) or **Save over it**; nothing is overwritten without that choice. Files that may hold > secrets are counted under the list, never shown. > - **Logs:** **Computers and settings → Logs** lists the desktop app's log, the engine's log and > each chat's agent run log. A log opens at its newest lines; **Refresh** reads it again, and the > search field shows the matching lines with their line numbers. Read only, with secrets already > replaced by `[redacted]` on the computer. > - **Usage and limits:** what each provider plan has used and when it resets, and recent usage. Read only. > > **All your chats, grouped like the desktop** (Android): the chat list shows every chat on the > computer, completed ones included, in the same groups as the desktop's agent sidebar: **General** > first, then one group per project folder. Tap a group's name to fold it. Each group lists its > active chats, then **Show completed (N)**, which opens the chats completed there (struck through, > newest first). Tap a completed chat to read it; its message box is replaced by **Restore**, and a > long-press on it offers Restore or Delete. Completing or restoring a chat on the computer shows up > on the phone the next time the list refreshes: when a reply finishes, when you pull down, or when > you come back to the app. > > **Photos and dictation** (Android): the **+** on the left of a chat's message box opens **Dictate**, > **Image from files** (the phone's photo picker; Nekko only sees the photos you pick) and **Take > photo** (the phone's camera app). Up to 4 photos go with the next message, shown as thumbnails you > can remove; they are scaled to 1600 px and sent end-to-end encrypted to the computer, which hands > them to the chat's model like a photo pasted on the desktop. A text-only model doesn't see them. > The photos stay in the message's bubble on the phone too, as small previews the computer makes > (the newest 40 photos in a chat, including ones attached on the computer); an older photo shows as > a plain tile, and the full-size original stays on the computer. > > **Dictation** (Android): tap **+ → Dictate**, speak, and tap ■ (or stop > at 30 seconds). The words go into the message box for you to check and send; nothing is sent by > itself. By default the speech is turned into text by **Nekko Voice on your computer** (Whisper, > end-to-end encrypted, the audio never reaches anyone else); install and switch it on there under > **Settings → Nekko Voice**. When the computer can't, the phone's own speech recognizer is used, > which on many phones sends the audio to Google. **Settings → Dictation** on the phone lets you use > only the computer, only the phone, or turn the microphone off. > > **Skills** (Android): type `/` in a chat's message box, at the start or after a space at the end > of what you've written, to see the skills on your computer: the built-in ones, those you > installed from the Marketplace for Nekko Agent, and the ones Nekko Agent found in your Claude > Code, Codex or Gemini skill folders. Pick one, add details if you like, and send. Text you typed > before the `/` stays in the box and goes along as the skill's details. Nothing is copied to the phone: the list is read from the computer each > time you open a chat, so a skill you install or remove there shows up on the phone right away, > and the computer adds the skill's instructions itself, so they never leave it. `/goal` is not > offered on the phone, and skills are installed and removed on the computer. > > **Long chats on the phone**: the phone gets the newest 300 messages of a chat, without the > screenshots inside tool results and with long tool output shortened to 4,000 characters, so even > a very long agent run opens in seconds. The chat says how many earlier messages, characters and > images stayed on the computer; open it there for the full detail. > > The Android app is **locked by default**: every time you open it, or come back to it, it asks for > your fingerprint, face or the phone's screen lock (PIN, pattern or password) before it shows > anything. While it is locked nothing can be read or approved, and the recent-apps preview shows a > plain cover instead of your chats. After a restart it doesn't connect to your computer until you unlock. Turn it off in > **Settings → Security → App lock**; turning it off asks for the same check first. Screenshots and screen > recording of the app work as usual, lock on or off; on Android 14 and later the screenshot toolbar > some phones show afterwards (Samsung) no longer hides the app behind the cover. A phone with no screen lock can't be locked; > Settings says so and links to the phone's security settings. > > The Android app **reconnects by itself** as soon as the phone changes network (Wi-Fi to mobile data, > airplane mode off), instead of waiting out its retry delay. While a chat on your computer is running, > **Settings → Connection → Stay connected while a chat runs** (on by default) keeps the app connected > after you leave it, with a silent "Staying connected · 1 chat running on Mac" notification that goes > away as soon as the reply is done. **Notify me** (on by default; Android 13+ asks for permission) > sends a notification when a reply finishes or fails, or a chat waits for your approval or answer, > while you're out of the app. Notifications say what happened and in which chat, never the reply, the > command or the question; with the app lock on they don't name the chat either, and tapping one opens > the chat through the lock. Without push (not built yet), these only arrive while the phone is still > connected. If Android restricts Nekko in the background, Settings says so and opens the app's > system page. The iOS and Android app (`apps/mobile`) is built for the phone rather than being the desktop on a small screen: your computer's chats with **Working** / **Needs you** badges, new chats on any of the computer's models and folders, live replies, **Allow this?** cards for risky commands, the agent's questions as tap-to-answer choices, follow-ups that queue while a run is going, and Stop. It can pair several computers and talks to one at a time. It also runs **models on the phone itself** (llama.cpp, curated small GGUF models from Hugging Face sized to the phone's memory), so you can keep chatting when the computer is off. Those chats never leave the phone and don't use the relay. **The native iPhone app** (`apps/ios`, SwiftUI) does the same over the same encrypted relay, and adds what only iOS can: a **Live Activity** for each running chat on the Lock Screen and in the Dynamic Island, with **Allow / Deny** for a waiting tool and **Stop**; notifications with Allow / Deny actions while the app is in the background (Allow asks you to unlock first); photo attachments; and dictation (Whisper on your computer when Nekko Voice is installed, else the iPhone's own speech recognition). Its pairing screen leads with Tailscale and shows whether Tailscale is on on the iPhone. If a Tailscale relay can't be reached, the app says which side is the problem: Tailscale is off on the phone, or the computer isn't answering. With the app lock on (Face ID or passcode, on by default), the Lock Screen and notifications never show chat titles or commands. On the phone, the pairing key and the derived encryption key live in the iOS Keychain / Android Keystore. The app drops any provider API keys your computer sends with its settings and never stores them. Manage devices from the same card: **rename** them, **revoke** one instantly (it's kicked live and denied from then on), or **Rotate secret** to cryptographically reset the room, which unpairs everything at once. A revoked phone can come back only the way a new one does: choose **Pair a device** on the computer and scan the new QR from the phone (the one-time code is the proof). Older computers refused a revoked phone even then; on those, **Rotate secret** first. Remote access survives restarts: if it was enabled, the agent reconnects when Nekko Agent starts. ## What a paired phone can do Your computer decides what a paired phone may ask for, whatever the phone app does: locking the phone app is not the boundary, your computer is. A phone can use your chats and nothing that changes how the computer itself is set up: - **Allowed:** list, open, start, fork, complete and delete chats; send, queue, edit or remove queued, and stop messages; answer approvals and the agent's questions; pick a model from the computer's providers and a folder from its list; voice status and transcription; read a chat's pull requests, the computer's automations, its usage and plan limits, and run an automation now; git in a chat's folder and its terminals and SSH (below); a chat's or saved folder's files and the computer's logs (below); and a few settings (below). - **Never allowed from a phone:** wiping data or resetting settings, remote access itself (pairing, revoking, rotating the secret), terminals not opened for a chat, files by a path on the computer or outside a chat's or saved folder, creating files, accepting or reverting changes, git outside a chat's own folder, restacking or linking stacked pull requests, making, changing or deleting automations, adding or removing folders, installing or removing voice models, connecting or disconnecting connectors, provider keys and sign-ins, MCP servers and hooks. Everything not on the allowed list is refused too, with an error that starts *Not allowed from a paired phone*. - **Settings a phone may change:** the chat mode for new chats (Ask, Guardrails or YOLO), what the chat pane's top-right button does (Complete or Delete), whether deleting asks first, and voice on/off and its language. An update that touches any other setting is refused as a whole: nothing in it is applied. - **Settings a phone can read:** only the theme and accents, the default and starred models, your saved prompts (name and text), whether automations are paused, and the settings above. API keys, tokens, provider details, folders, hooks, guardrails and your instructions never leave the computer this way; provider lists arrive as name and type only. - **A chat's changes, read only, only if you allow it:** with *Let phones see changes* on (off unless you turn it on, in Remote access on the computer), a phone can see a diff of the files the agent changed in a chat. Your computer works out the diff and sends only that: short paths, a capped number of lines, and files that may hold secrets (`.env`, keys, `.ssh`, `.aws`) counted but never shown. A phone still can't accept or revert changes or read any other file. - **Git in a chat's folder:** a phone names a chat, never a folder, and your computer runs git in that chat's checkout (its worktree when the chat is isolated); a folder's own id from a phone is refused. Reading (status, diffs, history, branches, compare, a pull request's checks and files) needs *Read*; staging, committing, a model-written message, pushing, switching branch, aborting a merge, opening a pull request and approving, merging, closing or reopening one need *Change* and a fresh signature. What reaches the phone is smaller than the desktop's view: at most 100 files, 300 lines of each file's diff and 256 KB of diff per answer. Files that may hold secrets (`.env`, keys, `.ssh`, `.aws`) are listed, so a commit never carries one unseen, but their diff is never sent. Commits show the author's name, not their email; no path on the computer is sent. A pull request is named by its GitHub address only. Pushes never force, and commits never skip hooks or amend. - **Files in a chat's or saved folder:** a phone names a chat or a saved folder, never a path on your computer. Paths are relative to that folder, and anything that leaves it (`..`, absolute paths, a link pointing outside) is refused. Listing, searching by name and opening a file need *Read*; saving needs *Change* and a fresh signature. A phone only saves over the version it opened (your computer checks the file's hash) and never creates a file. What reaches the phone is smaller than the desktop's view: at most 500 entries per folder, 100 search results, 256 KB of a file's text and images up to 2 MB; a save is at most 256 KB. Files that may hold secrets (`.env`, keys, `.ssh`, `.aws`, git credentials and `.git/config`) are refused before your computer even looks, and left out of listings and search results (counted, never named). - **Logs, read only:** the desktop app's log, the engine's log and each chat's agent run log, with *Read*. A phone gets at most the last 256 KB of a log, or up to 500 matching lines of a search (each cut at 2,000 characters). API keys, tokens, passwords and other secret-looking values are replaced with `[redacted]` before a line leaves the computer, and a search only matches the redacted text. - **Terminals and SSH in a chat:** a phone sees only terminals opened for a chat and the agent's command log (read only), never your other terminals, and only one chat's list at a time. Reading a terminal's last 64 KB and the SSH hosts needs *Read*; opening a terminal, starting a new one (in the chat's folder, with your default shell; a phone can't pick a folder or a shell), opening an SSH session, Ctrl-C and closing need *Change* and a fresh signature. Opening hands the phone a token for typing into that terminal only, so it doesn't ask for Face ID on every key: it ends when the phone leaves or disconnects, after 5 minutes without typing and after 30 minutes in any case, and your computer checks the phone's *Change* switch on every keystroke. Output goes only to the phones that opened that terminal, at most 32 KB every tenth of a second (a flood is cut short and the phone reads the end again). The desktop shows *Phone attached* on a terminal while a phone has it open. SSH hosts arrive as alias, host name and port (no user name); SSH runs on your computer with its own keys and config, and nothing about the keys reaches the phone. Running a command without typing it, renaming terminals and listing shells stay off. - **One chat's options:** a phone can change a chat's model, title, pin and tags, complete it, and switch it between Ask, Guardrails and YOLO, but not its tools, isolation or offline/incognito switches. Messages cannot attach files from the computer. Moving a chat (or the default for new chats) to a mode with fewer approvals, Ask to Guardrails or anything to YOLO, is confirmed on the phone like any other change: Face ID or the passcode on the iPhone. Android can't confirm changes yet, so there you change the mode on the computer. ### What phones can do: Read and Change, per phone Git, files, logs, terminals and SSH are on the iPhone now (above); task boards are coming. *Remote access → What phones can do* on the computer lists each paired phone with two switches: - **Read:** see your chats and their messages, settings, models, automations, usage and limits, and look at files, git status and history, logs and boards. - **Change:** send and queue prompts, stop a run, approve tools, answer questions, start, fork, delete and change chats, change the phone-settable settings, run an automation, transcribe a voice prompt; edit and save files, commit and push, branch and pull request actions, type in a terminal, edit boards, SSH. Both are on for every phone, including phones paired before the switches existed. Turn one off and your computer refuses those requests from that phone, whatever the phone app does: with Change off a phone can watch but can't make the agent do anything, and with Read off it sees nothing. Every request a phone can make is in one of the two; anything else is refused. **File, git, terminal and SSH changes, and moving a chat to Guardrails or YOLO, are confirmed on the phone and checked by your computer.** (Sending a prompt or approving a tool needs only the Change switch, so it doesn't ask for Face ID each time.) The first time a phone makes a change, it creates a key in its Secure Enclave (on iPhone) that only Face ID, Touch ID or the passcode can use, and gives your computer the public half. Each change after that is signed with the key, and your computer checks the signature before doing anything: a change with no signature, a wrong one, one more than 5 minutes old, or one it has already seen is refused. One Face ID covers 5 minutes, as for *Allow* from the Lock Screen. Your computer trusts the first key a phone gives it. A phone that later shows up with a different key is refused until you remove it and pair it again. *Forget all computers* on the iPhone deletes its key too. **Activity.** *Activity* under each phone lists every change it made or tried, prompts and approvals included, newest first, with why one was refused (Change off, no fresh Face ID or passcode, a changed key). Your computer keeps the newest 200 per phone; rotating the secret clears them. **Older phone apps.** Phone apps from before relay replay protection (below) still connect for one release. Your computer writes it to its log, shows *Older app: update it on the phone* next to that phone and adds one *Connected with an older app* line to its activity. To refuse them now, set `"acceptLegacyPhones": false` in `remote.json` in the data folder; such a phone is then told to update its app. **Revoke** a phone to stop all of it at once. Whatever the switches say, a phone can never reach provider keys or other credentials, remote access itself (pairing more devices, revoking, rotating), wiping data or resetting settings, or the computer's own sign-in windows, dialogs, updater and shell. ### Where the logs live The logs a phone reads are files you can open yourself: `logs/app.log` and `logs/nekkod.log` in the data folder (`~/.nekko-agent` unless you moved it). Each is rotated at 5 MB, and the last three old copies are kept (`app.log.1` to `app.log.3`). Windows' engine log holds only start-up lines and crashes for now. ## Security model, honestly stated | Threat | Defense | |---|---| | Relay operator reads your traffic | Impossible by construction: E2E encryption, relay sees ciphertext only | | Stranger connects to your room | Needs the pairing secret (128-bit) for transport auth, then a registered device or a live pairing code | | Leaked/stale pairing link | The one-time code is single-use and expires in 10 min; the link is scrubbed from the phone's URL/history after pairing | | Lost/stolen phone | The Android app asks for the phone's fingerprint, face or screen lock every time it opens. Revoke the phone in Settings: kicked immediately, denied afterward, push token dropped | | Revoked device replays old creds | The agent refuses its handshake and unicasts nothing to it; it gets back in only with a fresh one-time pairing code from the computer's owner; rotate the secret for a full cryptographic reset | | Malicious relay withholding/reordering | Availability only; it still can't read or forge frames (AES-GCM auth) | | Phone asks for something dangerous | Your computer only serves the phone allowlist above (no wipe, settings reset, remote-access changes, terminals outside a chat, file writes); tool calls run on your machine under your guardrails/sandbox and chat mode | | Someone records the relay's traffic and plays it back | Every connection starts with a fresh random challenge from your computer that the phone's sealed hello must answer, once; every request after it carries that challenge and a number that only goes up. A replayed hello or request is dropped and never runs. (Phone apps from before this are accepted for one release, see *Older phone apps*.) | | Someone else picks up the phone, or the relay replays a request, to make a change | Each change must be signed by the phone's Secure Enclave key, which needs Face ID or the passcode (one check lasts 5 minutes); your computer checks the signature, its age and that it was not used before, and logs every attempt in the phone's activity | What the relay does see: connection metadata (IPs, timing, frame sizes), the room code, and, if you enable push, device push tokens plus the content-free fact "a run finished". That's the whole list, and it's why relayed local-model use is inherently zero-data-retention for content. ## Choosing a relay How the Android and iOS apps each handle the connection (URL rules, pairing, reconnects, push, background) is compared in [phone-connection.md](https://github.com/nekko-labs/nekko-agent/blob/main/docs/phone-connection.md). ### Nekko Cloud (the managed relay): `wss://nekko-agent-relay.fly.dev` The app calls this **Nekko Cloud** and never shows the URL. Zero setup; free during beta with per-connection rate limits. When Nekko Cloud launches, the managed relay becomes part of the paid plans (it already supports gating via `NEKKO_RELAY_AUTHZ_URL`), while self-hosting stays free forever. ### Private relay over Tailscale (free, no account, nothing hosted) If your phone and computer are both on a [Tailscale](https://tailscale.com) network, you do not need a hosted relay at all: run the relay **on the computer** and bind it to the computer's Tailscale address. Only devices on your tailnet can reach it, Tailscale encrypts the hop (WireGuard), and the app's end-to-end layer still sits on top. This is how free remote-desktop tools such as Moonlight stay free: nothing runs in the cloud, the two ends find each other over a network you already own. **In the desktop app:** Settings → Remote access → **Use Tailscale**. The app finds this computer's Tailscale address (any interface in `100.64.0.0/10`), starts the bundled relay bound to that address only, and turns remote access on through it. Phones dial the MagicDNS name (for example `ws://gomi.tailfd6474.ts.net:4400`) when the Tailscale CLI reports one, else the `100.x.y.z` address. Then **Pair a device** and scan the QR from the phone with Tailscale switched on. Without Tailscale on this computer the option links to Tailscale and stays off. - **It stays on:** once started it comes back with the app. - **It waits for Tailscale:** with Tailscale off it binds nowhere and waits, and starts when Tailscale connects. - **It follows address changes:** it moves when the tailnet address changes. - **It says why it isn't running:** for example Tailscale off, or the port (4400) already in use. - **It is on your Wi-Fi too:** next to the tailnet address it listens on this computer's private LAN addresses (192.168.x.x, 10.x.x.x, 172.16.x.x to 172.31.x.x; never the internet, and not the virtual adapters of WSL, Hyper-V, Docker or VMs). Pairing codes then carry both routes, and the iPhone app uses the LAN when it is on the same Wi-Fi and moves to Tailscale by itself when it leaves, coming back once the LAN answers steadily again. Switch **Also on this Wi-Fi** off to keep the relay on the tailnet only; pair the phone again after changing it. Phones paired before this keep the one route until they scan a new code. On the iPhone, **Settings → Connection** shows the route in use. **Check routes** looks up, connects to and says hello on each route with timings, and **Share diagnostics** shares those results and the connection log as text, without rooms, keys, codes or messages. It runs as its own process (`out/main/relay.js`, under the app's own runtime) and exits with the app. It has no push keys, so phones get no "finished" notifications through it; the in-app connection and Live Activities while the app is open work as usual. **Headless or without the desktop app**, the scripts do the same by hand: ```bash npm run relay:tailscale # Windows (PowerShell); or scripts/private-relay.sh on macOS/Linux ``` The script prints the URL to paste into **Settings → Remote access → Use your own relay**. Either way the relay only listens on the Tailscale interface, so agent enrollment without an access token (`NEKKO_RELAY_ALLOW_UNAUTHENTICATED=1` in the script) is safe here; never use that flag on a public bind. The computer refuses a plain `ws://` relay on the open internet, whether you press Connect or start a headless agent with `NEKKO_RELAY_URL`. It uses the same rule as the phones, below. Plain `ws://` is fine on a tailnet: the transport is already encrypted end to end by WireGuard, and the app's own end-to-end encryption sits on top. The Expo iOS app ships with Expo's default App Transport Security (arbitrary loads allowed). **The native iPhone app allows plain `ws://` only toward private networks**: Tailscale (`*.ts.net` names and `100.x.y.z` addresses) and your local network. It refuses a `ws://` link to anything on the open internet before dialing; use `wss://` there. **The Android app allows plain `ws://` only to Tailscale MagicDNS names (`*.ts.net`)**, so use the name the script prints (`ws://gomi.tailfd6474.ts.net:4400`), not the `100.x.y.z` address: Android can't allow an IP range, and blocks the bare address. If you use an address the phone can't reach (a bare Tailscale IP, `localhost`, or any other plain `ws://` host), the Android app says so on its pairing screen and in the chat list instead of spinning on "Connecting…". If you prefer `wss://`, enable **HTTPS certificates** in the tailnet's DNS settings and either put `tailscale serve --bg 4400` in front (needs Serve enabled once per tailnet) or terminate TLS with `tailscale cert` on your own proxy. Trade-offs: the computer must be awake and Tailscale running; push notifications need APNs/FCM keys on this relay like any other; Tailscale's free plan covers up to 100 devices and 3 users. ### Self-host (free forever, one command) The relay is a tiny stateless Node service; anything that runs a container can host it. **Docker** ```bash docker run -d --name nekko-agent-relay -p 4400:4400 --restart unless-stopped \ -e NEKKO_RELAY_ALLOW_UNAUTHENTICATED=1 \ ghcr.io/nekko-labs/nekko-agent-relay:latest ``` **Docker Compose** ```yaml services: nekko-agent-relay: image: ghcr.io/nekko-labs/nekko-agent-relay:latest ports: ["4400:4400"] environment: NEKKO_RELAY_ALLOW_UNAUTHENTICATED: "1" restart: unless-stopped ``` **Coolify** (recommended if you already run one): add a new service → Docker image `ghcr.io/nekko-labs/nekko-agent-relay:latest`, expose port 4400, attach a domain, and let Coolify's proxy terminate TLS. Point Nekko Agent at `wss://relay.your-domain.com`. **Fly.io**: `fly deploy -c apps/relay-server/fly.toml` from a repo checkout. Keep it at **one machine** (`fly scale count 1`): rooms live in a machine's memory, so agent and phone must land on the same instance. Put any self-hosted relay behind TLS (`wss://`) for internet use; the E2E layer protects content regardless, but TLS also protects the transport-auth query string. Then paste the URL into **Settings → Remote access → Use your own relay**. Environment knobs: | Env | Meaning | |---|---| | `NEKKO_RELAY_PORT` / `NEKKO_RELAY_HOST` | Bind (default `0.0.0.0:4400`) | | `APNS_KEY_P8` / `APNS_KEY_ID` / `APNS_TEAM_ID` | Enable iOS push | | `FCM_SERVICE_ACCOUNT` | Enable Android push (service-account JSON) | | `NEKKO_RELAY_AUTHZ_URL` | Gate agent enrollment on a Nekko Cloud account (managed hosting) | | `NEKKO_RELAY_ALLOW_UNAUTHENTICATED=1` | Explicitly allow unauthenticated agent enrollment for trusted/private deployments; otherwise agent enrollment is disabled when no authz URL is configured | The relay validates device IDs as UUIDs and push tokens as non-empty platform tokens. A connected device cannot replace another connection's push token for the same device ID. Keep the relay behind TLS (`wss://`) and do not enable the unauthenticated mode on a public endpoint. ## Headless agents A machine without a screen (server, homelab box) can expose itself with the relay-agent mode: ```bash NEKKO_RELAY_URL=wss://your-relay NEKKO_ROOM=myroom NEKKO_PAIR_KEY= npx nekko-agent ``` It prints a one-time pairing code (10 minutes) at boot and keeps the same persistent device registry as the desktop app. --- # End-to-end encrypted sync Nekko Cloud **Pro** and **Team** sync your chats between your computers and phones. The sync is end-to-end encrypted: your devices lock every chat before it leaves them, and Nekko Cloud only ever stores the locked copy. Nekko Labs can't read your synced chats. We don't hold the keys, and nobody can make us produce something we don't have. If you turn on **Help improve Nekko Agent** (off unless you turn it on), samples you share are encrypted too, with a key that never sits on any server, and carry nothing that ties them to your account. > **Status (2026-10-22):** sign-in (Supabase Auth), the sync engine, Settings → Sync on the computer and the iPhone's screens are built and tested end to end against a local copy of Nekko Cloud, and the findings of an internal adversarial review are fixed. Sync stays off for users until the Supabase project is connected, Nekko Cloud is deployed with it, and an outside cryptography review is done. See [What's built](#whats-built). ## What Nekko Cloud can and can't see | Nekko Cloud stores | Nekko Cloud never has | | --- | --- | | Your account email and plan | The text of any chat, title, prompt, reply or file | | Each device's public key and the short name you gave it | Any key that can decrypt a chat | | Encrypted records, their approximate size (padded), revision number and an opaque id | Which chat a record belongs to (ids are keyed hashes) | | When a record last changed | Your recovery key | | Encrypted donations, with the day they arrived | Who sent a donation, or their IP address | ## How it works ```mermaid flowchart TB RK["Recovery key
(256 bits, shown once,
kept by you)"] -- wraps --> KR DK["Device keys
(P-256, one per device;
Secure Enclave on iPhone)"] -- wrap --> KR KR["Keyring for this key epoch
record key ‖ id key (64 bytes)"] KR -- "HKDF(record key, fresh salt)" --> REC["One AES-256-GCM key per record"] KR -- "HMAC(id key, collection ‖ chat id)" --> OID["Opaque object id"] REC --> BLOB["Sealed blob
(all Nekko Cloud keeps)"] ``` - **Keyring.** The first device you turn sync on with creates a random 64-byte keyring: a record key and an id key. It never leaves your devices unencrypted. - **Records.** Each chat is stored as records (`chat`, `chat-part`, and later `prompt` and others). Every revision of a record gets its own AES-256-GCM key and nonce, derived with HKDF-SHA256 from the record key and a fresh 32-byte random salt, so no key is used twice and there is no limit on how many records one key can protect. The account, key epoch, record type, object id and revision are authenticated with it, so the server can't swap a record into another chat, another account, or roll it back to an older revision without the device noticing. The plaintext is padded (Padmé) to hide its exact length. - **Object ids.** The server files a record under `HMAC-SHA256(id key, type ‖ chat id)`. Every one of your devices computes the same id; the server can't link it to a chat. - **Devices.** Each device has its own P-256 key pair. On the iPhone the private key is created inside the Secure Enclave and never leaves it. The keyring is encrypted to each device's public key and **authenticated by the sending device's key**: ECIES (ephemeral ECDH, HKDF-SHA256, AES-256-GCM) with a second, static-static ECDH secret between the sender and the recipient mixed into the key, the shape of HPKE's Auth mode. The wrap names the account, key epoch, recipient and sender, so it can't be handed to another device, and only the sender could have made it. - **Trust.** Each device holds one key epoch and that epoch's sealed account record, which lists the devices the account trusts. A device takes a newer epoch only from a wrap made by a device in that list, one epoch at a time, and only once the new epoch's own record lists it. Someone who can sign in to your account (with an emailed code, say) or who runs the server can't hand your devices a keyring of their own. - **Adding a device.** Two ways. With the recovery key, the new device opens the current epoch itself. Or by approval: the new device shows a one-time **join code** (16 characters), and on a device that already syncs you choose Approve and type it. The code does two jobs. The new device published a tag of its key made with the code, so the approving device checks that the key Nekko Cloud shows is really the new device's (a key swapped in by anyone else fails). And the keyring is wrapped with a key derived from the code, so the new device knows the wrap came from someone you showed the code to. The code never reaches the server. - **Removing a device.** Revoking a device cuts its sign-in and deletes its wrapped keyring. The device that removed it then starts a new **key epoch**: a new record key, wrapped only to the devices you kept, and seals every chat again under it. Your devices ignore anything written under an older epoch and forget the old keyring, so the removed device can neither read what is written afterwards nor plant a change with the key it kept. Nekko Cloud only accepts writes under the newest epoch. - **Recovery key.** When you turn sync on, you get a recovery key: 55 characters in groups of five, with a checksum that catches a typo. You save it (copy it, or save the printable recovery kit with a QR code), then type it back so we know it was saved right. The recovery key stands for a P-256 key pair; every key epoch's keyring is also sealed to its public half, so your devices never hold the recovery secret, yet a rotation after removing a device is still recoverable. If you lose every device, the recovery key on a new one gets your chats back; without it, nobody can, including us. There is deliberately no password option: a password-derived key could be guessed by whoever holds the encrypted keyring, and that is the server. - **The account record.** Each key epoch has one more sealed record: the epoch number, the devices the account trusts, and the recovery key's public half. Only a holder of that epoch's keyring can write it, so the server can't slip a device of its own into the next key rotation, or swap the recovery key that new epochs are sealed to. A device refuses an account record that names another epoch or a different recovery key from the one it pinned, and never accepts an older revision of any record than one it has seen. ## Plans - **Pro and Team** can turn sync on (5 GB and 20 GB of encrypted storage per account). - **Free** keeps everything on your own devices, as today. - **Downgrading never locks you out.** If a Pro plan ends, your devices can still download and decrypt what was synced, and you can delete it; only new uploads stop. - **Delete everything synced** removes every record, keyring and device from Nekko Cloud at once. Your account stays, and your chats stay on your devices. ## Help improve Nekko Agent (opt-in) Off unless you turn it on, and turning it off changes no feature. When it is on: 1. You choose what to share (a chat, or a single reply you rated). 2. Your device removes secrets, email addresses, file paths and anything that looks like a key or token before anything else happens. 3. The sample is encrypted on your device to Nekko Labs' **research key**. Its private half is kept offline, never on a server, so the donation is ciphertext in Nekko Cloud's database just like your synced chats. 4. It is uploaded without your sign-in. Nekko Cloud doesn't record your account, device or IP address with it, and keeps only the day it arrived. 5. Your device keeps a delete token for each donation, so you can withdraw it later. Anything not withdrawn is deleted after 180 days. ## Threat model | If someone… | They get | | --- | --- | | Copies Nekko Cloud's database | Ciphertext, padded sizes, opaque ids, device public keys, account emails and plans | | Signs in to your account (gets an emailed code) | Your email and plan, the device list, and the power to delete what's synced. Not your chats: without a device your other devices trust, nothing they accept comes from them, and Nekko Cloud refuses key, device and data changes from a sign-in that holds no keys | | Runs a malicious Nekko Cloud | The same, plus the ability to withhold or delete records and to refuse service. It can't read, forge or relabel records, roll a device back to a revision it has seen, or get a key of its own trusted. See the limits below | | Steals an unlocked device | That device's synced chats, until you revoke it from another device; then nothing new | | Learns your recovery key | Everything synced, so keep it like a password manager's emergency kit | | Obtains a donation | Nothing without the offline research key | What is not hidden: how many records you have, roughly how big they are, when they change, how many times each chat has changed (its revision), device names, and which record is the account record. **Limits, stated plainly** (from the internal review; the outside review is asked about each): - **A malicious server colluding with a device you removed** can show your other devices different views (a fork). It can keep the rotation away from a device that hasn't seen it yet, so that device keeps writing under the old epoch the removed device can read, and it can serve a new device that restores with the recovery key an epoch the removed device made up. Devices that have seen the rotation refuse all of it. Stopping a forking server completely needs devices to cross-check what they have seen (fork consistency), which is future work. - **The id key doesn't rotate.** A removed device can still compute the object ids of chats it knows, so it can tell when those chats change (not what changes). - **Deleting is a server's power.** Nekko Cloud, or someone signed in to your account right after a fresh sign-in, can delete what's synced. Your devices keep their copies. ## Wire format The reference implementation is `crates/nekko-sync`; `apps/ios/Protocol/Sources/NekkoProtocol/SyncCrypto.swift` matches it byte for byte, and both are tested against `crates/nekko-sync/tests/golden/sync-crypto.json`. All binary values are base64url without padding. | Blob | Layout | Key and nonce | Associated data | | --- | --- | --- | --- | | Record | `01 ‖ salt[32] ‖ ct ‖ tag[16]` | HKDF-SHA256(record key, salt, `nekko-sync/v1 record`) → 44 bytes | `nekko-sync/v1 record⏎account⏎keyId⏎collection⏎objectId⏎revision` | | Device wrap | `01 ‖ ephemeral P-256 public[65] ‖ ct ‖ tag` | HKDF-SHA256(ikm = ECDH(ephemeral, recipient) x ‖ ECDH(sender, recipient) x, salt = ephemeral ‖ recipient public ‖ sender public, info = context) | context = `nekko-sync/v1 wrap⏎account⏎keyId⏎recipientId⏎senderId` | | Join wrap | as a device wrap | HKDF-SHA256(ikm = ECDH(ephemeral, recipient) x ‖ psk, salt = ephemeral ‖ recipient public, info = context); psk = HKDF-SHA256(code, empty salt, `nekko-sync/v1 join-psk⏎account⏎deviceId`) | context = `nekko-sync/v1 join-wrap⏎account⏎keyId⏎deviceId` | | Recovery wrap | as a device wrap, to the recovery public key, without a sender | HKDF-SHA256(ECDH x, ephemeral ‖ recovery public, context) | context = `nekko-sync/v1 recovery⏎account⏎keyId` | | Donation | as a recovery wrap, to the research key | as a recovery wrap | context = `nekko-donation/v1⏎donationId` | Record and donation plaintext is framed as a 4-byte big-endian length and the bytes, zero-padded to the Padmé size of the frame (at least 256 bytes). The keyring is `record key[32] ‖ id key[32]`. Object ids are `base64url(HMAC-SHA256(id key, "nekko-sync/v1 object⏎" ‖ collection ‖ "⏎" ‖ localId))`. The recovery key text is Crockford base32 of `key[32] ‖ SHA-256(key)[0..2]`, in groups of five. Its P-256 key pair is the first valid scalar among `HKDF-SHA256(recovery key, salt = empty, info = "nekko-sync/v1 recovery-key" ‖ counter)` for counter = 0, 1, …. The join code is 10 random bytes as 16 Crockford base32 characters in groups of four; the tag a joining device publishes is `HMAC-SHA256(HKDF-SHA256(code, empty salt, "nekko-sync/v1 join-tag"), account ‖ "⏎" ‖ public key)`. Typed codes and recovery keys are read as ASCII only. The account record of epoch *n* is the `account` collection with local id `epoch-n`: `{"v":2,"epoch":n,"recoveryPublic":…,"devices":[{"id","publicKey","label"}]}`. ### Choices and alternatives - **AES-256-GCM with a derived key per record** rather than XChaCha20-Poly1305: CryptoKit, Android and WebCrypto all have AES-GCM with hardware support, none of them have XChaCha. Deriving a fresh key per record from a 32-byte salt removes GCM's random-nonce limit. - **P-256** rather than X25519: the iPhone's Secure Enclave only does P-256, and Android before API 31 has no X25519. - **Hand-rolled ECIES** rather than HPKE (RFC 9180): CryptoKit has HPKE from iOS 17, but Node and the RustCrypto stack used here don't ship it yet. Device wraps follow HPKE's Auth mode in spirit (ephemeral and static-static ECDH, all public keys in the KDF, the context as associated data), join wraps its PSK mode, and recovery wraps and donations its base mode. Moving to HPKE is a version bump (`01` → `02`). - **Authenticated wraps** rather than signatures: the Secure Enclave key is a key-agreement key, and a static-static ECDH secret authenticates the sender to the one recipient without a second key per device. - **A typed join code** rather than comparing fingerprints on two screens: comparing only checked the new device's key, one way, and only if the user looked closely. The code checks both ways and can't be skipped. - **Whole-record last-writer conflicts with revisions** rather than a CRDT: the server enforces `revision = previous + 1`, a losing device pulls, merges in plaintext and pushes again. Transcripts are append-only, so merges are rare. - **No server-side search.** Search runs on your devices over decrypted chats. Encrypted search schemes leak access patterns and weren't worth that. ## Signing in Supabase Auth proves who you are; Nekko Cloud keeps the account. The app signs in to Supabase with an emailed 6-digit code, or with Apple, GitHub or Google in the browser (PKCE; the computer listens on a loopback port for the redirect, the iPhone uses `nekko-agent://cloud-callback`). It trades the Supabase access token once, at `POST /v1/auth/session`, for this device's own Nekko Cloud token, and drops the Supabase session. Nekko Cloud checks the token against the project's published keys (JWKS), its issuer, audience and expiry, and refuses anonymous users. An existing account (a beta signup's, say) is linked by email only when Supabase vouches for the email. A device token lasts 180 days from its last use; signing out revokes it, and signing in again binds the new token to the same device, so removing the device always ends it. The loopback page shows no text from the request, and a request without a code that PKCE accepts doesn't end the wait. Where the keys live: on a Mac, the Keychain; on Windows, Credential Manager; on Linux, a file only your user can read (like the app's other sign-ins); on the iPhone, a Secure Enclave key-agreement key plus the Keychain (this device only). ## What syncs Every chat, except incognito chats (never) and chats in the Local only mode (unless you include them in Settings → Sync). Pictures and attached files are left out of the synced copy (a short note stands in), so a record is mostly text; a chat whose text is over 1 MB isn't synced and Settings says how many. The newest change to a chat wins; a chat a reply is being written in is never overwritten from the cloud. Deleting a chat deletes it on your other devices. The iPhone reads synced chats (they stay readable offline) but doesn't change them. ## Locked out? No company that runs real end-to-end encryption keeps a key it can use on its own. They combine a key you keep, your other devices, a trusted contact, a PIN held in tamper-proof hardware that erases after a few wrong guesses, or (for businesses) an admin key. The research is in [reports/e2ee-lockout-recovery-2026-10-22.md](https://github.com/nekko-labs/nekko-agent/blob/main/reports/e2ee-lockout-recovery-2026-10-22.md). Nekko Agent does the first two today, with the recovery kit, the confirm step and the prompt to add a second device. The next options (a recovery contact, a Team admin key, PIN recovery in an enclave) are open decisions. ## Nekko Cloud API All sync routes take `Authorization: Bearer nk_…`. Writes answer `402 {"error":"plan-required"}` on the Free plan. | Sign-in route | What it does | | --- | --- | | `GET /v1/auth/config` | The Supabase project URL and its public anon key | | `POST /v1/auth/session` `{accessToken, label}` | A Supabase access token for this device's token and the account (30 per IP per minute) | | `GET /v1/auth/me` | The account and plan | | `DELETE /v1/auth/session` | Sign this device out | | Route | What it does | | --- | --- | "Holds the keys" below means the token is bound to a device that has a wrap of the current epoch. Nekko Cloud checks it to stop someone who can only sign in; the devices' own checks are what stop a malicious server. | Route | What it does | | --- | --- | | `GET /v1/sync/state` | Plan, whether it can write, this device, the current key epoch, devices (with any join tag), storage used | | `POST /v1/sync/devices` `{publicKey, label, joinTag?}` | Register this device's public key; the token becomes this device's. The same key again binds a new sign-in's token to it | | `DELETE /v1/sync/devices/:id` | Revoke a device: its tokens and wraps go; then rotate. Holds the keys, or removes itself | | `PUT /v1/sync/keyrings/:keyId` `{recoveryWrap, wraps[]}` | Start key epoch `keyId` (must be the next one). The first from a registered device wrapping to itself; later ones only from a device holding the keys | | `PUT /v1/sync/keyrings/:keyId/wraps/:deviceId` `{wrap}` | Add a wrap to the current epoch: for an approved device (holds the keys), or to itself after a restore. Never over an existing wrap | | `GET /v1/sync/keyrings/:keyId` | The recovery wrap, and this device's wrap | | `POST /v1/sync/push` `{objects[]}` | Up to 200 sealed revisions; per object `ok`, `conflict` or `stale-key`. Holds the keys | | `GET /v1/sync/pull?since=&limit=` | Changes after a sequence number, oldest first | | `GET /v1/sync/objects/:collection/:objectId` | One object, out of order (a new epoch's account record) | | `DELETE /v1/sync` | Delete everything synced. Holds the keys, or signed in within the last 10 minutes | | `POST /v1/donations` `{id, blob, deleteHash}` | Unauthenticated; not logged | | `DELETE /v1/donations/:id` with `x-delete-token` | Withdraw a donation | ## What's built - [x] Encryption format and reference implementation (`crates/nekko-sync`), with tests for tampering, relabelling, rollback, rotation, wrong devices and recovery-key typos; vectors cross-checked against Node's `crypto`. - [x] Nekko Cloud: Supabase sign-in, device tokens, the storage API, Pro and Team gating, quotas, revocation, delete everything, anonymous donations (`apps/nekko-agent-api`), and `npm run dev:local`, the whole service with a stand-in Supabase on one machine. - [x] The sync engine (`crates/nekko-cloud`) in nekkod, tested with three devices against the local service (`tests/live.rs`): sign-in, turn on, recovery-key restore, approval by join code, edits both ways, revoke, rotate and seal again, deletes, and no plaintext on the server. `tests/attacks.rs` runs the internal review's attacks through a proxy that rewrites the server's answers: a keyring from an untrusted key, a swapped key at approval, a lowered rollback guard and a replayed revision, writes under an old epoch, and a withheld account record. Each is refused. - [x] An internal adversarial review ([reports/crypto-review-internal-2026-10-22.md](https://github.com/nekko-labs/nekko-agent/blob/main/reports/crypto-review-internal-2026-10-22.md)) and its fixes ([decision 0008](https://github.com/nekko-labs/nekko-agent/blob/main/docs/decisions/0008-authenticated-wraps-and-join-codes.md)). - [x] Settings → Sync and the sign-in screen on the computer, with the animated sync picture, the recovery key and kit, devices, approve and remove. - [x] The iPhone: sign-in, join by recovery key or approval, approve a computer, read synced chats offline (`CloudSync` tests on an in-memory server; screens built on macOS CI). - [x] The offline research-key tooling and runbook ([research-key-ceremony.md](https://github.com/nekko-labs/nekko-agent/blob/main/docs/research-key-ceremony.md)). - [ ] Connect the Supabase project and deploy Nekko Cloud with it (settings in `apps/nekko-agent-api/README.md`). - [ ] Stripe plans into `users.plan`. - [ ] Generate the research key offline, pin it, and build donation redaction and sharing. - [ ] Android. - [ ] The outside cryptography review ([scope and RFP](https://github.com/nekko-labs/nekko-agent/blob/main/reports/crypto-review-scope-2026-10-22.md)) before sync is switched on for anyone. --- # Nekko Agent CLI and MCP The CLI makes the Nekko Agent host a subagent for Devin, Claude Code, Codex, Cursor, and other harnesses. It uses the same host engine as the desktop and Local Web UI installs. ## Install and target Published package: ```bash npm install --global nekko-agent # publishing with the next release npx nekko-agent status ``` From a checkout: ```bash npm install npm run build --workspace=apps/cli node apps/cli/dist/index.js status ``` Use `nekko-agent` after a global install or linking the built package, or invoke the bundled binary from `apps/cli/dist/index.js`. Without `--url`, the CLI talks to the running app if there is one, else starts a private `nekkod` (the engine daemon) against `~/.nekko` (override with `NEKKO_DATA_DIR`) and stops it when it exits. To drive a Local Web UI, use: ```bash nekko-agent --url http://127.0.0.1:1440 --token "$NEKKO_TOKEN" status --json ``` `--url` can also be supplied as `NEKKO_URL`; `--token` can be supplied as `NEKKO_TOKEN`. ## Safety and approval Chat defaults to `--approve guardrails` (or `NEKKO_APPROVE=guardrails`). Guardrails mode sets the session to `guardrails`: ordinary tools run, ask-rule approvals are refused and reported as structured `blocked` entries, and the turn continues. The result tells the harness to use `--approve yolo` when an override is intentional. `--approve yolo` explicitly opts into the previous unattended behavior. Deny rules remain a hard floor in every mode. `--approve ask` prompts on the TTY; it is an error when stdin is not a TTY. These policies govern the agent tool loop, not every process the host can launch. In particular, `bash` is not confined by `workspace-jail`; use the host's deployment and workspace controls when granting an external harness access. ## Commands ```text status [--json] sessions [--json] chat "" [--session ID] [--new] [--workspace ID] [--provider ID] [--model ID] [--approve guardrails|yolo|ask] [--json|--stream ndjson] [--timeout SECONDS] [--quiet] workspace list|add PATH|remove ID|index ID|search ID QUERY [--json] prompts [--json] tasks list|add|run ID|delete ID [--json] skills [--json] skills install ID [--target nekko-agent|claude|codex] [--json] tools [--json] models [PROVIDER_ID] [--json] local status|list [--json] local search [QUERY] [--json] local download OWNER/REPO [--quant LABEL] [--json] local downloads [--active] | local cancel JOB_ID [--json] local load MODEL_ID [--provider ID] [--ctx N] [--gpu-layers N] [--ttl SECONDS] [--json] local unload MODEL_ID [--provider ID] | local start [--provider ID] | local stop [--provider ID] [--force] train start NAME GOAL [--provider ID] [--model ID] [--workspace ID] [--json] train status|hint ID TEXT|stop ID [--json] watch [--session ID] [--json] mcp ``` Prompts may be positional, piped through stdin (`nekko-agent chat -`), or loaded from a file (`nekko-agent chat --file prompt.md`). `--quiet` suppresses human progress on stderr. ## Machine output `--json` emits exactly one JSON object on stdout. Chat objects have this shape: ```json { "sessionId": "…", "provider": "…", "model": "…", "text": "…", "toolCalls": [{ "name": "read_file", "input": { "path": "README.md" } }], "blocked": [{ "ruleLabels": ["…"], "command": "…", "severity": "high", "reason": "…" }], "durationMs": 1234, "usage": { "inputTokens": 100, "outputTokens": 40 } } ``` `--stream ndjson` emits one typed event per line: ```json {"type":"text","delta":"Hello"} {"type":"tool_call","call":{"name":"read_file","input":{"path":"README.md"}}} {"type":"tool_result","toolCallId":"…","ok":true,"output":"…"} {"type":"blocked","ruleLabels":["ask"],"command":"…","severity":"high","reason":"…"} {"type":"done"} ``` Progress and diagnostics go to stderr. Other commands accept `--json`; `watch --json` emits the same typed event objects as NDJSON and exits cleanly when it receives Ctrl-C. Watch events are `text`, `tool_call`, `tool_result`, `blocked`, `done`, and `error`; reasoning and usage-only events are omitted. ## Exit codes | Code | Meaning | | ---: | --- | | 0 | Success | | 2 | Usage or invalid flags | | 3 | Nothing configured (provider/model missing) | | 4 | Guardrail-blocked tool call | | 5 | Provider/model failure | | 6 | Chat timeout | | 7 | Cannot reach or unauthorized against `--url` | ## MCP Start the JSON-RPC stdio server: ```bash node apps/cli/dist/index.js mcp ``` Claude Code: ```bash claude mcp add nekko-agent -- node /abs/path/nekko-agent/apps/cli/dist/index.js mcp ``` Codex: ```bash codex mcp add nekko-agent -- node /abs/path/nekko-agent/apps/cli/dist/index.js mcp ``` Cursor and generic `mcpServers` configuration: ```json { "mcpServers": { "nekko-agent": { "command": "node", "args": ["/abs/path/nekko-agent/apps/cli/dist/index.js", "mcp"] } } } ``` `nekko-agent_chat` accepts the same `approve` policy as the CLI and defaults to `guardrails`. `nekko-agent_train_start` also accepts an explicit `approve` argument; unattended training that intentionally permits ask-rules must pass `"approve": "yolo"`. The server negotiates MCP protocol versions, echoing a supported client version and otherwise selecting its newest supported version. `nekko-agent_task_create` exposes `title`, `prompt`, `kind`, `runAt`, `intervalMs`, `workspaceId`, `providerId`, `modelId`, `condition`, and `keepAlive` directly; `title`, `prompt`, and `kind` are required. A `nekko-agent_chat` result presents the assistant reply first, followed by a metadata block containing session, tool-call, blocked-entry, duration, and usage details. The MCP tool list mirrors the CLI coverage: `nekko-agent_chat`, `nekko-agent_list_sessions`, `nekko-agent_new_session`, `nekko-agent_get_session`, `nekko-agent_workspace_list`, `nekko-agent_workspace_add`, `nekko-agent_workspace_remove`, `nekko-agent_workspace_index`, `nekko-agent_workspace_search`, `nekko-agent_prompts_list`, `nekko-agent_tasks_list`, `nekko-agent_task_create`, `nekko-agent_task_run`, `nekko-agent_task_delete`, `nekko-agent_skills_list`, `nekko-agent_skill_install`, `nekko-agent_tools_list`, `nekko-agent_models_list`, `nekko-agent_train_start`, `nekko-agent_train_status`, `nekko-agent_train_hint`, `nekko-agent_train_stop`, `nekko-agent_status`, and the local-model tools below. ### Local models An MCP client can run a model on this machine without opening the app: check what is installed and loaded, download a model, load it, send it work through `nekko-agent_chat`, and unload it again. The CLI's `local` command does the same from a terminal. | Tool | CLI | What it does | | --- | --- | --- | | `nekko-agent_local_status` | `local status` | The built-in engine (installed, running, loaded models, memory budget) and every enabled local runtime (Ollama, LM Studio, vLLM). | | `nekko-agent_local_models` | `local list` | Models in the engine library: id, size, quantization, loaded, and whether it fits on the GPU (`full`, `partial`, `cpu`, `wont`). | | `nekko-agent_model_search` | `local search` | Hugging Face GGUF repos the engine can download (the curated list with no query). | | `nekko-agent_model_download` | `local download` | Start downloading one quantization; `Q4_K_M` (or the closest) when none is named. Returns a job id at once. | | `nekko-agent_downloads` | `local downloads` | Progress of each download: state, percent, size, ETA. | | `nekko-agent_download_cancel` | `local cancel` | Cancel a download. | | `nekko-agent_model_load` | `local load` | Load a model; the engine starts itself if it is not running. Returns the `provider` and `model` to pass to `nekko-agent_chat`. | | `nekko-agent_model_unload` | `local unload` | Unload a model to free memory. | | `nekko-agent_runtime_start` | `local start` | Start the engine, or a runtime named by `providerId`. | | `nekko-agent_runtime_stop` | `local stop` | Stop it. A server Nekko Agent did not start is refused with `needsConfirmation`; ask before retrying with `force`. | Every tool targets the built-in engine unless it is given a `providerId` (CLI `--provider`) for another local runtime; cloud providers are refused. Download, load and stop are the same operations as the Model Server page and follow its rules: a download is several GB more often than not, so compare its size with the memory budget in `local_status` first. Who keeps the model running depends on whether the app is open. With the app serving, the engine is the app's and a loaded model stays loaded after the CLI or MCP client exits. Without the app, `nekko-agent mcp` runs the engine itself for the length of its session and stops it when the client disconnects, so no model server is left behind. The one-shot `local load` and `local start` commands need the app for that reason, and an in-process `local download` waits until the files have landed. A typical hand-off from Claude Code: "run qwen3 4B locally and have it review this file" becomes `nekko-agent_local_models` (or `model_search` and `model_download`, polling `nekko-agent_downloads`), then `nekko-agent_model_load`, then `nekko-agent_chat` with the returned `provider` and `model`. A chat turn carries the agent's system prompt and tool list, several thousand tokens, so a model loaded with a very small `contextTokens` cannot answer one; leave it unset for the engine default. ## Recipes ### Drive a repeated workflow ```bash nekko-agent tasks add --title "daily review" --kind recurring \ --interval-ms 86400000 --prompt "Review the current workspace diff and summarize risks" \ --workspace "$WORKSPACE_ID" --provider "$PROVIDER" --model "$MODEL" --json nekko-agent tasks list --json nekko-agent tasks run TASK_ID --json ``` ### Fan out several sessions ```bash nekko-agent chat "Review security" --new --workspace "$W" --json > security.json & nekko-agent chat "Review tests" --new --workspace "$W" --json > tests.json & nekko-agent chat "Review API design" --new --workspace "$W" --json > api.json & wait ``` ### Hand off training and poll it ```bash nekko-agent train start "ranking-v1" "Improve ranking accuracy" \ --workspace "$W" --provider "$PROVIDER" --model "$MODEL" --json nekko-agent train status --json nekko-agent train hint RUN_ID "Try a larger validation split" --json nekko-agent train stop RUN_ID --json ``` ### Run a local model from a script ```bash nekko-agent local status --json nekko-agent local download unsloth/Qwen3-4B-GGUF --quant Q4_K_M --json nekko-agent local downloads --active --json # poll until done nekko-agent local list --json # find the model id nekko-agent local load "$MODEL_ID" --json nekko-agent chat "Summarize README.md" --provider nekko-engine --model "$MODEL_ID" --new --json nekko-agent local unload "$MODEL_ID" --json ``` --- # Automations An automation is a workflow that runs on its own: on a schedule, when something happens in a connected app, or when you start it. They live in the **Workflows** tab. This page covers the ready-made ones, how you hear about their runs, and how to stop them all at once. For building your own step by step, see the Workflows tab's **How workflows work** strip; for running a repo's CI on your machine, see [Local CI runner](https://nekkoagent.com/docs/local-ci-runner). ## Turn on a ready-made automation **Workflows → New workflow** opens on a gallery. An empty Workflows tab shows the same cards. | Automation | When it runs | What it needs | |---|---|---| | Summarize my GitHub notifications every morning | Weekdays at a time you pick | GitHub connected, the `gh` CLI signed in, a default model | | Review every PR I open | Each pull request you open in a repo you pick (checked every 2 minutes) | GitHub connected, the `gh` CLI signed in, a default model | | Diagnose failed CI | Each failed GitHub Actions run in a repo you pick (checked every 2 minutes) | GitHub connected, a local model | | Review new PRs with a skill | Each new pull request in a repo you pick (checked every 2 minutes); new commits too if you turn on its second trigger | GitHub connected, a project folder that is a clone of the repo, a model (local if you leave **Local models only** on) | | Nightly dependency check | Every night at a time you pick | A project folder, a default model | Each card marks what you already have. Click one to open its setup sheet: 1. **What it needs.** Anything missing has a button: **Connect** opens Connectors, **Choose a model** opens Models. A template that runs in a project asks which folder. One that runs on a local model only lists your local models to pick from (your default model, if it is local, is already picked); with none set up, **Set up a local model** opens Models. 2. **Its questions.** A time of day, or a repository (`owner/name`) and your GitHub username. Only PRs you open start a review: the trigger checks the event's author and its `opened` action exactly, so other people's PRs, reopened PRs and closed ones never reach the agent. 3. **Notify me.** Digests default to *After every run*; change it here or later in the editor. 4. **Turn on.** The automation is created, enabled and armed. The toast says when it runs first. **Customize** opens the same automation in the workflow editor instead, where you can change its steps, triggers and model before saving. Every ready-made automation but two only reads: it summarizes or reviews, and never comments, posts, installs or changes anything. Results land in the run history (and a notification, if you asked for one). The exceptions are **Diagnose failed CI**, which keeps one comment on the failing PR (see [Diagnose failed CI](https://nekkoagent.com/docs/local-ci-runner#diagnose-failed-ci)), and **Review new PRs with a skill**, below. ## Review new PRs with a skill Runs a skill on every new pull request in a repo and keeps its verdict in one comment on the PR. - **The skill.** The setup sheet picks the nyaa council (`nyaa:cr`, or `vaizer:cr` from the vaizer plugin) when its Claude Code plugin is enabled, else the built-in `review`; any skill a step can run is offered. It gets the PR number as its arguments and runs with only the tools its `allowed-tools` lists. - **Where it runs.** On a checkout of the PR's head made for the run and removed after it; your own checkout is never touched. A draft PR waits until it is marked ready. - **Untrusted PRs.** A PR from a fork, or from someone who cannot push to the repo, never runs its code: package managers, builds, linters and scripts are off, and the comment says *lint/audit skipped: untrusted PR*. - **The comment.** Posted by an action step, not by the model: one comment per PR, marked with the workflow's name, with @mentions quieted and the length capped. Turn on the template's second trigger (*PR updated*) to re-review each new commit; the same comment is updated. - **Model.** **Local models only** starts on when you have a local model set up; turn it off to use your default model. How skills, plugins and the checkout work: [Workflows](https://nekkoagent.com/docs/workflows#skill-steps). Digests read text other people wrote, such as notification titles or package advisories. So a fixed command fetches it first, and the agent that summarizes it has **no tools**: it can only write the summary, so nothing in that text can make it run a command. You can set **No tools** on any prompt or skill step in the editor. Keep such a step last, because later steps share its chat and see the same text. ## How a run went Expand a workflow to see its runs. The selected run shows how long it took and when it started, then either: - **Failed at "step"**: the step that failed, its error, and a **Transcript** button for that step's chat, or - **Result**: the last agent step's answer, which for a digest is the digest itself. The history on the right lists the last ten runs with their status, duration, what started them, and the step a failed run stopped at. ## Notifications Each workflow's **Notify me** setting decides when a finished run tells you: - **When a run fails** (the default, also for workflows made before this setting existed), - **After every run**, for digests, or - **Never**. Clicking a notification opens the Workflows tab on that run. Notifications follow **Settings → Notifications → Desktop notifications**, and stay quiet while the Workflows tab is on screen. A run cut short because the app quit is reported as a failure the next time it starts. Phone notifications for failed runs are not available yet. ## Pause all automations The **Pause all automations** switch in the Workflows header is the kill switch: - schedules, pull request and push events, Slack messages, connector polls and webhooks start no runs, and scheduled tasks (from a chat's **⚡ Automate** menu) wait; - whatever is running when you switch it on is stopped; - the **Run** button, a task's **Run now** and `nekko-agent workflow run` still work. A schedule that came due while paused runs once when you resume. Events that arrived during the pause are skipped, not replayed. The setting is shared, so pausing from one window or the web edition pauses everywhere. ## While you are using the computer A scheduled run on a **local model** waits until you are away. It competes with you for the GPU, memory and fans otherwise. You count as away when: - there has been no keyboard, mouse or touch input for 5 minutes; - no chat you started is replying, from the app or your phone; - the computer is on mains power and not running hot. A run that has waited 2 hours starts anyway, so nothing is skipped. While it waits, the workflow row and the task in the Automations window say **waiting for you to be away**. The run that starts says **low priority · waited 2 hr** in its history. Choose this yourself: - **Settings → Automations → When automations run** sets it for every automation: **By where the model runs** (the default above), **Only when I'm away**, **Low priority** (runs on time, even while you work, but not on a low battery or a hot computer) or **On time**. - **Away after** sets how many minutes without input count as away (5 by default). - **When it runs**, in a workflow's editor (under **Runs**) and in a chat's **⚡ Automate** dialog, overrides the setting for that one automation. What does not wait: - runs on a **cloud model**, which do not load your computer; - pull request, push, Slack, connector and webhook events, which run at once so none is lost; - the **Run** button, a task's **Run now** and `nekko-agent workflow run`; - goal runs, which you start yourself. On a machine nobody sits at (Docker, a server), you always count as away. On Linux, Nekko reads the desktop's idle hint, which GNOME sets after its own 5-minute delay. --- # Workflows: models and unattended runs A workflow runs with nobody watching it: on a schedule, from a git event, a connector poll or a webhook. These settings, under **Runs** in the workflow editor, decide which model it uses and what its agent steps may do. ## Model - **Model** sets the model for every prompt and skill step. Left on **Default model**, steps use the model chosen in Models. - Each prompt or skill step can override it. **Use workflow model** is the default. The step graph names a step's model when the step overrides it. - Auto is not offered: a run has to know its model before it starts. ## Local models only With **Local models only** on: - A step runs only on a model server on this computer: the Nekko engine, Ollama, LM Studio, vLLM or llama.cpp (or an OpenAI-compatible server) on a loopback address (`localhost`, `127.x.x.x`, `[::1]`). - Any other model is refused with *This workflow uses local models only; <provider> is not local.* and the step fails. The run never falls back to another model, including your default. - The run's chat is offline, so there are no internet tools (`fetch_url`, browser), no MCP servers and no connector context. - Action steps still run: they are the host calling GitHub, Slack and so on with your connector, not tools the model calls. The editor only lists local providers in the picker, and warns when a step or your default model is not local. ## Tool access | Setting | Prompt and skill steps can | | --- | --- | | None | Use no tools. Answers come from the prompt and context. | | Read files | Read, search and list files in the workspace. | | Allowlist | Use only the tools each step lists. | | Full workspace | Use every tool, under your default chat mode. The default, and what existing workflows use. | Shell steps and action steps you write in the editor are not agent tools; they run under every setting. ### Allowlists Under **Allowlist**, each prompt or skill step lists its tools in Claude Code's allowed-tools syntax: ``` Read, Grep, Glob, Bash(gh pr view:*), Bash(git diff:*) ``` - `Read`, `Write`, `Edit`, `Glob`, `Grep`, `LS`, `WebFetch` name the matching tools; `Bash` allows any command and `Bash(prefix:*)` commands that start with those words; `Bash(npm test)` allows exactly that command. - A listed tool runs without asking. Anything else is denied. - Commands are matched by their words, not as text: chaining (`&&`, `;`, `|`), substitution (`` ` ``, `$()`, any `$`), redirection and subshells never match, even when the command starts with an allowed prefix. - `gh` is limited to `pr view`, `pr diff`, `pr checks`, `repo view`, and `api` reads (no `-X`/`--method` other than GET, no `-f`, `-F`, `--field`, `--raw-field` or `--input`). `git` is limited to `diff`, `log`, `show`, `status`, `rev-parse` and `fetch`. Posting goes through action steps. ## Approvals are denied automatically Nobody is there to click Approve, so a run never waits for one. A tool call that would need approval under the chat mode is refused at once and the step sees: ``` Denied: unattended automation run (write_file: Write /path/to/file) ``` The refusal is in the step's transcript and the agent carries on. Runs never offer `ask_user` either. This applies to every workflow, including ones made before these settings existed; nothing else about them changes. ## Skill steps A skill step runs a skill as one step of the run. The editor's picker groups every skill this computer has, in the order a name is resolved (the first of a name wins): | Group | Where it comes from | | --- | --- | | Built-in | Nekko Agent's own skills (`review`, `plan`, `test`…). | | Installed | Skills installed into Nekko Agent from the Skills tab. | | Claude Code skills | `SKILL.md` folders under `~/.claude/skills`, `~/.codex/skills`, `~/.gemini/skills`, `~/.agents/skills`, and the same folders in your projects. | | Plugins | Skills and commands of the Claude Code plugins you have enabled, named `:` (for example `nyaa:cr`). | ### Claude Code plugins Nekko Agent reads the plugins Claude Code has installed; it never installs, updates or changes one. - A plugin counts when `~/.claude/settings.json` turns it on under `enabledPlugins` (`"nyaa@nekko-labs": true`). - Its files are read from `~/.claude/plugins/cache////`. With several versions installed, the one whose `.claude-plugin/plugin.json` has the highest `version` is used. - `skills//SKILL.md` becomes `:`, and so does `commands/.md`. A skill wins over a command of the same name. - A file larger than 256 KB is skipped, as is a name with characters other than letters, digits, `.`, `_` and `-`. A link that points out of the plugin's folder is not followed. ### Arguments **Arguments** fill the skill's `$ARGUMENTS`, and `$1` to `$9` word by word (quotes keep words together), as Claude Code fills a command's. They can use templates, so `{{trigger.number}}` hands a PR-triggered skill its PR number. A skill with no `$ARGUMENTS` gets them on an `ARGUMENTS:` line after its text. A plugin skill's `${CLAUDE_PLUGIN_ROOT}` becomes its plugin folder, and its frontmatter is not sent to the model. ### Tools and the skill's folder - Under **Allowlist** access, a skill step with no allowed tools of its own uses the skill's `allowed-tools`. The editor shows them; list fewer to narrow them. The usual limits still apply: `gh` and `git` stay reads. - The run can read the skill's own folder (a plugin's whole folder), so the references and scripts it ships are there, even when the workspace jail is on. Nothing in that folder can be written or edited. ## Pull request checkouts With **Check out the pull request** on (`prCheckout`), a run started by a GitHub `pr_opened` or `pr_updated` event works on the PR's own code: - The workflow's project folder must be a clone of the repository, with `origin` on GitHub. The run fetches `pull//head` from `origin` into a ref of its own and checks it out as a detached Git worktree under the app's data folder (`workflow-checkouts/`). - Your checkout is never touched: no branch switch, no stash, no changed file. - The worktree and its ref are removed when the run ends, whether it succeeded, failed or was cancelled. Ones left by a crash are removed the next time the app starts. - A PR whose head cannot be fetched fails the run before any step. ### Untrusted pull requests A PR is trusted when its head branch is in the same repository and GitHub marks its author `OWNER`, `MEMBER` or `COLLABORATOR`. Anything else, a fork or an outside contributor, is untrusted, and its run never executes the PR's code: - Prompt and skill steps keep only `Read`, `Grep`, `Glob`, `LS` and `gh`/`git` commands (which are reads). Package managers, build tools, linters, test runners, scripts, writes, the web and MCP are dropped, from the step's own list or the skill's `allowed-tools`. - Under **Full workspace** access the run is held to a read-only allowlist of the same kind. - The step is told those checks are unavailable, so a review reports *lint/audit skipped: untrusted PR* instead of failing. Shell steps you write are not agent tools: they run as written for every PR, so keep them out of a workflow that reviews other people's PRs. --- # Local CI runner Use a machine running Nekko Agent as a CI runner for a GitHub or GitLab repo: when a commit is pushed or a pull request opens, a workflow checks the code out, runs your build and tests **on that machine**, and reports the result back to the provider as a commit status - with a comment on the PR when it fails. It saves hosted-runner minutes by burning your own hardware instead. ## What it is - and what it is not A "Local CI runner" is a **workflow template**, not a new subsystem. Create it from **Workflows → New workflow → Local CI runner** (under *Build your own*). Behind the scenes it is ordinary workflow pieces you already have: - a **trigger** that fires on repo events (see [Getting events in](#getting-events-in)), - **shell steps** that check out the commit and run your build/test commands, - **action steps** (`github.setCommitStatus`, `gitlab.setCommitStatus`, `github.commentPR`, `gitlab.commentMR`) that report the outcome back through the saved connector credentials. **This is not a GitHub Actions self-hosted runner.** Nekko Agent does not speak the `actions/runner` broker protocol, will not pick up `runs-on: self-hosted` jobs, and does not use the Checks API (no annotations, no check-suite lifecycle). It produces a plain **commit status** (`nekko-agent/` context) and PR comments - enough for "did this commit pass on my machine", not a replacement for the Actions job queue. If you need real self-hosted runners, use GitHub's or GitLab's official runner. ## The template The workflow looks like this: ``` checkout (shell) ──fail──▶ report failure (action) ──▶ comment on PR (action) ──▶ fail │ success ▼ build + test (shell) ──success──▶ report success (action) ──▶ end ``` - **Check out the commit** - `git fetch origin {{trigger.branch}} && git checkout {{trigger.sha}}` - **Build and test** - `npm ci && npm run build && npm test` - **Report success / failure** - `github.setCommitStatus` with context `nekko-agent/` (the default when the `context` param is empty) - **Comment on the PR** - `github.commentPR` with the tail of the test step's output. On plain `push` events there is no PR number, so this step fails and the run still ends red. **The two shell steps are placeholders.** Edit them for your project - your package manager, your test command, your merge strategy. The action steps are ready as-is: they take repo, sha, and PR number from the event when the `{{trigger.*}}` params render empty. For GitLab, swap the action steps for `gitlab.setCommitStatus` (state `failed`/`success`, status name `nekko-agent/`) and `gitlab.commentMR`. ## Getting events in A run only happens when an event reaches the workflow. Pick **one** delivery mode per workflow , arming two means double runs. ### Poll mode - works anywhere, including the desktop app The desktop app can't receive real webhooks (it's behind NAT), so the honest default is polling: Nekko Agent asks the provider's API what's new, on an interval, with a per-trigger cursor stored in `workflow-cursors.json` next to `workflows.json`. The template ships a disarmed **Connector** trigger as the starter: 1. Connect GitHub (or GitLab) under **Connectors** - the poller uses that token. 2. On the workflow's Connector trigger: set **Repo** to `owner/name`, set the event name, arm it. - GitHub event names are the API's `type` strings: `PushEvent`, `PullRequestEvent`, `PullRequestReviewEvent`, … One trigger matches one name, so add a second trigger for PRs. - Three GitHub event names read their own endpoints instead of the shallow events feed (the editor suggests them; each needs **Repo**). See [Reacting to GitHub CI and PRs](#reacting-to-github-ci-and-prs). - GitLab polls the project events feed; filter with the **Filter** substring field. 3. **Poll every N minutes** defaults to 1. Each new matching event starts a run; a late or duplicated page can't refire (events are deduped by id, and the cursor survives restarts). Polling is eventually-consistent by design: expect up to a poll interval of latency, and note that repo events APIs are shallow (recent events only). ### Reacting to GitHub CI and PRs These GitHub connector trigger events are polled from their own endpoints: | Event | Fires when | Payload (`{{trigger.*}}`) | | --- | --- | --- | | `workflow_run_failed` | An Actions run in the repo completes with `failure` or `timed_out`. Each attempt fires once, so a re-run that fails again fires again. | `run_id`, `run_attempt`, `workflow`, `branch`, `sha`, `html_url`, `conclusion`, `trigger_event`, and `number` when the run belongs to a PR (fork PRs included, looked up from the commit). | | `pr_opened` | An open PR is seen for the first time. | `number`, `title`, `branch`, `base`, `sha`, `author`, `author_association`, `head_repo`, `is_fork`, `draft`, `html_url` | | `pr_updated` | An open PR's head commit changes (a new push). | Same as `pr_opened`. | Arming a PR trigger records the PRs already open without firing, so it does not work through the backlog. Draft PRs are skipped unless **Include draft PRs** is ticked; a draft marked ready then fires `pr_opened`. The token needs **Actions: read** and **Pull requests: read** (fine-grained) or the `repo` scope (classic); a 403 or 404 shows up as the trigger's error, naming the permission. Two action steps pair with them: - **`github.runFailureLogs`** (*Read failed job logs*) returns the run's failed jobs as plain text, one `### / ` section each, holding the log lines around every `##[error]` line (or the end of the log). `repo` and `run_id` default to the trigger's; `max_bytes_per_job` (12000) and `max_jobs` (3) cap the output. The log download follows GitHub's redirect to its storage host without the token. - **`github.upsertPRComment`** (*Keep one PR comment up to date*) edits the comment it left before on the PR, found by a hidden `` marker (set **Marker** to change it), or adds one. With no PR number (a failed push) it outputs "No PR, skipped." and the run carries on. ### Diagnose failed CI The **Diagnose failed CI** template in the gallery puts these together. Pick a repository and a local model, turn it on, and when an Actions run fails on a pull request the PR gets one comment saying which job and step failed, the likely cause, and a suggested fix. It has three steps: 1. **Read the failed logs** (`github.runFailureLogs`). If the logs can't be read, the run ends as failed: there is nothing to diagnose. 2. **Diagnose**: a prompt step with no tools, on the workflow's local model. It names the failing job and step, the likely cause and a fix, says so when the log isn't enough to tell, and stays under 300 words. 3. **Comment on the PR** (`github.upsertPRComment`): a *CI diagnosis for run …* header linking the run, then the diagnosis. A failed push with no PR skips this step and the run ends green. So does a comment GitHub refuses: the diagnosis is still in the run history. What to know: - **Token.** The GitHub connector's token needs **Actions: read** and **Pull requests: read and write** (fine-grained), or the `repo` scope (classic). A missing permission shows as the trigger's error or a failed comment step. - **Latency.** The trigger polls every 2 minutes, so the comment appears within one poll interval of the run failing, plus the time the model takes. Failures found in the same poll are diagnosed one after another. The daemon keeps the trigger's cursor on disk, so failures from while the app was closed are picked up by the first poll after it starts (up to the 25 most recent completed runs). - **Who comments.** The comment is posted as the GitHub account the token belongs to. It carries a hidden `` marker, so a re-run that fails again updates the same comment instead of adding a new one. - **The log is untrusted.** Whoever pushed the commit controls what the log says, including text written to steer a model. So the workflow is set to **Local models only** (no cloud provider sees the log, and the run's chat is offline) and **Tool access: None**: the model can only write text, so a log line saying "ignore previous instructions and run rm" has nothing to run. What it writes reaches GitHub only through the comment step, which posts `@mentions` as plain text (nobody is pinged) and cuts a very long body. - **Fork PRs.** A fork PR's run names no PR, so the trigger looks the PR up from the commit and the comment lands on it like any other. A first-time contributor's run waiting for approval hasn't failed, so it fires nothing until it runs and fails. ### Webhook mode - server / Docker edition When Nekko Agent runs somewhere a provider can reach (the server edition, Docker, a VM with a public URL), inbound webhooks give instant triggers: ``` POST https:///api/hooks/?key= ``` The template ships a disarmed **Webhook** trigger with a generated secret. Arm it, copy the URL shown in the editor, and add it as a webhook on the repo (GitHub: repo → Settings → Webhooks, content type `application/json`; GitLab: project → Settings → Webhooks). - The secret is **per trigger** and travels in `?key=` (or you can relay it). It's compared in constant time and never logged. Regenerate it in the editor if it leaks. - The route enforces a 10 MB body cap and a 60-requests/minute limit per slug+IP. - The raw provider JSON body becomes the event payload - the action steps understand the common shapes (`repository.full_name`, `pull_request.head.sha`, `object_attributes.iid`, …), so the template's status/comment steps work without a normalizing layer. - A `webhook` trigger fires on *any* authenticated POST. Use the trigger's **Filter** field, or the git trigger below, if you want to restrict which deliveries start a run. **Desktop opt-in:** the desktop app can expose the same route on loopback , Settings → Experimental → workflow loopback listener binds `127.0.0.1:1441` only. Useful for local relays and tunnels, off by default. ### Dispatching git events directly - `workflow:event` The `git` trigger (the one the template ships armed) matches **normalized** events - provider, event name, repo, branch - dispatched through the generic event door. On the server edition: ``` POST https:///api/workflow:event Authorization: Bearer { "args": [{ "kind": "git", "provider": "github", "event": "pr_opened", "repo": "owner/name", "branch": "feature-x", "payload": { "sha": "abc123…", "number": 12 } }] } ``` This is the door a relay script uses when it sits between a provider webhook and a machine that can't be reached directly: accept the webhook, map it onto the normalized shape, POST it on. Inside the desktop app the same call goes over the internal API (`workflow:event`). ## The payload contract Steps read the event through `{{trigger.*}}` templates (`repo`, `branch`, plus every payload key) and the action steps fall back to the event when a param renders empty. These keys are understood - normalized names first, then the raw provider body: | Need | Normalized | Raw GitHub body | Raw GitLab body | |---|---|---|---| | Repo / project | `repo` | `repository.full_name` | `project.path_with_namespace`, `project_id` | | Commit SHA | `sha` | `after`, `head_commit.id`, `pull_request.head.sha`, `head` | `checkout_sha`, `object_attributes.last_commit.id` | | PR / MR number | `number`, `pull_number` | `number`, `pull_request.number`, `issue.number` | `iid`, `object_attributes.iid` | | Branch | `branch` | `ref` (`refs/heads/…`) | `ref` | Reported statuses use context `nekko-agent/` on GitHub (states `success`/`failure`/`error`/`pending`) and the same name on GitLab (`success`/`failed`/ `running`/`pending`/`canceled`) - each op accepts the other's spelling for `state`. ## Security model, honestly stated | Threat | Defense | |---|---| | A stranger's PR runs code on your machine | **None by default - this is the big one.** Shell steps run unattended, as you, in the workflow's workspace. Only point this at repos you control, or gate it (see below). | | Webhook spoofing | Per-trigger secret in `?key=`, constant-time compare, 60 req/min rate limit, 10 MB body cap | | Exposed endpoint | Server edition binds `127.0.0.1` by default; the desktop listener is loopback-only and off unless enabled | | Credential theft via the workflow | Connector tokens live in settings, never on the step; a workflow file carries no secrets except its webhook keys | | Event injects a shell command | The command line comes from your workflow, not the event - but `{{trigger.*}}` values are interpolated *before* exec, so only interpolate fields you trust (git SHAs and ref names can't contain shell metacharacters; a free-text payload field can) | **Untrusted pull requests need review.** Anyone who can open a PR against your repo could get their code executed on this machine. Guardrails that actually help: - Restrict the trigger's **Repo** and **Branches** (e.g. `main`, `release/*`) so random forks and branches don't fire it. - Prefer the git trigger with an explicit event list over a wide-open webhook trigger. - For public repos, keep the runner on a dedicated workspace you don't mind being compromised, or add a `prompt` step that reviews the diff before the shell steps run. - One run per workflow at a time: events that arrive while a run is in flight are skipped, not queued - so a push flood can't stack runs, but it can also drop builds. ## Limits - Local execution only: runs happen on the machine hosting Nekko Agent, with its CPU, its toolchain, and its uptime. A closed laptop is a runner that's down. - Shell steps time out after 600s by default (`timeoutSec` per step); step output kept in the run log is trimmed to ~4 KB. - No parallelism beyond one run per workflow, no job matrix, no artifacts - it's a workflow engine, not a CI fleet. --- # Decision models A decision model answers typed questions about a piece of text with calibrated probabilities, in one forward pass, without generating any text. Nekko Agent runs one natively in its engine daemon: **Laya**, an open-weight model from Convai Innovations (Apache 2.0). ## What Laya and Jev are TypeSafe Jev is a closed, hosted "System One" decision API: you send a `state` (text or JSON) and named questions, and get back typed answers. [Laya](https://huggingface.co/convaiinnovations/laya) is an open-weight model trained for the same job, and its own server (`laya-serve`) speaks Jev's request and response shape. Nekko Agent speaks it too, so a Jev client works against the local engine by changing its base URL. Three question types: | type | `criteria` | answer | | --- | --- | --- | | `choice` | an object of label -> description, or a list of labels | `{ "type": "choice", "choice": "billing", "confidence": 0.86, "probabilities": { "billing": 0.97, ... } }` | | `score` | the ordered list of levels, index 0 first | `{ "type": "score", "score": 1.44, "confidence": 0.14, "legend": { "0": "not urgent", ... }, "probabilities": { "0": 0.12, ... } }` | | `noul` | optional `{ "true": ..., "false": ... }` descriptions | `{ "type": "noul", "noul": 0.82 }` (the probability it is true) | Every answer also carries Laya's two extras, `answer_confidence` (the top probability, which is what the temperatures are fitted on) and `action.act_probability` (the act/escalate head, which Laya's own README says carries no usable signal yet). The response has `model`, `answers`, `usage` (`input_tokens`, `output_tokens: 0`, and how much of the state was cut to fit), and `latency_ms`. ## The local runtime `crates/nekko-decide`, inside `nekkod`. No Python at run time: the checkpoint is an ONNX export run by ONNX Runtime (the `ort` crate with prebuilt binaries), and the tokenizer is the checkpoint's `tokenizer.json` (the `tokenizers` crate). - **Same input as the reference, token for token.** Prompt construction, option markers, the 48-token per-option cap and the head budget, state truncation (from the back for a conversation list, so the newest turn survives), the Python `json.dumps` rendering of JSON states and structured criteria, and temperature clamping all follow the `laya` package (0.3.22). The golden test checks every question's token sequence against the package's own. - **One call, all questions.** A bad question becomes that question's `error` entry; the others are still answered. Request caps are `laya-serve`'s: 64 questions, 50,000 state characters, 100 options per choice, 32 score levels, 512 options in all. - **Execution providers.** DirectML on Windows, CoreML on macOS, CUDA on Linux only in a build made with `--features cuda` against a CUDA build of ONNX Runtime (not the release builds; see "CUDA on Linux" below), and CPU everywhere. Each accelerator gets a probe batch at load; one that cannot build or run the graph falls back to CPU at load time, and `decide:status` reports the provider actually in use and why any were skipped. - **Fixed shapes on DirectML and CoreML.** DirectML only runs fast on a shape it compiled when the session was created: a dynamic session answered its first-seen shape in 15 ms and every other shape in about 85 ms. Accelerated sessions therefore pin one row of `max_len` tokens and 128 option slots, and run a call's questions one after another. CPU and CUDA batch every question of a call in one run. - **Surfaces.** Daemon channels `decide:load` (`[dir, precision?]` or `[{ dir, precision?, ep?, name? }]`), `decide:unload`, `decide:status` and `decide:run` (`[{ model?, state, questions }]`); on the engine's OpenAI-compatible port, `POST /v1/decisions` (Jev's path) and `POST /v1/systemone` (`laya-serve`'s), behind the same API key and CORS as every other route, answering 409 when no decision model is loaded. - **Model directory.** `model_fp16.onnx` (default), `model.onnx` (fp32, may have a `.data` file beside it) or `model_int8.onnx`, plus `tokenizer.json` (at the root or under `tokenizer/`) and `rl_agent_config.json`. ## Measured RTX 5090, Ryzen 9 9950X3D, Windows 11, release build, the English checkpoint, the README's support-ticket email as the state (`cargo run --release -p nekko-decide --example latency -- `). p50 of 100 (DirectML) or 10 (CPU) calls after warm-up: | | 1 question | 10 questions | load | | --- | --- | --- | --- | | DirectML, fp16 | **8.6 ms** | 81 ms | 1.5-2.7 s | | DirectML, fp32 | 15 ms | 154 ms | 2.5 s | | DirectML, fp16, dynamic shapes (not used) | 86 ms | 102 ms | | | CPU, fp32 | 94 ms | 733 ms | 3.7 s | | CPU, fp16 | 106 ms | 707 ms | 2.5 s | | CPU, int8 | 68 ms | 731 ms | 1.0 s | Laya's README reports 39.5 ms and 158.6 ms on a T4 with torch. Batching ten questions into one DirectML run would be faster than ten runs of one, but only with a second fixed-shape session, which doubles GPU memory; not done. The same machine and model files, measured again on 2026-10-17 for every checkpoint (p50 of 100 calls; CUDA in Docker under WSL2 on the same GPU, `scripts/decision-models/cuda-golden.sh`; Linux CPU in the same container): | | laya-en, 1 / 10 questions | laya-multilingual | laya-typed-decisions | load (laya-en) | | --- | --- | --- | --- | --- | | DirectML, fp16 (Windows) | 8.4 / 86 ms | 10.6 / 106 ms | 17.4 / 175 ms | 1.4 s | | DirectML, fp32 (Windows) | 15.6 / 155 ms | | | 1.9 s | | CUDA, fp16 (Linux) | 10.5 / 13.3 ms | 8.0 / 9.1 ms | 10.9 / 13.2 ms | 5.3 s | | CUDA, fp32 (Linux) | 10.3 / 20.6 ms | 8.0 / 11.6 ms | 10.3 / 20.6 ms | 10.6 s | | CPU, fp32 (Windows) | 90 / 787 ms | | | 1.7 s | | CPU, fp32 (Linux, what Linux release builds run) | 71 / 560 ms | | | 10 s | CUDA batches a call's questions into one run, so ten questions cost little more than one; DirectML runs them one after another on its fixed shape. The typed-decisions model is slower on DirectML than the English one with the same architecture because its 1,024-token budget is the fixed shape every question is padded to. ### CUDA on Linux (PF33) `--features cuda` makes `ort` download pyke's ONNX Runtime 1.28 build with CUDA 13. **That build does not run on an RTX 50-series (Blackwell, sm_120) GPU**: its CUDA kernels are compiled for sm_75, sm_80 and sm_90 only and carry no PTX, so every session fails its probe ("CUDA failure 209: no kernel image is available for execution on the device") and the runtime falls back to the CPU, correctly but silently fast-less. Microsoft's own 1.28.3 build (`onnxruntime-linux-x64-gpu_cuda13`, sm_75 to sm_120 plus sm_120 PTX) works when linked dynamically (`ORT_LIB_LOCATION= ORT_PREFER_DYNAMIC_LINK=1`); mixing its provider library with pyke's static core does not ("ComputeAsync is not implemented"). With it, all three checkpoints pass the golden test on CUDA (fp32 max error 0.0001, fp16 0.017 / 0.020 / 0.006, every answer the same). ONNX Runtime's CUDA provider runs fp32 MatMuls in TF32 by default, which moved fp32 probabilities by up to 0.004; the runtime now turns TF32 off. **Decision: Linux release builds do not ship CUDA.** What shipping it would take: Microsoft's `libonnxruntime.so` (27 MB) and CUDA provider (267 MB) beside `nekkod`, 295 MB on top of a 203 MB AppImage and 157 MB .deb, plus a CUDA 13 runtime and cuDNN 9 that are not on most Linux machines and are over 1 GB to bundle. What it buys: about 60 ms per question (71 to 10 ms) and 0.5 s per ten-question call. Decision calls are agent tool calls next to chat-model turns that take seconds, so the CPU is fast enough, and Windows (DirectML) and Macs (CoreML) already get the GPU without any of this. Revisit if Linux users ask for it; the way to do it would be a separately downloaded runtime (as DirectML.dll is fetched on demand) rather than a bigger package. Self-builders can use the commands in `scripts/decision-models/cuda-golden.sh`. ### CoreML on macOS `.github/workflows/decision-models.yml` (run by hand, and on changes to `crates/nekko-decide`) downloads the exports, checks their SHA-256 sums, and runs the golden test with CoreML requested on a `macos-14` (Apple silicon) runner, writing which provider actually ran each checkpoint to the job summary. GitHub's macOS runners are virtual machines without the Neural Engine, and Apple's paravirtualized GPU may or may not take the model, so a CPU fallback there does not prove one on a real Mac, while a CoreML pass proves the graph compiles and answers correctly under CoreML. The first run (2026-10-17) did not get that far: the golden set under CoreML on the VM was still running when the job hit its 60-minute limit, so it showed neither a CoreML run nor a fallback. The workflow now asks the latency example first, which reports the provider in use and any fallback reason within minutes. Its second run (2026-10-17, laya-en fp16 on `macos-14`) answered: **CoreML took the model, with no fallback**, but it is unusably slow in the VM: load 127 s, one question 32.8 s p50 (39.7 s p95, 5 runs), against about 0.07 s on a CPU, and the ten-question call did not finish inside the step's 30 minutes. The runner's GPU is paravirtualized and it has no Neural Engine, so this says nothing yet about a real Mac, but if a real Mac is anywhere near this slow, CoreML must not be the default there (`Ep::accelerators()` would need to prefer the CPU on macOS). That is what the Mac check below decides. **Checking CoreML on a Mac** (Apple silicon, about 3 GB of disk and 10 minutes): with Rust and the GitHub CLI installed, from a checkout of this repository run ```sh bash scripts/decision-models/mac-check.sh # laya-en bash scripts/decision-models/mac-check.sh laya-en laya-multilingual laya-typed-decisions rm -rf ~/nekko-decision-check # afterwards ``` It downloads the exports (checked against `SHA256SUMS`), runs the golden test with CoreML requested and on the CPU in fp16 and fp32, and the latency example, then prints a summary. What to look for: each line says `on coreml` (CoreML ran it) or `on cpu` (it fell back; the latency run above prints the reason), the token check says 86, the answers say 86/86, and the max error stays under 0.02 for fp16 and 0.001 for fp32. Paste the summary and the latency lines into the PR or TASKS.md (PF33). ## Accuracy against the reference `crates/nekko-decide/tests/golden/` holds 60 requests (86 questions: all three types, 1 to 30 options, states from empty to far past the 512-token window, JSON and conversation states, unicode, structured criteria, `option_order`) and what the official `laya` 0.3.22 package answered on CPU in fp32, once per checkpoint (`golden.json` for English, `golden-multilingual.json`, `golden-typed-decisions.json`; `LAYA_GOLDEN` picks one). The test runs when `LAYA_MODEL_DIR` points at a model directory and is skipped otherwise, so CI never downloads the model. Every row below has every token sequence identical to the reference's (86/86). Measured on the hosted files (`decision-models-v1`), RTX 5090, Windows (DirectML, CPU) and Linux in Docker (CUDA). Max and mean absolute probability error; answers the same as the reference's: | | laya-en | laya-multilingual | laya-typed-decisions | | --- | --- | --- | --- | | fp32, CPU | 0.0001 / 0.00002, 86/86 | 0.0000 / 0.00002, 86/86 | 0.0000 / 0.00003, 86/86 | | fp32, DirectML | 0.0001 / 0.00002, 86/86 | 0.0001 / 0.00002, 86/86 | 0.0000 / 0.00003, 86/86 | | fp16, DirectML | 0.0186 / 0.00097, 86/86 | 0.0157 / 0.00080, 86/86 | 0.0057 / 0.00057, 86/86 | | fp16, CPU | 0.0173 / 0.00049, 86/86 | 0.0029 / 0.00016, 86/86 | 0.0053 / 0.00036, 86/86 | | int8, CPU | 0.048 / 0.0037, 86/86 | 0.125 / 0.0068, 85/86 | 0.025 / 0.0023, 85/86 | | fp32, CUDA (Linux) | 0.0000 / 0.00002, 86/86 | 0.0001 / 0.00002, 86/86 | 0.0000 / 0.00003, 86/86 | | fp16, CUDA (Linux) | 0.0167 / 0.00058, 86/86 | 0.0196 / 0.00076, 86/86 | 0.0060 / 0.00039, 86/86 | The test fails fp32 over 1e-3, fp16 over 0.02 and int8 over 0.06, and fp32 or fp16 on any changed answer. That means the multilingual int8 file fails it (one answer flips, a 0.125 move on a 15-option near-tie): its 256k-token embedding table is a Gather, which the weight-only quantizer leaves in fp32, so the file is larger than fp16 (929 MB against 646 MB) and less accurate. It is on the release for completeness but not in the app's catalog. The typed-decisions int8 flip is a near-tie on a 14-option question and inside int8's bound. ## The hosted export The app downloads the exports from a release of this repository, [`decision-models-v1`](https://github.com/nekko-labs/nekko-agent/releases/tag/decision-models-v1). It is never marked the latest release, because the app's updater (`crates/nekkod/src/native.rs`) and the website read `releases/latest`. Assets are named `.`; `SHA256SUMS` lists all of them, `LICENSE` is upstream's Apache-2.0 license and `NOTICE` says what was changed. The catalog (`crates/nekkod/src/decision-catalog.json`) pins every file's size and SHA-256. | checkpoint | upstream (pinned) | fp16 | fp32 | int8 | tokenizer | | --- | --- | --- | --- | --- | --- | | `laya-en` | `convaiinnovations/laya@55cf4c4` | 846 MB | 1,689 MB | 612 MB | 3.6 MB | | `laya-multilingual` | `convaiinnovations/laya-multilingual@e4e9ddf` | 646 MB | 1,290 MB | 929 MB (not offered) | 34 MB | | `laya-typed-decisions` | `convaiinnovations/laya-typed-decisions@1a793eb` | 846 MB | 1,689 MB | 612 MB | 3.6 MB | The revisions are the ones laya 0.3.22 itself pins as reviewed (`laya.revisions.PINNED_REVISIONS`); their weights are byte-identical to `main` today. Upstream's license is Apache-2.0 (the GitHub repository's `LICENSE`, and every checkpoint's model card), which allows redistributing the weights and derivative works with the license and a notice of changes; upstream ships no NOTICE file of its own. **Download in the app.** Model Server's Decisions tab lists all three. Downloading one precision queues its four small files and the model file as one group in the engine daemon's download queue (`decisions:download`, `crates/nekkod/src/decisions.rs`, the same queue as chat models: resumable, cancellable, shown in Downloads). Each file is checked against its published size and SHA-256 before it is renamed into place, so a file that is not the published one is refused and deleted, never loaded; a file already on disk at its published size is kept. Measured: laya-en fp16 (0.85 GB) from the real release into an empty data directory in 62 s, loaded on DirectML fp16 in 2.2 s, and the README's support email answered `billing` (0.988) in 19 ms (`scripts/itest-decision-download.mjs`). ## The export recipe `scripts/decision-models/` rebuilds every hosted file from the pinned checkpoints (README there). Python is used at build time only, in a throwaway virtual environment. 1. fp32: laya's own `scripts/export_onnx.py` recipe (torch dynamo exporter, opset 18, dynamic batch, sequence and option counts, traced from batch-2 dummies). 2. Clear `allowzero` on every `Reshape` (DirectML refuses `allowzero=1`; no shape tensor in this graph holds a literal 0, so the result is identical). 3. fp16: `onnxruntime.transformers.float16.convert_float_to_float16(model, keep_io_types=True)` after deleting the graph's `value_info` (stale intermediate types are what broke the tozp file). For laya-en and laya-typed-decisions the rotary-embedding angle nodes (`Cos`/`Sin` and what feeds them, 14 small nodes) stay in fp32: the angles reach ~500 rad at 512 tokens, where fp16 resolves only 0.25 rad, and plain fp16 put laya-en at 0.0213 on DirectML, just over the test's 0.02. The multilingual model measured better plain (0.0157 against 0.0343), so it is converted plain. Keeping nodes in fp32 makes the converter insert duplicate Casts out of order; the script drops the duplicates and sorts the graph. 4. int8: weight-only, `MatMulNBitsQuantizer` with 8 bits, block size 128, symmetric, `accuracy_level=4`. Laya's own recipe (`quantize_dynamic`) quantizes activations per tensor and moves logits by up to 3.7 on the English checkpoint, flipping 20 of 86 answers. 5. The tokenizer and config files are upstream's own bytes, fetched fresh (loading laya rewrites the cached `tokenizer_config.json` of the multilingual checkpoint in place). `reference.py` regenerates the golden files; rerun on the English checkpoint it reproduces the committed `golden.json` (identical tokens, probabilities within 4e-6, from torch 2.14.0 to 2.14.1). The only other public export, `tozp/laya-onnx`, was traced with TorchScript at opset 14 from a batch-1, 512-token dummy input: its decision head only runs at exactly 512 tokens, and at 512 its logits are off by up to 4 against the reference (36 of 86 golden answers match). Its fp16 file also fails to load on ONNX Runtime's CPU provider (a type error in a converter-inserted Cast, and an access violation with full optimizations). The runtime refuses exports below opset 17 with a message saying why, since that CPU failure would otherwise take the daemon down. ## Limitations and open items - **No automatic routing between checkpoints.** laya's `Router` picks the multilingual checkpoint for non-Latin scripts; here the user picks the model they load, and one is loaded at a time. `laya-multilingual` runs at its shipped 1,024-token budget (the 8,192-token mode is not offered). - **The model's own limits.** The English checkpoint is near chance on the typed-decisions benchmark zero-shot, ships over-confident (the `choice:11+` temperature is clamped from 0.10 to 0.5, as laya does, and those confidences are uncalibrated), and many options share a 192-token head budget, so questions with dozens of options lose accuracy. See Laya's README, "Honest Limits". - **Not verified here:** CoreML on a real Mac (the checklist above is for that; it falls back to the CPU if it fails), and a DirectML machine older than this one. CUDA is verified on Linux in Docker on an RTX 5090, with Microsoft's ONNX Runtime build. - **Windows runtime libraries.** ONNX Runtime is statically linked, and on Windows it makes `nekkod` import the Visual C++ runtime's `MSVCP140.dll` (Rust already needed `VCRUNTIME140.dll` from the same package). pyke's prebuilt ONNX Runtime is compiled against the DLL runtime (`/MD`), so `nekkod` cannot link the runtime statically; instead the installer and the npm package ship the runtime DLLs app-local beside `nekkod` (`scripts/vc-runtime.mjs`), and a PC without the Visual C++ Redistributable still starts it. - **DirectML is not shipped.** `nekkod` delay-loads `DirectML.dll` (`crates/nekkod/build.rs`), and before the DirectML provider starts `crates/nekko-decide/src/directml.rs` loads a copy at least 1.15: Windows' own `System32\DirectML.dll` (Windows 11 has carried one since 2024), or one the host fetched. On a Windows whose copy is older (Windows 10), loading a model fetches Microsoft's `DirectML.dll` 1.15.4 once, reading only that entry out of the `Microsoft.AI.DirectML` NuGet package with HTTP range requests (about 9 MB of a 200 MB package) and checking it against a pinned SHA-256 (`packages/host/src/engine/directml.ts`). If that fails the model loads on the CPU and the load message says why. This took 18 MB off every install. --- # Local inference benchmarks How fast Nekko Agent's engine runs a model compared with Ollama and LM Studio, on the same machine and the same model file, measured the same way. vLLM is supported by the harness but not measured here (it has no native Windows build). The numbers are from one machine. They are not a promise about yours; run the harness (below) to get your own. ## Results, 2026-09-30 **Model:** `gemma-4-12B-it-Q4_K_M.gguf` (lmstudio-community), the same file for every server. **Machine:** NVIDIA RTX 5090 32 GB, AMD Ryzen 9 9950X3D, Windows 11. Median of 3 runs; 32k context; everything on the GPU. | Measure | Nekko Agent engine | llama.cpp as the engine ran it before | Ollama 0.23.0 | LM Studio (llama.cpp CUDA 12, 2.46.0) | | --- | --- | --- | --- | --- | | Decode, tokens/s | **154** | 127 | 115 | 116 | | Code edit that repeats its input, tokens/s | **534** | 117 | 106 | 108 | | Four requests at once, total tokens/s | **256** | 283 | 113 | 210 | | Time to first token, short prompt | **99 ms** | 98 ms | 153 ms | 114 ms | | Prefill of an 8k-token prompt, tokens/s | 5,832 | 5,968 | **6,170** | 5,886 | | New agent session (8k-token context), first turn to first token | 1,719 ms | 2,280 ms | **1,280 ms** | 2,565 ms | | Agent session, later turns to first token | 253 ms | 219 ms | **211 ms** | 288 ms | | Agent session, six turns end to end | 7.9 s | 8.4 s | **7.6 s** | 9.3 s | Six chats visited in turn, three rounds each, more chats than the server has slots (what a person with several agent chats open does): | | Nekko Agent engine | llama.cpp default prompt cache | llama.cpp, no RAM prompt cache | Ollama | LM Studio | | --- | --- | --- | --- | --- | --- | | Revisit of a chat, to first token | **790 ms** | 1,729 ms | 1,894 ms | 1,290 ms | 1,600 ms | | All three rounds, end to end | **27.1 s** | 32.0 s | 38.4 s | 31.7 s | 33.3 s | ### Reading the results honestly - **Where Nekko Agent is clearly ahead:** edits that repeat their input (about 4.5x the other servers, from n-gram speculative decoding), plain decode (about a third faster), coming back to a chat that lost its slot (about 40% faster than Ollama), and parallel requests (Ollama serves one at a time by default). - **Where it is not:** the first turn of a brand-new long conversation is slower than Ollama's (1.7 s against 1.3 s), and prefill is level with everyone. The first-turn gap was 2.3 s before this work; the remaining difference is under investigation. - **Noise:** run-to-run variation was about 10% on prefill and larger on the four-request number (the same configuration measured 256, 283 and 343 tokens/s across runs). Differences smaller than that are not claims. - "Nekko Agent engine" here is `llama-server` from the pinned llama.cpp build started with exactly the flags the engine now passes, measured without the engine's router in front of it. The router (Rust, in the engine daemon) adds about 0.1 ms per request: 27.39 ms against 27.29 ms straight to the model server, median of 30 one-token requests. ### What changed to get there Measured flag by flag on the same model and build (b11011): - **`--spec-default`** (n-gram speculative decoding from the conversation, no draft model): code edit 117 to 506-534 tokens/s, decode 127 to 154-155. - **`--no-cache-idle-slots`**: llama.cpp's default copies every idle slot to its RAM prompt cache when a new request arrives. Keeping them in place cut the first turn of a new chat from 2.3 s to 1.7 s and a revisit of an evicted chat from 1.7 s to 0.8 s. The RAM cache still catches what a slot really loses (turning it off entirely was worse on both). - **`--cache-reuse 256`**: reuse cached chunks around an edit in the middle of a prompt. - **Vision models keep it.** With Gemma's projector loaded as well, the code edit ran at 633 tokens/s and decode at 152; llama.cpp only drops `--cache-reuse` for multimodal models. - **A draft model is opt-in, not automatic.** Gemma 4 E4B as the draft for the 12B raised the code edit to 624 tokens/s but halved ordinary decode (155 to 74) because only 55% of its guesses were accepted. A draft helps only when it is much smaller than its target. - Parallel slots and a shared KV pool were already llama.cpp's default (`-np` auto with a unified cache), so nothing changed there. Each flag is passed only when the installed engine build supports it (the engine reads the binary's `--help` once), so an older engine keeps working without them. ## Reproducing The harness is `crates/nekko-bench`. It measures; it does not start servers. ```bash cargo build --release -p nekko-bench ``` Start each server with the same GGUF, then list them in a config file: ```json { "machine": "RTX 5090 32 GB, Windows 11", "model": "gemma-4-12B-it Q4_K_M", "repeats": 3, "targets": [ { "name": "Nekko Agent engine", "baseUrl": "http://127.0.0.1:18095/v1", "model": "gemma" }, { "name": "Ollama", "baseUrl": "http://127.0.0.1:11434/v1", "model": "bench-gemma" }, { "name": "LM Studio", "baseUrl": "http://127.0.0.1:1234/v1", "model": "bench-gemma" } ] } ``` ```bash target/release/nekko-bench bench.json --out results.json --markdown results.md ``` `--only decode,agent` runs a subset. The workloads: `decode`, `prefill`, `agent` (six turns over an 8k-token context), `rotate` (six chats, three rounds), `code-edit`, `concurrent` (four at once). How each server was started for the results above: - **Nekko Agent engine:** `llama-server --model --alias gemma --port 18095 --ctx-size 32768 --n-gpu-layers 999 --cache-reuse 256 --spec-default --no-cache-idle-slots` - **llama.cpp as before:** the same without the last three flags. - **Ollama:** a Modelfile with `FROM `, `PARAMETER num_ctx 32768`, `PARAMETER num_gpu 999`, then `ollama create bench-gemma -f Modelfile`. - **LM Studio:** `lms server start` and `lms load google/gemma-4-12b --context-length 32768 --gpu max --identifier bench-gemma`. The harness counts tokens from each server's usage report when it sends one, and times the first streamed piece of text or reasoning as the first token. --- # Floop tasks [Floop](https://github.com/nekko-labs/floop) is a work tracker where a team and its AI agents keep their work in two separate streams. Nekko Agent can read your Floop tasks and work on them as its own agent. ## Show your Floop tasks 1. In Floop, open **Settings → Agents**, add an agent for Nekko Agent (or use the existing `nekko-agent`), and create an **API token** for it. 2. In Nekko Agent, open **Connectors**, find **Floop**, and enter your Floop server URL (for example `https://your-team.fly.dev`) and the token. 3. Your open Floop tasks appear in the Agent Monitor's **Tasks** section and can be pulled into a chat's context. The server URL must use https (a Floop running on `localhost` is the exception). ## Let the agent update tasks The connector only reads. To let Nekko Agent claim tasks, post progress and finish them, add Floop's MCP server: 1. Install the Floop CLI from the Floop repository (`npm install -g ./packages/cli`). 2. Save the agent's token as a profile: `floop login --profile nekko-agent --url https://your-team.fly.dev --token flp_…` 3. In Nekko Agent, open **Settings → MCP servers** and add a server with the command `floop` and the arguments `mcp --profile nekko-agent`. The agent then gets tools to find its next task, start it, leave notes, finish it, or hand it to a person with a question. Every call goes through your normal approvals, and Floop records each change under the agent's name. --- # Contributing to Nekko Agent Thanks for your interest! Nekko Agent is an open-source, local-first AI coding & cowork desktop app. Contributions of all sizes are welcome. ## Development setup Requires Node 22.12+ and a Rust toolchain ([rustup.rs](https://rustup.rs)): `npm run dev` and `npm run build` compile the engine daemon (`nekkod`). The project uses **npm workspaces** (not pnpm/yarn). ```bash git clone https://github.com/nekko-labs/nekko-agent cd nekko-agent npm install npm run dev # build the Rust daemon and the app, then launch the desktop app ``` To stop a dev run, press **Enter** or **q** (or Ctrl+C) in its terminal. The app quits the normal way, stopping the engine and any model servers it started; press again to force it, which ends the whole process tree. A run that hasn't exited after 10 seconds is forced too. ## Project layout | Path | What | | --- | --- | | `crates/` | The engine, in Rust: `nekkod` (the daemon every edition starts) and the crates it is built from (providers, the agent loop, tools, context, chats, automation, the model server, the phone relay agent). Tested with `cargo test`. | | `packages/shared` | Types + IPC contracts (pure, no runtime deps) | | `packages/profile` | The user's data folder on this machine: where it lives, moving it, older installs' profiles | | `apps/desktop` | Electron app: `src/main` (Node), `src/preload` (bridge), `src/renderer` (React) | | `apps/cli` | The `nekko-agent` CLI and MCP server, on a private nekkod or a running app | | `apps/local-web-ui` | The Local Web UI server: the same React UI over HTTP, in front of nekkod | | `apps/website` | Static marketing site | **Rule of thumb:** engine behaviour goes in `crates/` (testable with no app at all); the apps are the UI and the transport in front of nekkod. ## Publishing the `npx nekko-agent` package `npm run bundle:web` produces a self-contained package in `apps/local-web-ui/cli-dist/` (server + engine bundled by esbuild, plus the built `web/` UI). To release it: ```bash npm run bundle:web cd apps/local-web-ui/cli-dist && npm publish ``` After that, anyone can run the Local Web UI with `npx nekko-agent`. ## Before you open a PR Keep the build green and the suite passing: ```bash npm run build # Rust daemon (nekkod), then shared → profile → cli → desktop npm test # tests in every workspace (one Vitest run over all of them) npm run typecheck # every TypeScript workspace ``` `npm test` runs the root `node --test` scripts, desktop's `test:node`, then a single `vitest run` whose projects (root `vitest.config.ts`) are the six workspaces. Narrow it with `npx vitest run --project apps/desktop`, or run one package as before with `npm test -w @nekko-agent/desktop`. The engine's tests are `cargo test --workspace`. - Add tests in `crates/` for new engine behavior (providers, guardrails, agent loop, context); real-binary tests of the daemon go in `crates/nekkod/tests/daemon`. - Desktop UI flows: add a step to a journey in `apps/desktop/e2e/` (`npm run test:e2e -w @nekko-agent/desktop`, hidden windows only; see its README) rather than a new one-off script. - Match the surrounding code style; keep changes focused. - For provider changes, verify against a real local server: point a provider at it and run a chat through the CLI on this checkout's nekkod (`NEKKOD_PATH=target/debug/nekkod node apps/cli/dist/index.js chat "hi" --new`). ## Documentation The docs at [nekkoagent.com/docs](https://nekkoagent.com/docs) are rendered from Markdown in this repo. `docs/hub/nav.json` lists the pages and the file each comes from. The generated pages are not committed: Vercel builds them on every deploy, so a docs change is just the Markdown edit. When a pull request changes what users see or do, update the page that describes it in the same PR. To look at the result before it ships, `npm run website` builds the docs and serves the site on `http://127.0.0.1:8080` (set `PORT` to change it), with the same clean URLs as production. ```bash npm run website ``` Adding, removing or renaming a page in `nav.json` also changes the docs list in `apps/website/llms.txt` and the docs URLs in `apps/website/sitemap.xml`, which are committed. `npm run docs` updates them; if you forget, the **Docs site** workflow does it on your pull request and pushes the result to its branch. ## Commit messages Conventional commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`). ## Reporting bugs / requesting features Use the issue templates. For local-model issues, please include your provider kind (Ollama / LM Studio / vLLM / cloud), the base URL shape, and the model id. ## License By contributing, you agree your contributions are licensed under the [MIT License](https://github.com/nekko-labs/nekko-agent/blob/main/LICENSE). --- # Architecture Nekko Agent is one engine and one React UI that run in several places: the desktop app, the self-hosted Local Web UI, Docker and the phone. This page is a map of the code for contributors. The full technical plan, with every decision and task, is in [`TASKS.md`](https://github.com/nekko-labs/nekko-agent/blob/main/TASKS.md); what the product does and why is in [`spec/`](https://github.com/nekko-labs/nekko-agent/blob/main/spec/README.md), one file per feature. ## The big picture ```text desktop window (Electron + React) browser (Local Web UI, Docker) | | | IPC HTTP + WebSocket v v apps/desktop main apps/local-web-ui \ / \----------- nekkod (Rust) ------------/ engine daemon on loopback / \ engine services model servers (chat, tools, (llama-server, terminals, ...) routed) ``` - **The UI never shares a process with the agent.** The window talks to `nekkod`, the engine daemon, over a loopback connection with a per-launch token. The agent loop, terminals and model servers run there, so a busy agent cannot freeze the interface. - **The engine is all Rust.** `nekkod` answers every engine channel itself. It was ported from a TypeScript engine (`packages/host`, `packages/core`, deleted 2026-10-19); the golden fixtures under `crates/*/tests/golden` are frozen records of that engine's behaviour ([docs/runbooks/golden-fixtures.md](https://github.com/nekko-labs/nekko-agent/blob/main/docs/runbooks/golden-fixtures.md)). - **Every edition is the same app.** Desktop, web and Docker run the same engine and the same UI. Only the transport differs: Electron IPC on the desktop, HTTP and WebSocket in the browser. ## Desktop lifetime and notifications Closing the UI already hides it to the tray without stopping the engine. Explicit Quit shuts down the service and managed processes; Stop Nekko service stops the engine without quitting the UI. Neither arrangement promises continued work during OS sleep, shutdown or logout. The 2026-10-13 implementation moves Electron native notifications into main (`apps/desktop/src/main/notifications.ts`), with a direct authenticated engine WebSocket subscription, fresh settings reads per eligible notice, reconnect/backoff and disposal on Quit. Main creates native notices; click delivery uses an encoded session deep link to restore the window and select the chat. Renderer notifications remain for browser editions only, avoiding duplicate Electron delivery. Settings exposes the default-on toggle in its standalone Notifications section. The engine's usage summaries emit per-day session costs, local tokens, avoided costs and model ids. Budget aggregates those UTC buckets for every recorded detail instead of mixing a range with lifetime totals. Utilization's same-token Claude dedupe is display-only and prefers Anthropic, without changing provider routing or storage. Status: implemented, unit and isolated fixture interaction tested; image/motion inspection and native toast delivery/click/permissions testing pending. Main currently assumes events are unattended, so focused-visible-chat suppression is a known review issue. Parent owns resolution and landing. ## Repository layout | Path | What lives there | | --- | --- | | `packages/shared` | Types and IPC contracts. Pure, no runtime dependencies. | | `packages/profile` | The user's data folder on this machine: where it lives, moving it, older installs' profiles, and the running app. Used by the desktop's main process, the CLI and the Local Web UI. | | `apps/desktop` | The Electron app: `src/main` (Node), `src/preload` (the bridge) and `src/renderer` (React). | | `apps/local-web-ui` | The self-hosted Local Web UI, also the `npx nekko-agent-local-web-ui` package. | | `apps/cli` | The `nekko-agent` CLI and MCP server. | | `apps/mobile` | The iOS and Android app (Expo). | | `apps/relay-server` | The relay that pairs a phone with a computer. It only ever sees encrypted frames. | | `apps/nekko-agent-api` | Nekko Cloud's API: signups, accounts, encrypted sync. The only part of the project that needs a database. | | `apps/website` | nekkoagent.com, including these docs. | | `crates/nekkod` | The engine daemon. | | `crates/nekko-*` | The daemon's services: chat turns, tools, terminals, inference, remote access, storage, usage and more. | ## Where data lives Nekko Agent is local-first. Everything you can run yourself works without a database; Postgres is only behind the hosted Nekko Cloud API. ```text your computer Nekko Cloud (hosted, optional) --------------------------------- ------------------------------------ desktop / Local Web UI / Docker apps/relay-server (stateless, nekkod + TS host routes encrypted frames only) chats: /sessions/.json settings, memory, models: files ---> apps/nekko-agent-api (Fastify, Fly.io) in the local data directory E2EE | sync v phone app Postgres (Neon) pairs through the relay signups, users, api_tokens, sync_* (ciphertext only), donations ``` | Component | Storage | Database needed? | | --- | --- | --- | | Desktop app, Local Web UI, Docker | Files in the local data directory (one JSON file per chat, written to a temp name and renamed into place) | No | | Relay (`apps/relay-server`) | None: it routes encrypted frames and keeps nothing | No | | Nekko Cloud API (`apps/nekko-agent-api`) | Postgres via `DATABASE_URL` | Yes | ### The cloud database - **Provider.** Production runs on [Neon](https://neon.tech) serverless Postgres, through Neon's pooled connection string. The Neon project sits in `aws-us-east-1`, next to the API's Fly region (`iad`). Any recent Postgres works; Neon is what we run and recommend (see [the API README](https://github.com/nekko-labs/nekko-agent/blob/main/apps/nekko-agent-api/README.md#database-neon-recommended)). - **Scale to zero.** Both the Fly machine and the Neon compute stop when idle. The first query after a quiet spell waits for the compute to wake (well under a second), and the pool's timeouts in `src/db.ts` leave room for that. - **What it holds.** Signups and accounts (`signups`, `users`), API tokens stored as SHA-256 hashes, end-to-end encrypted sync (`sync_*`: ciphertext, opaque ids and revisions; the server never holds a key that opens them), and opt-in research samples sealed to an offline key (`donations`, with no user, device or IP column). - **Migrations.** Plain SQL in `apps/nekko-agent-api/migrations/NNNN_*.sql`, applied in order on every start inside one transaction under an advisory lock and recorded in `schema_migrations`. No ORM. - **Tests.** The API's tests run against PGlite in memory, so contributors never need a database or a Neon account. - **Planned.** Account sign-in, billing and the relay's authorization endpoint (`NEKKO_RELAY_AUTHZ_URL`) will live in the same service and database. ## Build order `npm run build` builds the Rust daemon, then the TypeScript workspaces in dependency order: `shared`, `profile`, `cli`, `desktop`. `npm run dev` runs a faster variant (the daemon in the incremental `dev-daemon` profile, no production desktop bundle, since electron-vite dev builds its own) and launches the desktop app. ## Where to put new code - Engine behaviour goes in a Rust crate under `crates/`, so it can be tested without Electron. - The desktop app only wires the engine to the window, the file system and the shell. - A change to what the product does also updates its file under `spec/` (and `node scripts/spec-index.mjs` for a new one); a change to how it is built updates `TASKS.md`. --- # Release signing How Nekko Agent's macOS and Windows builds get signed (and, on macOS, notarized), what is wired, and what needs a human with an Apple or Azure account. Everything here is **secret-gated**. With no signing secrets configured the release still builds and publishes, just unsigned, so nothing below can block a release. A *partially* configured set fails the job before building, because a signed-but-unnotarized app is still blocked by Gatekeeper while looking like it worked. ## Status at a glance | Platform | Mechanism | Wired | Blocked on | | --- | --- | --- | --- | | macOS | Developer ID codesign + notarization | ✅ live since v0.6.0 | nothing | | Windows | Authenticode via Azure Artifact Signing | ✅ secret-gated | Azure identity validation + the secrets below | | Linux | none (AppImage/deb are unsigned) | n/a | n/a | ## macOS Nekko Agent is packaged by electron-builder, which does the signing, hardened runtime, and notarization itself. There is no hand-rolled `codesign` pipeline here, and no `Developer ID Installer` certificate is needed, because the artifacts are `.dmg`/`.zip` rather than a `.pkg`. What is configured, in `apps/desktop/electron-builder.yml`: - `hardenedRuntime: true` — required for notarization - `entitlements` / `entitlementsInherit` → `build/entitlements.mac.plist` - `notarize: false` by default, flipped on with `-c.mac.notarize=true` by the release workflow, so a local `npm run dist` does not need the Apple API key `apps/desktop/scripts/after-pack.cjs` ad-hoc signs the app **only when no real certificate is present**, so unsigned local builds still launch on Apple Silicon. It steps aside when `CSC_LINK` is set. ### The entitlements, and why each one is there The hardened runtime disables things Electron and Nekko Agent need. See the comments in [`build/entitlements.mac.plist`](https://github.com/nekko-labs/nekko-agent/blob/main/apps/desktop/build/entitlements.mac.plist); the short version: | Entitlement | Needed for | | --- | --- | | `allow-jit`, `allow-unsigned-executable-memory` | V8 | | `disable-library-validation` | the `@lydell/node-pty` prebuilt `.node`, dlopen'd from outside the asar | | `allow-dyld-environment-variables` | spawning shells, agents, and MCP servers | | `files.user-selected.read-write` | the user's project folders | | `network.client` / `network.server` | model providers and the local phone-relay server | | `device.camera` | the QR pairing scanner | `NSCameraUsageDescription` is set via `mac.extendInfo`. Without it macOS kills the process instead of prompting when the QR scanner opens. ## Secrets These are **`nekko-labs` organization secrets**, shared with `hypergate` and `lightwrite`, so the certificate is uploaded and rotated in exactly one place. | Secret | What it is | | --- | --- | | `MACOS_SIGNING_CERTS_P12` | base64 of the Developer ID `.p12` (→ `CSC_LINK`) | | `MACOS_CERT_PASSWORD` | password for that `.p12` (→ `CSC_KEY_PASSWORD`) | | `APPLE_API_KEY_P8` | App Store Connect API key contents | | `APPLE_API_KEY_ID` | ASC key id | | `APPLE_API_ISSUER` | ASC issuer id (a UUID) | | `AZURE_SIGNING_TENANT_ID` | Entra tenant of the signing service principal | | `AZURE_SIGNING_CLIENT_ID` | its application (client) id; the Windows gate | | `AZURE_SIGNING_CLIENT_SECRET` | its client secret | Plus four non-secret **organization variables** (`gh variable set --org`), also read by the Windows leg: `AZURE_SIGNING_ENDPOINT`, `AZURE_SIGNING_ACCOUNT`, `AZURE_SIGNING_PROFILE`, `AZURE_SIGNING_PUBLISHER`. See [Windows](#windows). Certificate: `Developer ID Application: Nekko Labs LLC (3HM5598S99)`. ### Uploading them Run on a Mac that has the Developer ID certificate **and its private key** in the login keychain (`security find-identity -v -p codesigning` to confirm). Requires org-admin on `nekko-labs`. Export the `.p12` from **Keychain Access** (login → My Certificates → expand the `Developer ID Application: Nekko Labs LLC` row so the private key goes with it → Export), rather than with `security export -t identities`, which dumps *every* identity in the keychain into one bundle. Either way you choose an export password, which becomes `MACOS_CERT_PASSWORD`. Then, and read the warning below before pasting: ```bash gh secret set MACOS_SIGNING_CERTS_P12 --org nekko-labs --visibility all < <(base64 -i /tmp/nekko-signing.p12) gh secret set APPLE_API_KEY_P8 --org nekko-labs --visibility all < AuthKey_XXXXXXXX.p8 gh secret set APPLE_API_KEY_ID --org nekko-labs --visibility all --body AuthKeyIdHere ``` > **Do not paste those as a block with the two interactive ones.** `gh secret > set` without `--body` reads its value from **stdin**, so a pasted command on > the following line is consumed as the secret. That is how `MACOS_CERT_PASSWORD` > once ended up holding the literal text `gh secret set MACOS_CERT_PASSWORD > --org nekko-labs --visibility all`, which surfaced only as `MAC verification > failed during PKCS12 import (wrong password?)` in a release job. Set the two interactive secrets one at a time. In zsh the prompt form is `"VAR?prompt"`, not bash's `-p`: ```bash read -rs "P?p12 password: " && gh secret set MACOS_CERT_PASSWORD --org nekko-labs --visibility all --body "$P" && unset P read -rs "P?issuer UUID: " && gh secret set APPLE_API_ISSUER --org nekko-labs --visibility all --body "$P" && unset P ``` Then delete the export: `rm -P /tmp/nekko-signing.p12`. **Verifying the `.p12` locally is a trap.** Keychain encrypts the payload with `RC2-40-CBC`, which OpenSSL 3 moved to the legacy provider, so `openssl pkcs12 -info` fails with `unsupported ... RC2-40-CBC` and reads like a bad password. Pass `-legacy`. The password check that actually matters is the MAC: a genuinely wrong password says `Mac verify error: invalid password?`, while reaching the cipher error at all means the password was accepted. ```bash openssl pkcs12 -in /tmp/nekko-signing.p12 -info -noout -legacy ``` Look for a **Shrouded Keybag** line, which confirms the private key is in the export. macOS `security import` reads the legacy encryption natively, so no re-export is needed for CI. ### When the App Store Connect key rotates The notary key is the one credential here that can die without any secret changing: an App Store Connect API key gets revoked or rotated in Apple's portal, and `APPLE_API_KEY_ID` / `APPLE_API_KEY_P8` quietly keep pointing at a key that no longer exists. That happened on 2026-09-06, when the team's key was replaced and the old one revoked. Every release from v0.7.1 on then signed fine and died in notarization, five minutes into the job, with: ``` Failed to notarize via notarytool. ... HTTP status code: 401. Unauthenticated. ``` The release workflow now checks the key *before* building ("Validate the notary credentials"), using the same read-only call you can run locally against a candidate `.p8`: ```bash xcrun notarytool history --key AuthKey_XXXXXXXX.p8 --key-id XXXXXXXX --issuer ``` A live key lists past submissions (Nekko Agent's and hypergate's, since they share the team). A revoked one returns the 401 above. The issuer id is per team and does not change when the key does. To rotate, with a key whose App Store Connect role is Developer or above, and after confirming the file really is a private key (`openssl pkey -in AuthKey_XXXXXXXX.p8 -noout` exits 0): ```bash gh secret set APPLE_API_KEY_P8 --org nekko-labs --visibility all < AuthKey_XXXXXXXX.p8 gh secret set APPLE_API_KEY_ID --org nekko-labs --visibility all --body XXXXXXXX ``` Then re-run the Release workflow at the tag that failed: ```bash gh workflow run release.yml --repo nekko-labs/nekko-agent --ref vX.Y.Z ``` The secrets are organization-wide, so one rotation also repairs hypergate and lightwrite. ## Windows Windows installers are signed with **Azure Artifact Signing** (Microsoft's managed code signing, formerly Trusted Signing). A `.p12` is no longer an option: since June 2023 the CA/B Forum requires every new code-signing key to live in a hardware module, so no new Authenticode certificate can be exported to a file for CI. Artifact Signing holds the key, issues short-lived certificates in the validated organization's name, and costs about $10 a month on the Basic tier. electron-builder 26 signs through it natively (`win.azureSignOptions`, which runs the `TrustedSigning` PowerShell module on the runner). The release workflow passes the options as `-c` flags only when `AZURE_SIGNING_CLIENT_ID` is set, so a local `npm run dist` never tries to reach Azure. It signs the app `.exe`, the uninstaller and the NSIS installer, and then "Verify the signed installer" fails the job unless each one has a `Valid` signature from `CN=` with a timestamp. With no client id the build is unsigned (SmartScreen shows "unknown publisher") and the release still ships; a client id without the rest fails before packaging. `AZURE_SIGNING_PUBLISHER` is also written into `app-update.yml`, and electron-updater refuses an update not signed by that publisher. Set it to the certificate's common name exactly (the validated legal name, e.g. `Nekko Labs LLC`) and never change it casually: installs on the old name would stop accepting updates. ### One-time setup (needs Philip: Azure owner + company documents) 1. In the Azure portal, register the `Microsoft.CodeSigning` resource provider on the subscription, then create an **Artifact Signing account** (Basic) in a supported region, e.g. East US (`https://eus.codesigning.azure.net`). 2. **Identity validation → Public Trust → Organization** for Nekko Labs LLC. This is the slow, human step: Microsoft verifies the business (registration documents, a domain email) and usually answers in a few business days. Only a validated identity can back a public certificate. 3. Create a **certificate profile** (type *Public Trust*) on that identity. 4. Create the CI identity and give it signing rights on the account only: ```bash az ad sp create-for-rbac --name nekko-agent-release-signing # note appId (client id), password (client secret), tenant az role assignment create --assignee \ --role "Trusted Signing Certificate Profile Signer" \ --scope ``` (Newer tenants may list it as "Artifact Signing Certificate Profile Signer" after the rename; it is the same role.) 5. Store them on the org. Set the client secret interactively, one command at a time (see the stdin warning under macOS): ```bash gh secret set AZURE_SIGNING_TENANT_ID --org nekko-labs --visibility all --body gh secret set AZURE_SIGNING_CLIENT_ID --org nekko-labs --visibility all --body read -rs "P?client secret: " && gh secret set AZURE_SIGNING_CLIENT_SECRET --org nekko-labs --visibility all --body "$P" && unset P gh variable set AZURE_SIGNING_ENDPOINT --org nekko-labs --visibility all --body https://eus.codesigning.azure.net gh variable set AZURE_SIGNING_ACCOUNT --org nekko-labs --visibility all --body gh variable set AZURE_SIGNING_PROFILE --org nekko-labs --visibility all --body gh variable set AZURE_SIGNING_PUBLISHER --org nekko-labs --visibility all --body "Nekko Labs LLC" ``` 6. Re-run the release at the latest tag (`gh workflow run release.yml --repo nekko-labs/nekko-agent --ref vX.Y.Z`) and check the "Verify the signed installer" step. The client secret expires (two years by default with `create-for-rbac`); when it does, the Windows leg fails in the signing step with an Entra `AADSTS7000222` error. Rotate with `az ad sp credential reset --id ` and re-set `AZURE_SIGNING_CLIENT_SECRET`. SmartScreen reputation: Artifact Signing certificates carry the validated organization name, and reputation accrues to that identity rather than to one certificate, so warnings fade as installs accumulate instead of resetting with every short-lived certificate. ### The installer itself The NSIS installer is **one click**: double-clicking it shows a short branded progress window, installs per user into `%LOCALAPPDATA%\Programs`, adds Start Menu and desktop shortcuts and the Add/Remove Programs entry, and opens the app. There is no UAC prompt and no wizard page. Uninstalling still asks whether to remove chats and settings (`build/installer.nsh`); a silent uninstall, including the one electron-updater runs, keeps them. Certificates expire (Developer ID is 5 years). When it rolls, re-export and re-run the two `MACOS_*` commands; nothing in this repo changes. ## The macOS drag-to-install window The `.dmg` opens to a Finder window with the app on the left, the Applications folder on the right, and a pixel Nekko leaping between them under "Drag the cat into Applications". The art is generated, not drawn by hand: ```bash node apps/desktop/scripts/gen-dmg-art.mjs ``` writes `build/background.png` (640x400) and `build/background@2x.png`, which dmg-builder merges into a HiDPI `.background.tiff`. The icon positions in the `dmg` section of `electron-builder.yml` must match `APP_X` / `APPS_X` / `ICON_Y` in the script. Two Finder facts shaped the layout: icon labels are drawn **black** even in dark mode, so they sit on light sage ledges, and a user who shows Finder's path and status bars loses the bottom ~50px of the window, so the copy is at the top. The `Installers` workflow (`.github/workflows/installers.yml`) builds both installers unsigned on any PR that touches their config, installs and uninstalls the Windows one silently, and checks the disk image has its background, layout and Applications link. ## npm publishing The `publish-cli` job uses npm Trusted Publishing as its primary authentication path. npm's documented requirements are Node `22.14.0` or newer and npm `11.5.1` or newer; the workflow uses Node 24 and installs npm 11.5.1 explicitly. It prints both versions before publishing and disables `setup-node` dependency caching because npm's release guidance says not to use package-manager caching in release builds. Trusted Publishing uses the GitHub Actions OIDC token from `id-token: write`. npm automatically generates provenance attestations for trusted publishes, so the OIDC path does not need `--provenance`. While bootstrapping, `NPM_TOKEN` remains an optional fallback; that path explicitly adds `--provenance`. If neither credential works, the workflow prints a message directing the maintainer to configure the trusted publisher or add `NPM_TOKEN`. ### First publish bootstrap npm's documented setup is under an existing package's settings, and npm does not currently provide a PyPI-style pending publisher for a package name that has never been published. Publish `nekko-agent` once manually with account authentication and 2FA: ```bash npm login npm publish --workspace=apps/cli --access public ``` Then open the `nekko-agent` package settings on npmjs.com and add a GitHub Actions trusted publisher with: | Field | Value | | --- | --- | | Organization or user | `nekko-labs` | | Repository | `nekko-agent` | | Workflow filename | `release.yml` | | Allowed action | `npm publish` | The workflow file is `.github/workflows/release.yml`; npm wants only the filename in this field. After confirming a tagged publish succeeds through OIDC, harden the package's Publishing access settings by requiring 2FA and disallowing token-based publishing, then revoke the temporary `NPM_TOKEN` secret. The release job will continue using OIDC after the token is removed. ## Verifying a release ## Installer and updater targets The release workflow intentionally publishes only targets with a clear installation story: | Platform | Artifact | `electron-updater` support | | --- | --- | --- | | Windows | NSIS `.exe` | ✅ automatic updates | | macOS | `.dmg` plus required `.zip` metadata artifact | ✅ automatic updates | | Linux | AppImage | ✅ automatic updates | | Linux | `.deb` | ✅ supported by current electron-builder updater docs | The Windows MSI and ZIP targets are not built: MSI is not an updater target and the ZIP would duplicate the NSIS installation path without adding an updater benefit. The current electron-builder documentation lists macOS DMG, Windows NSIS, and Linux AppImage/DEB as auto-updatable targets. Unsigned builds can still be installed, but signing warnings are independent of updater support. The release workflow already runs these on every signed macOS build and fails if any of them do. To check a downloaded `.dmg` by hand: ```bash codesign --verify --deep --strict --verbose=2 /Applications/Nekko Agent.app spctl --assess --type execute --verbose=4 /Applications/Nekko Agent.app xcrun stapler validate /Applications/Nekko Agent.app ``` `spctl` should say `accepted` with `source=Notarized Developer ID`. ## Building signed locally Only needed when debugging the signing config itself. ```bash CSC_NAME="Nekko Labs LLC (3HM5598S99)" npm run dist -w @nekko-agent/desktop ``` `CSC_NAME` takes the certificate's **common name without the type prefix**. Passing the full `Developer ID Application: ...` string fails with *"Please remove prefix ... appropriate certificate will be chosen automatically"*. That signs with the local keychain identity but does **not** notarize, so `stapler validate` will fail on the result. That is expected; Gatekeeper on the build machine still accepts it because the machine trusts the developer. --- # Docs for AI agents These docs are written for people and for AI agents. Every page is also published as plain Markdown, and the whole set is available as one file, so an agent can read it without parsing HTML. ## Machine-readable docs | URL | What it is | | --- | --- | | [`/llms.txt`](https://nekkoagent.com/llms.txt) | An index of the site and every docs page, in the [llms.txt](https://llmstxt.org) format | | [`/llms-full.txt`](https://nekkoagent.com/llms-full.txt) | Every docs page in one Markdown file | | `/docs/.md` | Any single page as Markdown, for example [`/docs/cli.md`](https://nekkoagent.com/docs/cli.md) | Each page also links its Markdown version at the top, next to the title. ## Use Nekko Agent from your agent Nekko Agent can be a subagent for Claude Code, Codex, Cursor and other tools: they hand it work, and it runs that work on your local models. Register its MCP server once, for example in Claude Code: ```bash claude mcp add nekko-agent -- node /abs/path/nekko-agent/apps/cli/dist/index.js mcp ``` The tools, the other harnesses and the CLI commands are in [CLI and MCP](https://nekkoagent.com/docs/cli). ## Working on the code An agent changing Nekko Agent should read these files in the repo first: 1. [`AGENTS.md`](https://github.com/nekko-labs/nekko-agent/blob/main/AGENTS.md): the rules for every change and a map of the runbooks (landing a PR, failing checks, visual evidence). 2. [`spec/`](https://github.com/nekko-labs/nekko-agent/blob/main/spec/README.md): what the product does and why, one file per feature with an index. Read the index and the files your change touches, and update them whenever a feature changes. 3. [`TASKS.md`](https://github.com/nekko-labs/nekko-agent/blob/main/TASKS.md): the technical plan and the task list, with stable task IDs. 4. [Contributing](https://nekkoagent.com/docs/contributing) and [Architecture](https://nekkoagent.com/docs/architecture) on this site. ## Editing these docs Each page is rendered from one Markdown file in the repo, listed in `docs/hub/nav.json`. Edit the Markdown; the generated site is not committed, Vercel builds it on every deploy. To look at the result before it ships, `npm run website` builds the docs and serves the site on [http://127.0.0.1:8080](http://127.0.0.1:8080) (set `PORT` to change it), with the same clean URLs as production. ```bash npm run website ``` A change to `nav.json` also changes `apps/website/llms.txt` and `apps/website/sitemap.xml`, which are committed; `npm run docs` updates them, and if you skip it the **Docs site** workflow commits them to the pull request. A pull request that changes user-facing behaviour should also update the page that describes it. ---