Skip to content

nanobot

nanobot (catalog agent nanobot) is a small personal-assistant agent with a CLI, a WebUI and a gateway that connects chat channels (Telegram, Discord, Feishu, WhatsApp, Matrix, QQ, WeChat and others under nanobot/channels/); it is open source, written in Python, and not a fork of another agent. It stores each conversation as a JSONL file of OpenAI chat-completions messages behind one metadata line, rewritten whole on every save, and at the 2026-10-19 commit in a SQLite session database (Session database). Parsed: the nanobot parser reads the session database, the session JSONL files, the memory journal, the WebUI transcript, the CLI history, the cron and trigger run records, the WebUI sidebar cache, and the permission settings of config.json and pairing.json.

Source and evidence

HKUDS/nanobot, commit acdae3d0ae2714b6dde672428e921dbf705c096f (2026-10-04, PyPI package nanobot-ai version 0.3.5, pyproject.toml:2-3), MIT (LICENSE), open source, Python. Every claim is source-level unless it cites docs/ in the same checkout.

The session database and the other changes of 2026-10-19 are cited at 909b277. Its pyproject.toml still reads 0.3.5, while the import module names 0.3.6 as the version that introduces the database (session/jsonl_migration.py:1-6), so the database may not be in a release yet.

Observed

  • No real install. The session database sessions/<workspace-id>/sessions.sqlite3, the frozen namespace JSONL and jsonl-backup/ copies beside it, and history/cli_history under any .nanobot* state directory are not observed on any real install, source-derived (SQLite session store, Transcript stores).

Source reviews

Collection

Per-user storage

All paths derive from Path.home(), so the layout is ~/.nanobot on Linux and macOS and %USERPROFILE%\.nanobot on Windows; there is no XDG, Library or AppData branch. The config file is ~/.nanobot/config.json unless --config selects another (nanobot/config/loader.py:20-39), and the instance data directory is the config file's parent (nanobot/config/paths.py:20-27). The documented multi-instance pattern is ~/.nanobot-<name>/config.json with its own workspace (docs/multiple-instances.md:13-20). Settings can also come from NANOBOT_* environment variables (config/schema.py:665-668). Since 909b277 a global --home <dir> option sets the instance root; without it the root is ~/.nanobot, and history/cli_history, the legacy sessions directory and the default workspace follow the chosen root (config/home.py:19-28; config/paths.py:52-72; cli/commands.py:115). The documented form is --home ~/.nanobot-<name> (docs/multiple-instances.md:3,12), which the .nanobot* entry covers; any other directory is missed.

Under the data directory:

Always under ~/.nanobot regardless of --config (under the --home root when it is given): history/cli_history (prompt_toolkit input history), legacy sessions/<safe key>.jsonl, the default workspace/, and launchd logs logs/<name>.launchd.log (config/paths.py:51-71; gateway/service.py:135-139).

Outside the instance root, the two OAuth model providers keep their tokens through the oauth-cli-kit library (a PyPI package with no linked repository; read in its 0.1.6 wheel, sha256 d4a73beae7333ae642a9e5766ab0a04de53279667fefdf064c23db77a3494e7f), whose FileTokenStorage writes <user_data_dir(app_name)>/auth/<token_filename> unless OAUTH_CLI_KIT_TOKEN_PATH names another file (oauth_cli_kit/storage.py:29-38, default app_name oauth-cli-kit at :111). user_data_dir is the platformdirs data directory, ~/.local/share/<app> (or $XDG_DATA_HOME) on Linux, ~/Library/Application Support/<app> on macOS and AppData/Local/<app> on Windows (unix.py:37-45; macos.py:28-30; windows.py:28-36, platformdirs 4.3.6). The GitHub Copilot provider uses app_name nanobot and the file github-copilot.json, so ~/.local/share/nanobot/auth/github-copilot.json on Linux (providers/github_copilot_provider.py:38,53-58); the OpenAI Codex provider keeps the default app_name, so its token is under oauth-cli-kit/auth/ (providers/openai_codex_provider.py:769). The catalog has no line for either directory, so neither token is collected (Credentials).

Scratch directories

The default workspace (~/.nanobot/workspace, or <home>/workspace under --home, config/home.py:24-28), the compose file's api-workspace (Docker), the MCP preset directories mcp/<name>/ and cli-apps/ are the agent's own working directories, not user projects. All are under .nanobot*, so the catalog collects them and the analyzer's catalog rule classes them as scratch directories. No temp root outside the home holds session state: the only tempfile directories are transient staging for skill deletes and marketplace installs and an SSH control-socket directory (nb-ssh-*, webui/remote_mux.py:29).

Workspace

The workspace (default ~/.nanobot/workspace, config/schema.py:117-120) holds the agent's instructions and memory: AGENTS.md, SOUL.md, USER.md (agent/context.py:92,194-202), HEARTBEAT.md (periodic tasks, cli/gateway_runtime.py:629), memory/MEMORY.md, memory/history.jsonl, legacy memory/HISTORY.md, cursors memory/.cursor and .dream_cursor (agent/memory.py:76-91), a dulwich git repository versioning those files (agent/memory.py:89-91; utils/gitstore.py:56), skills/ (agent/skills.py:61), cron/jobs.json (cli/gateway_runtime.py:469) with the run records cron/runs/<run id>.json and the queue cron/action.jsonl (Cron run records), triggers/ with triggers.json, inbox/, processing/, failed/ and runs/ (Trigger run records), .nanobot/workspace-id (session/manager.py:529-534) and .nanobot/tool-results/ spilled tool output (utils/helpers.py:366-368,639). Older releases kept sessions/*.jsonl inside the workspace; they are copied to the data directory and removed (session/manager.py:806-855). A workspace other than the default is a project path (Where the project path is recorded), collected only as a project.

Persistence

Persistence: systemd user unit ~/.config/systemd/user/nanobot-gateway.service or ~/Library/LaunchAgents/ai.nanobot.gateway.plist (gateway/service.py:25,86-87,135-136,227-236). No Windows service.

Docker

The compose file bind-mounts the host's ~/.nanobot at /home/nanobot/.nanobot rather than using a named volume, and the API service's workspace is ~/.nanobot/api-workspace (docker-compose.yml:13-14,48-49), so the home entry collects both and no DOCKER_VOLUMES line is needed.

Credentials

  • config.json: providers.<name>.apiKey (config/schema.py:194-202), the API server apiKey (:340), MCP server env (:370), among them the GitHub personal access token of the GitHub MCP preset (Source host rows), and channel secrets such as Feishu appSecret and verificationToken (channels/feishu/config.py:18-20) or Linear clientSecret (channels/linear/config.py:22-23). Keys are camelCase via to_camel (config_base.py:9-15). ${VAR} references are resolved from the environment (config/loader.py:196).
  • auth/xai.json (xAI OAuth, providers/xai_oauth.py:262) and auth/mcp.json (MCP OAuth, agent/tools/mcp_oauth.py:102).
  • whatsapp-auth/neonize.db is a linked-device WhatsApp session; matrix-store/ holds Matrix session and encryption state.
  • webui/remote-pairing/<uuid>/identity (an unencrypted OpenSSH private key) and exchange (a raw X25519 private key), the material of the remote WebUI pairing link; secret globs .nanobot*/webui/remote-pairing/*/identity and .nanobot*/webui/remote-pairing/*/exchange from collector 1.14.0. The request, receipt and ssh_route files beside them and webui/remote-instances.json stay unflagged. Evidence in Secrets.
  • No keychain use found.
  • github-copilot.json, the GitHub OAuth token of the GitHub Copilot model provider (scope read:user), written by nanobot provider login github-copilot as {access, refresh, expires, account_id} with mode 0600, account_id being the GitHub login of the signed-in account (providers/github_copilot_provider.py:37,193-204; oauth_cli_kit/storage.py:56-70 in the wheel named in Per-user storage), and the OpenAI Codex provider's token beside the library's own data directory. Both live outside .nanobot*, where that section places them, and are not collected.
  • pairing.json, the approved chat senders, is at 909b277 {"approved": {<channel>: [<sender_id>...]}, "pending": {<code>: {channel, sender_id, created_at, expires_at, delivery_status}}}, the code an ABCD-EFGH value that lives ten minutes and only lets an operator approve that sender (pairing/store.py:27-33,73-85,130-138). It is permission evidence, not a credential, and stays unflagged.

Exclusions

bin/tui (downloaded TUI binaries, cli/tui_launcher.py:261), run/executables (:248), cache/tiktoken (utils/token_encoding.py:20). Nothing else is large by design; media/ can grow but is evidence.

Project-local files

A user can point --workspace at a repository, which then receives the workspace files of Per-user storage, notably .nanobot/ and memory/. AGENTS.md is read from the active workspace, SOUL.md and USER.md from the configured one (agent/context.py:194-202). No per-project database.

Where the project path is recorded

Parsing

Transcript stores

Home-relative; .nanobot may be .nanobot-<instance> when --config points elsewhere (nanobot/config/paths.py:20-27).

  • .nanobot/sessions/<workspace-id>/<stem>.jsonl: one file per session key. <workspace-id> is 32 hex characters; <stem> is the URL-safe base64 of the session key without padding (nanobot/session/manager.py:74,903-924), so cli:direct is Y2xpOmRpcmVjdA.jsonl. The sibling .workspace file gives the workspace path (:606-607).
  • Sidecar <stem>.checkpoint.json: volatile mid-turn checkpoint, deleted on the next full save (:60,926-927,1233-1235). Not a transcript.
  • Legacy: .nanobot/sessions/<key with ":" → "_">.jsonl (global, before per-workspace namespaces) and <workspace>/sessions/*.jsonl (older still), both migrated into the namespaced directory (config/paths.py:69-71; session/manager.py:806-855,929-933). Same record format. Conflicting copies are kept in .migration-conflicts/ (:777-779).
  • <workspace>/memory/history.jsonl: compressed long-term journal, lines {cursor, timestamp: "YYYY-MM-DD HH:MM", content, session_key?} (agent/memory.py:311-322); legacy memory/HISTORY.md.
  • .nanobot/webui/<safe key>.jsonl and <safe key>.segments/NNNNNN.jsonl: WebUI display transcript, schema version 3, a rendering mirror of the session (webui/transcript.py:1,31-39,172-188). The directory is the instance's data directory, so .nanobot-<instance>/webui/ for an instance (config/paths.py:20-27,46-48). Read as a secondary source; record shape in Record schema. The legacy <safe key>.json snapshot beside it (webui/transcript.py:186-188) is not read: its shape was not traced.
  • .nanobot/history/cli_history: prompt_toolkit input history, user prompts only (config/paths.py:64-66), always under ~/.nanobot whatever instance runs unless --home moves it with the instance root (Per-user storage), so an instance run as --home ~/.nanobot-work writes .nanobot-work/history/cli_history. Read as a secondary source; the parser selects history/cli_history under any .nanobot* state directory (issue 179).

What survives a delete

In the JSONL layout of the acdae3d commit, /new empties the session file and leaves the WebUI transcript (command/builtin.py:314-346), so the WebUI turns before it survive only there and in the memory journal's summary. Session.clear() in session/manager.py empties the messages and the next save rewrites the file (:374-380), so a /new in a chat erases the previous transcript; only memory/history.jsonl and the WebUI mirror keep a trace. Deleting a WebUI chat deletes the session file and the transcript together (webui/ws_http.py:1250-1251; webui/thread_disk.py:19-31). The CLI history survives /new and any session deletion; nothing in the traced source truncates it.

In the SQLite layout of the 2026-10-19 commit, deleting a WebUI chat removes the session's rows and its child sessions' rows from sessions.sqlite3, its WebUI transcript and, when the user confirms, its scheduled jobs (webui/ws_http.py:1363-1378; session/manager.py:555-574); /new removes the session's messages and leaves the WebUI transcript. Writes (in session/sqlite_store.py) upsert each changed messages row and delete rows past the new length, so a /new or a rewind removes rows rather than rewriting a file (:281-335). Deleting a session is one DELETE FROM sessions, and the foreign keys cascade to its messages, checkpoint and pending inputs (:53-66,394-396). No VACUUM, auto_vacuum or secure_delete is set (not found in nanobot/session or nanobot/llm_usage at 909b277; grepped for VACUUM, auto_vacuum and secure_delete), so a deleted session's pages stay in the file's free list or the WAL until reused. What remains: the memory journal memory/history.jsonl (summaries, not copies of turns); the CLI history history/cli_history (each prompt with its time, no session; nothing truncates it); any frozen namespace JSONL and jsonl-backup/ copy from the database import; the WebUI sidebar cache .webui_session_index.json until it is rebuilt; llm_usage.sqlite3 (model and time per call, no session key); and the run records of scheduled and triggered runs, cron/runs/<run id>.json and triggers/runs/<delivery id>.json, each with the prompt and the reply (triggers/local_store.py:45-50). No deletion marker is written. The analyzer reads the frozen and backup JSONL copies, the WebUI transcript, the CLI history, the sidebar cache and the cron and trigger run records as secondary sources and the memory journal as a primary source of summaries (Past use and secondary sources), and does not read llm_usage.sqlite3 (see Secondary sources). The run records of a workspace outside .nanobot* are collected through the project catalog.

SQLite session store

Since 909b277, parsed (issue 178):

  • .nanobot/sessions/<workspace-id>/sessions.sqlite3, with its -wal and -shm sidecars: the session store of record, mode 0600 (session/location.py:31-35,56; session/sqlite_store.py:95-96; session/manager.py:422-437). Primary. Schema in Session database. On first start, every sessions/<workspace-id>/*.jsonl and <workspace>/sessions/*.jsonl is imported in one transaction (session/sqlite_store.py:157-200, 185-200; session/jsonl_migration.py:155-197). The namespace's own JSONL files are left where they were and are never written again, so after the import they are frozen copies of the sessions as they stood that day; every later turn is only in the database. The global pre-namespace .nanobot/sessions/*.jsonl files and .migration-conflicts/ are no longer read by nanobot at all (get_legacy_sessions_dir is only exported, and migration-conflicts is not found in nanobot at 909b277; grepped for get_legacy_sessions_dir and migration-conflicts).
  • .nanobot/sessions/<workspace-id>/jsonl-backup/<name>.jsonl and <name>.checkpoint.json: the older <workspace>/sessions/ files, copied byte for byte with their modification time kept and then removed from the workspace (session/jsonl_migration.py:32,221-232,235-291). Same record format as the session JSONL; secondary, since every one was imported into the database. The file name is the older workspace form, the key with : as _, so the session id comes from its metadata line.

Record schema

Line 1 is metadata (session/manager.py:1207-1220): {"_type":"metadata", key, created_at, updated_at, metadata, last_archived, last_consolidated}. An optional line 2 is {"_type":"provider_state", state} (:1221-1226). Every other line is one message dict (:1227-1228; loader treats any line without a known _type as a message, :961-987):

  • role: user, assistant, tool (and system if a caller adds one).
  • content: string, or a list of OpenAI content blocks ({type:"text", text}; inline images are replaced on save, agent/loop.py:2409-2427).
  • timestamp: datetime.now().isoformat(), naive local time with no zone, for example 2026-10-01T10:15:02.123456 (session/manager.py:208-217; agent/loop.py:2430). created_at and updated_at in the metadata line are the same form (:177-178).
  • assistant: tool_calls: [{id, type:"function", function:{name, arguments (JSON string)}, extra_content?, provider_specific_fields?}] (providers/base.py:94-112), reasoning_content (thinking text), thinking_blocks (utils/helpers.py:753-771). Empty assistant messages are dropped (loop.py:2391-2392).
  • tool: tool_call_id, name, content (agent/runner.py:541-550). The shell tool is named exec (agent/tools/shell.py:247-248).
  • Flags: _hidden_history: true marks the synthetic user message that stands in for summarised history (session/history_visibility.py:10; session/manager.py:219-238); _runtime_context marks injected runtime context on a user message (runtime_context.py:14; loop.py:2428-2429); _command: true marks the user message and the assistant reply of a slash command such as /model, which skip the model; the user message is the command as typed (/model fast) and the reply is the command's output (agent/loop.py:2143-2155, at 909b277). A switch's reply is Switched model preset to `<preset>`. followed by - Scope: current session, - Model: `<model id>` , - Context window: <n> and - Max output tokens: <n> lines; a failed switch replies Could not switch model preset: <reason>, and /model alone replies ## Model with - Current model: `<id>`  and - Current preset: `<preset>`  (command/builtin.py:384-448).

No model is recorded per message. The session's model preset is metadata._nanobot_model_preset (session/model_selection.py:9); the summary is metadata._last_summary {text, last_active} and the last delivery route metadata.last_channel (session/keys.py:10,38).

Session database

Since 909b277 the transcripts are in SQLite. sessions/<workspace-id>/sessions.sqlite3 is opened in WAL mode (the write-ahead log, a -wal sidecar that holds committed pages not yet copied into the main file) with PRAGMA user_version 1 (session/sqlite_store.py:44-73,174):

Table Columns Holds
sessions key (primary), created_at, updated_at, metadata (JSON text), provider_state, last_archived, revision, generation, title, preview, visible_updated_at the old metadata line, one row per session key; times are datetime.isoformat() of naive local time, as in the JSONL
messages session_key, position, record (JSON text) one message per row in order; record is exactly the old JSONL message line (role, content, timestamp, tool_calls, flags)
checkpoints session_key, record the mid-turn runtime checkpoint, formerly <stem>.checkpoint.json
pending_inputs session_key, kind, position, record follow-up messages queued while a turn runs (kind pending_user_followups)
storage_meta key, value jsonl_import = complete after the import, and the pending workspace-source list

A child session (a subagent or a fork) names its parent in metadata.parent_session_key (session/types.py:8-9; session/sqlite_store.py:404-413).

WebUI display transcript

The WebUI is nanobot's browser chat interface, served by its gateway over a WebSocket channel; its chats are sessions with the key websocket:<chat_id> (webui/session_identity.py:8,17-19). Every transcript append goes through webui_session_key(chat_id) (webui/transcript.py:1274-1281), so only WebUI chats have one. The file stem is SessionManager.safe_key, the key with : replaced by _ and unsafe characters by _ (session/manager.py:899-900; utils/helpers.py:372-374), so websocket:chat-1 is websocket_chat-1.jsonl, unlike the base64 stem of the session file (:903-904).

Layout. Records are appended to the active <safe key>.jsonl (webui/transcript.py:1075-1086,1118-1127). After a turn_end, when the file passes 2 MiB, the older turns move into the next numbered <safe key>.segments/NNNNNN.jsonl and the active file is rewritten with the recent turns only (:35-36,629-668,671-711); segments/manifest.json indexes the segments. The files hold disjoint turns, not copies of each other: nanobot reads the segments in order, then the active file (:1058-1065).

Records. One JSON object per line, event naming it, chat_id, the turn annotations turn_id, turn_phase and turn_seq (:1289-1305), and created_at_ms, set to time.time() in epoch milliseconds when the record is appended, unless the caller gave one (:1089-1106). This is true UTC, unlike every other nanobot time.

A fork writes the target's transcript from the source's rows or from the truncated session messages, user and message only, every record then stamped at the fork (:1319-1358,1372-1407).

CLI input history

nanobot agent reads input through a prompt_toolkit PromptSession with a FileHistory on ~/.nanobot/history/cli_history (cli/terminal.py:86-95,182-188). The format is the one Aider's input history uses (aider.md): an entry is a # <local timestamp> line followed by one +<line> per input line. The timestamp is naive host local time, the same convention as the session files. No session key or workspace is written, and the file is shared by every CLI session.

Memory journal

memory/history.jsonl is not a copy of session events. An entry is a summary the model wrote of archived messages (agent/memory.py:1006-1007) or, when summarising fails, a [RAW] <n> messages block of several formatted messages (:627-647,649-672), stamped to the minute (:311-324).

Joins and timestamps

  • Session: file = metadata key. Keys are <channel>:<chat_id>, or unified:default, heartbeat, cli:direct (session/keys.py:8-9,23-27; cli/agent.py:146). The channel and chat id identify the external sender.
  • Project: .workspace in the same directory.
  • Tool call to result: tool_calls[].id = tool_call_id.
  • Model: by time, against .nanobot/llm_usage.sqlite3 llm_calls.started_at_ms (UTC epoch ms, providers/base.py:1283; llm_usage/store.py:196-218), which also gives the host's clock offset.
  • Subagents: not persisted as separate session files as far as traced.
  • WebUI transcript to session file: session key websocket: + chat_id = the session file's metadata key; its file is sessions/<workspace-id>/<base64 of the key>.jsonl, whose .workspace gives the project. Tool calls join on call_id = tool_calls[].id. Text: the WebUI user text is the typed message, while the session's copy has any runtime context (a quoted reply, an automation template) appended (runtime_context.py:120-145; agent/loop.py:693-699), so those two differ. Time: the session user message is stamped when it is persisted at the start of the turn (agent/loop.py:663-705; session/manager.py:208-217), its assistant and tool messages when the turn is saved at its end (agent/loop.py:2430), all in host local time; the WebUI stamps each record in true UTC as it streams. The two differ by the host's UTC offset (at most 14 hours) plus up to the turn's length.
  • CLI history to session file: text and time only; the CLI's session key is cli:direct unless --session names another (cli/agent.py:146), and the file records neither. The entry is written when the prompt is submitted and the session user message at the start of the turn, both in local time.
  • Run records to session: session_key = the session's key. The session does not store the record's prompt but the text Scheduled cron job triggered: <job name>, a blank line and the job's message for a cron run (cron/bound_runner.py:81-95; cron/session_turns.py:18-24), and Local trigger received: <trigger name, else id>, a blank line and the delivery's content for a trigger run (triggers/local_runner.py:212-214), both rebuildable from the record's fields. The record's times are true UTC epoch milliseconds, the session's local time, so they differ by the host's UTC offset plus the turn's length.
  • Time basis: message and metadata times are naive host local time (Record schema), the WebUI's created_at_ms true UTC; the parser's convention is under Parser plan.

Other stores

The parser reads the WebUI sidebar cache and the cron and trigger run records, not the usage database (Past-use rows and secondary sources). The sidebar cache and the run records are cited at 909b277.

Usage database

llm_usage.sqlite3 (WAL, nanobot/llm_usage/__init__.py:42; store.py:190-222) holds only call metadata: table llm_calls has started_at_ms (UTC epoch milliseconds), provider, model and source per call and no session key (llm_usage/store.py:196-218 at 909b277), so a call joins a session on time alone. It holds no transcript.

WebUI sidebar cache

.nanobot/sessions/<workspace-id>/.webui_session_index.json: a rebuildable cache of the WebUI sidebar, {"version": 8, "sessions": [...]} with one row per WebUI transcript (title, preview, model preset, workspace scope) (webui/session_list_index.py:1-6,39-40,172-204). Secondary; it holds a preview, not turns, and a row can outlive the transcript until the cache is rebuilt. Only the rows of WebUI transcripts that have no row in sessions.sqlite3 are written to the file (:79,198-205), each {key, created_at, updated_at, title, preview, model_preset, recovery_state, _workspace_scope_present, _workspace_scope_value, _source: "webui_transcript", file, mtime_ns, size, webui_activity_mtime_ns, webui_activity_size, webui_activity_files}, title empty, preview the first user text of the transcript, created_at the first record's created_at_ms and updated_at the newest transcript file's modification time, both as naive local isoformat() (:402-413,352-356,416-497). The WebUI directory is shared by the instance's workspaces, so a cached row names a chat, not the workspace of the namespace directory it sits in.

Cron run records

<workspace>/cron/runs/<run id>.json: one audit record per scheduled run, job_id, job_name, session_key, rendered_prompt, status, response or error, run_id, updated_at_ms (UTC epoch milliseconds) (cron/service.py:207-208,472-474; cron/bound_runner.py:98-146; utils/run_records.py:19-29), beside cron/jobs.json, whose state.runHistory[] keeps runAtMs, status, runId and sessionKey (cron/service.py:445-454), and cron/action.jsonl, a queue of {action, params} lines (:673-674). Secondary: the prompt and the reply are also turns of the bound session, and the run record survives the session's deletion. The record is rewritten at each status, queued before the turn and ok (with response) or error (with error) after it, each write setting updated_at_ms to the time of that write; prompt_vars is {message}, the job's message; run_id is <job id>:<epoch milliseconds at the run's start>:<8 hex>, and the file name is the run id with every character other than letters, digits, ., _ and - replaced by _ (cron/bound_runner.py:81,98-146; utils/run_records.py:14-29).

Trigger run records

<workspace>/triggers/: local triggers (triggers.json, each bound to a channel, chat_id and session_key), the delivery queue (inbox/, processing/, failed/, each delivery {id, triggerId, content, createdAtMs, attempts, lastError}) and runs/<delivery id>.json audit records with the content and the response (triggers/local_store.py:45-50,53-90,292-310; triggers/local_types.py:121-158). Secondary, as for the cron runs; the directory exists at acdae3d as well. A run record is {kind: "local_trigger", trigger_id, delivery_id, content, created_at_ms, attempts, last_error?, trigger_name, session_key, channel, chat_id, sender_id, origin_metadata, status, error?, response?, run_id, updated_at_ms}, the trigger's fields present only while the trigger exists, created_at_ms the time the delivery was queued, and content, error, last_error and response cut to 4,000 characters (triggers/local_store.py:24,439-468). Its status is processing when the delivery reaches the agent, then ok with the response, or interrupted, retrying or error with the error (triggers/local_runner.py:55-111,145-166).

Format versions

No version field in session files. The metadata line writes both last_archived and the older last_consolidated for downgrade compatibility (session/manager.py:1215-1218). Legacy file names differ (Transcript stores). At 909b277 the session store moved from JSONL to SQLite (Session database), .migration-conflicts/ and the import of the global pre-namespace sessions/*.jsonl are gone (SQLite session store), and the message, metadata and WebUI record shapes are unchanged; what Session.clear() and a /new erase is under What survives a delete.

Secrets

provider_state.state is opaque provider conversation state. Tool content holds raw tool output. tool_calls[].function.arguments can contain whatever the agent passed to a shell. Credentials themselves are not in session files.

Remote WebUI pairing, nanobot's SSH-based link from a local WebUI to a nanobot gateway on another host the user runs (no vendor relay, webui/remote_pairing.py:1-5), keeps its material under the instance's webui/ directory (webui/ws_http.py:392-394): remote-pairing/<uuid>/identity (an unencrypted OpenSSH private key), exchange (a raw X25519 private key), request, receipt and ssh_route (:208-210,249-251,290-299). remote-instances.json lists the saved remote servers by UUID with name, host (user@host), port, ssh_config, identity_file, config_path, runtime_user, local_port and pair_id (webui/remote_instances.py:28-34,52-53,98-103; webui/remote_ssh.py:32-41), beside remote-origin-ports.json and per-server pinned host keys in remote-hosts/<uuid> (remote_instances.py:70-71,105-125). The catalog collects all of them under .nanobot*, and the two private keys are flagged by the secret globs named in Credentials.

Parser plan

Globs (home-relative): .nanobot*/sessions/*/sessions.sqlite3, .nanobot*/sessions/*/*.jsonl, .nanobot*/sessions/*.jsonl, .nanobot*/sessions/*/.migration-conflicts/*.jsonl, .nanobot*/sessions/*/jsonl-backup/*.jsonl, .nanobot*/sessions/*/.webui_session_index.json, .nanobot*/config.json, .nanobot*/pairing.json, .nanobot*/**/cron/runs/*.json and .nanobot*/**/triggers/runs/*.json, and in a project base cron/runs/*.json and triggers/runs/*.json. Skip *.checkpoint.json. Read .workspace from the namespace directory.

Column Source
timestamp_utc message timestamp (naive local; see below)
session_id metadata key
project_path .workspace file content
git_branch empty
turn_type user → user (_hidden_history → system); assistant → assistant, plus tool_use per tool_calls[], thinking from reasoning_content; tool → tool_result
model empty, or metadata._nanobot_model_preset; in a session with a /model switch, empty before the first switch and the switched preset after each
tool_name tool_calls[].function.name / tool name
tool_use_id tool_calls[].id / tool_call_id
text content text; for tool_use the command/path/url key of the parsed arguments, else the arguments string
source_line line number

Timestamps are host local time (Joins and timestamps). The collector does not record the host zone, so the parser follows the aider parser's documented convention (local time emitted as if UTC, stated in its subsection of the agents page) and lets the analyst correct it; llm_usage.sqlite3 gives true UTC for the same calls to derive the offset.

The primary store and the CLI history location are in the parser (issue 178 and issue 179), and so are the relay, permission, model switch, run record and sidebar cache rows of the subsections below (issue 207).

Permission rows

Source-derived; not observed on any real install. From 909b277 (issue 207), the parser yields one system row per standing setting of config.json at the file's modification time, permission setting: <key>=<value> (from <path>, file mtime), <path> relative to the home (.nanobot/config.json), for tools.restrictToWorkspace, tools.exec.enable, tools.exec.sandbox, each tools.exec.allowPatterns, denyPatterns, sandboxRoBinds, sandboxRwBinds and allowedEnvKeys entry, each tools.ssrfWhitelist entry, tools.webuiAllowLocalServiceAccess (or its legacy allowLocalPreviewAccess), tools.webuiAllowRemotePackageInstall and each channels.<name>.allowFrom entry, a key the file lacks giving no row. nanobot saves the file in camelCase and accepts snake_case as well (config/loader.py:149; config_base.py:12-15; config/schema.py:440-449), so the row names the key as the file spells it; no other key of the file, which holds API keys (Credentials), is read (config/schema.py:428-458; agent/tools/shell_config.py:11-28; channels/base.py:244-257); one per pairing.json approved.<channel>[] sender at the file's modification time, permission setting: pairing.approved.<channel>=<sender_id> (from <path>, file mtime), and one per pending.<code> entry at its created_at (epoch seconds, true UTC), permission setting: pairing.pending.<channel>=<sender_id> expires_at=<time> delivery_status=<status> (from <path>, created_at), the delivery status left out when absent and the code itself never shown (pairing/store.py:1-5,32-33,73-85,133-138); and after the session start row, at its time, permission mode: access_mode=<restricted|full> project=<project_path> (from session metadata workspace_scope) from metadata.workspace_scope, which holds the scope of the last message the WebUI accepted (security/workspace_access.py:11-12,80-84,212-213; webui/inbound_commands.py:694). The key was already written at the Source and evidence commit. Per-call decisions: the agent records none. The exec tool's refusals are tool results such as Error: Command blocked by deny pattern filter (agent/tools/shell.py:825-835), already tool_result rows; no approval prompt exists (not found at 909b277; grepped for approv in nanobot/agent and for ask_user, confirm and permission there, which find only the in-memory goal permission of agent/goal_permission.py).

Model rows

When the metadata line has (issue 96 and issue 97) metadata._nanobot_model_preset, a system row model: <preset> (preset) follows the session start row at its time; the value names a model preset of nanobot's configuration, not a model id, and the row says so. A session has one metadata line, so it names one preset; changes come only from the switch rows below. Per-call models are in llm_usage.sqlite3 llm_calls, which the parser does not read.

A /model <preset> switch is kept in the transcript as a user and assistant message pair flagged _command (shapes in Record schema); the parser keeps both as plain rows and, source-derived and not observed on any real install (issue 207), writes after each reply that starts Switched model preset to `<preset>`  a system row model: <old> -> <new> (preset) requested=<name> resolved=<model id>, <name> from the preceding _command user message /model <name> and <model id> from the reply's - Model: `<id>`  line (command/builtin.py:405-448). The metadata keeps only the last preset, so the first preset of a switched session is not recorded anywhere: in a session with a switch the parser writes no model: <preset> (preset) row at the start, the first switch reads model: <new> (preset) requested=<name> resolved=<model id>, and the model column is empty until it. A failed switch (Could not switch model preset:) and the /model status reply give no row.

Past-use rows and secondary sources

Sources, primary or secondary (issue 122; every one read on every run):

File Role Rows
sessions/<workspace-id>/sessions.sqlite3 primary (issue 178) as above, one session per sessions row in created_at order, its messages.record values in position order, source_line the row's rowid; a value that is not a JSON object is a parser: unparseable record <position> (<reason>): <record> or parser: unparseable session metadata (<reason>): <metadata> row
sessions/<workspace-id>/<stem>.jsonl and jsonl-backup/<name>.jsonl beside a sessions.sqlite3 secondary, tolerance 2 seconds (the same timestamp strings) as above
sessions/<workspace-id>/<stem>.jsonl with no database beside it, legacy and .migration-conflicts/ copies primary as above
<workspace>/memory/history.jsonl primary: summaries, not copies of events one system row per entry, memory history #<cursor>: <content>
webui/<safe key>.jsonl, webui/<safe key>.segments/NNNNNN.jsonl secondary, tolerance 15 hours user; assistant (message with no kind, stream_end without merge_next); tool_use and tool_result per tool event; thinking (opt-in); system for breadcrumbs without tool events, finished file edits, fork_marker, context_compaction and unknown events; nothing for delta, reasoning_delta, turn_end
.nanobot*/history/cli_history secondary, tolerance 2 seconds one user row per entry at its own time, no session or project
<workspace>/cron/runs/<run id>.json secondary, tolerance 15 hours (true UTC) user of the text the session stores for the run (Scheduled cron job triggered: <job_name>, a blank line, prompt_vars.message; else rendered_prompt) at the start time inside run_id; assistant of response and system cron run: job <job_name> (<job_id>) run <run_id> status=<status>, with  error=<error>, at updated_at_ms; session_id session_key, project_path the workspace
<workspace>/triggers/runs/<delivery id>.json secondary, tolerance 15 hours (true UTC) user Local trigger received: <trigger_name or trigger_id>, a blank line, content, at created_at_ms; assistant of response and system trigger run: trigger <trigger_name> (<trigger_id>) delivery <delivery_id> status=<status> attempts=<n>, with  error=<error>, at updated_at_ms
sessions/<workspace-id>/.webui_session_index.json secondary; system rows, which never pair one system row per entry, webui sidebar entry: key=<key> title=<title> preview=<preview> model_preset=<preset> updated_at=<updated_at> (from .webui_session_index.json version <n>), absent fields left out, at created_at, session_id the key, no project

A frozen JSONL keeps the session start row of the import day, whose updated_at differs from the database's, so both start rows are shown; every message the database still holds is shown once, and a turn it lost (a /new, a rewind, a deleted session) is recovered from the copy. The WebUI rows' project_path comes from the session JSONL of the same key, so a WebUI chat whose session exists only in the database has none.

The WebUI rows carry created_at_ms as true UTC, the session file's rows local time as if UTC; the 15-hour window is the largest UTC offset (14 hours) plus an hour of turn, for the assistant and tool messages the session stamps at the turn's end. Known misses, each shown twice: a turn longer than that hour, a forked chat's transcript (every record stamped at the fork), and a user message whose session copy carries appended runtime context. A kept WebUI row keeps its true-UTC time.

Primary store (done): read sessions/<workspace-id>/sessions.sqlite3 through sqlite_util.py (Session database): one session per sessions row, metadata as the old metadata line, messages ordered by position with record mapped as the JSONL message lines are now, and each record that does not decode its own parser: unparseable record <position> (<reason>): <record> row. The namespace's *.jsonl files and jsonl-backup/*.jsonl then become secondary (secondary() true, default 2-second window: the same timestamp strings), since the import copied every one of them; the parser makes them secondary only when the database is beside them, since without it the JSONL is still the store of record. Messages whose session row is gone still yield rows, with no start row. checkpoints (the mid-turn checkpoint) and pending_inputs (queued follow-up prompts) are not read.

CLI history (done): select .nanobot*/history/cli_history, since --home moves it.

Run records and sidebar cache (done, source-derived; not observed on any real install; issue 207): the rows in the table above, from the shapes in Other stores. The run records are selected under any .nanobot* directory (the default workspace, and the state directory for the legacy cron location) and, in a project base the project catalog collected, at its root. The prompt row carries the text the session stores for the turn, rebuilt from the record (Joins and timestamps), so it pairs with the session's copy; a trigger's content or reply longer than the record's 4,000-character limit is cut there, pairs with nothing and is shown twice. The sidebar cache's rows name chats whose session row is gone or never existed, so they always stay.

Sync, relay and origin rows

Sync: nanobot records no sync state, because it uploads no session to a vendor or share service. The session, the WebUI transcript, the memory journal and the CLI history are written only under the home and the workspace (Transcript stores), and the project's own privacy notes say chat history is stored locally and that the model provider sees the prompts (SECURITY.md:208-212). A channel (Telegram, Slack and the other chat services nanobot answers through) keeps its own copy of the messages it carried, on the channel's servers, as the model provider sees the prompts; neither is sync. Searched at the Source and evidence commit: sync, share, upload, cloud, telemetry, analytics, backup, export, remote, every https:// host outside the provider and channel modules, and the WebUI and TUI front ends for analytics libraries (none). The paths found and read, none of which carries session content to a vendor:

Relay: source-derived; not observed on any real install (issue 207). The parser puts the session key and metadata.last_channel in its session start row and, after it at its time, writes session relayed: <channel> chat <chat_id> (from session key) for a key whose channel is a messaging platform, and session relayed: <channel> chat <chat_id> (from metadata.last_channel) when the last route is a messaging platform and differs from the key, as the agent-zero and goose parsers do; records_relay is set. The channel prefixes at 909b277 are dingtalk, discord, email, feishu, linear, matrix, mattermost, mochat, msteams, napcat, qq, signal, slack, telegram, wecom, weixin, whatsapp and whatsapp_agent (each channel's name, for example channels/telegram/runtime.py:589); websocket (the WebUI), cli, api (api/server.py:361), heartbeat, dream: and unified:default are local, though unified:default with a messaging last_channel was relayed (session/keys.py:8-38). Origin: the agent records none; nanobot remote pairs a server with a local WebUI over SSH, "No public WebUI or relay" (cli/remote.py:1,20).

Scratch directory rows

No row needed. The default workspace and the compose file's api-workspace are under .nanobot*, which the catalog rule already classes (Scratch directories); a --home outside the home's .nanobot* is neither collected nor classed.

Deletion rows

No row; the session's rows are gone from the database, and what survives is the secondary sources above; the full list, with its citations, is under What survives a delete.

Bad-line rows

Every JSONL file and the CLI history report each bad line (iter_jsonl and read_input_history); in the database, each messages.record that does not decode is its own parser: unparseable record <position> (<reason>): <record> row and session metadata that does not decode a parser: unparseable session metadata (<reason>): <metadata> row (the sources table under Past use and secondary sources).

Fixtures

Fixture, file .nanobot/sessions/0123456789abcdef0123456789abcdef/dGVsZWdyYW06MTIzNDU2Nzg5.jsonl, with .workspace containing /home/alice/.nanobot/workspace:

{"_type":"metadata","key":"telegram:123456789","created_at":"2026-10-01T10:15:00.000001","updated_at":"2026-10-01T10:15:09.500000","metadata":{"last_channel":"telegram:123456789"},"last_archived":0,"last_consolidated":0}
{"role":"user","content":"what is using port 8080?","timestamp":"2026-10-01T10:15:01.200000"}
{"role":"assistant","content":"","tool_calls":[{"id":"call_abc123","type":"function","function":{"name":"exec","arguments":"{\"command\": \"ss -ltnp | grep 8080\"}"}}],"reasoning_content":"Check listening sockets.","timestamp":"2026-10-01T10:15:04.000000"}
{"role":"tool","tool_call_id":"call_abc123","name":"exec","content":"LISTEN 0 128 *:8080 users:((\"python3\",pid=4242))","timestamp":"2026-10-01T10:15:05.000000"}
{"role":"assistant","content":"python3 (pid 4242) is listening on 8080.","timestamp":"2026-10-01T10:15:09.400000"}

A /model switch in a session, from command/builtin.py:435-448 and agent/loop.py:2143-2155:

{"role":"user","content":"/model fast","_command":true,"timestamp":"2026-10-04T08:01:00"}
{"role":"assistant","content":"Switched model preset to `fast`.\n- Scope: current session\n- Model: `gpt-5.4-mini`\n- Context window: 128000\n- Max output tokens: 8192","_command":true,"timestamp":"2026-10-04T08:01:00"}

pairing.json, from pairing/store.py:73-85 and 130-160:

{"approved": {"telegram": ["123456789"]}, "pending": {"ABCD-EFGH": {"channel": "discord", "sender_id": "999", "created_at": 1791187200.5, "expires_at": 1791187800.5, "delivery_status": "sent"}}}

A cron run record cron/runs/job1_1791187200000_ab12cd34.json, from cron/bound_runner.py:98-146 and utils/run_records.py:19-29:

{"job_id": "job1", "job_name": "morning digest", "session_key": "slack:C42", "prompt_ref": "x", "prompt_vars": {"message": "summarise the inbox"}, "rendered_prompt": "Reminder: summarise the inbox", "status": "ok", "response": "Three new mails.", "run_id": "job1:1791187200000:ab12cd34", "updated_at_ms": 1791187205000}

Source host rows

The agent records a source-host token but no connected account, so the parser yields no source host: row. Two GitHub credentials exist, neither naming the account in a collected file:

  • The GitHub MCP preset of the WebUI runs ghcr.io/github/github-mcp-server in Docker with a personal access token the user pastes, stored in config.json as tools.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN (webui/mcp_presets.json:331-367; webui/mcp_presets_api.py:1086-1088); the collector flags the whole file (Credentials), and the parser reads no key of tools.mcpServers. The token's account and the repositories it reaches are server-side only.
  • The GitHub Copilot model provider's sign-in (nanobot provider login github-copilot, a device-code OAuth flow with scope read:user) serves model calls, not repository access; its token and the account's login are in github-copilot.json outside .nanobot*, which the catalog does not collect (Credentials, Per-user storage).

The bundled github skill drives the user's own gh CLI and its login (skills/github/SKILL.md:1-10), which is the GitHub CLI's state, not nanobot's. No repository list is cached and no repository-bound remote conversation exists: grepped nanobot/ at 909b277 for github, gitlab, bitbucket, oauth, installation, repositor, pull request and clone; GitLab and Bitbucket appear nowhere, and installation and oauth also find the Linear channel's OAuth app, an issue tracker, and the MCP and xAI OAuth stores of Credentials.

Confidence

  • High (source): every path in Per-user storage to Where the project path is recorded, read from constants in the cited files.
  • High (source): file naming, metadata and message field names, naive local timestamps and the tool call shape of Record schema; the WebUI transcript record shape and layout.
  • Medium: that matrix-store/ holds credentials (inferred from its role; not read).
  • Medium: .nanobot-* as an instance naming convention comes from docs, and any other --config location is missed.
  • Medium: the thinking_blocks element shape is provider-defined.
  • Medium: that the fixture's WebUI pairing holds on a real install, since no WebUI transcript or CLI history was observed.
  • Not determined: the contents of webui/workspace-state.json, weixin/ and mochat/ state.
  • Not determined: whether the Docker image, run without the compose file's /home/nanobot/.nanobot mount (Docker), keeps its state elsewhere, such as /root/.nanobot.
  • Not determined: whether subagent runs are persisted anywhere.
  • Not determined: the legacy WebUI <safe key>.json snapshot shape.
  • Medium: the OAuth token paths outside .nanobot*, read in the oauth-cli-kit 0.1.6 wheel and platformdirs 4.3.6; an install resolves whatever versions satisfy oauth-cli-kit>=0.1.6,<1.0.0.