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 andjsonl-backup/copies beside it, andhistory/cli_historyunder any.nanobot*state directory are not observed on any real install, source-derived (SQLite session store, Transcript stores).
Source reviews¶
- 2026-10-21: HKUDS/nanobot at
909b277(0.3.5 inpyproject.toml), and theoauth-cli-kit0.1.6 wheel it depends on: the relay, permission, model switch, run record and sidebar cache plans of Parser plan are now in the parser, the run record and sidebar cache shapes are in Other stores, and the GitHub Copilot sign-in is in Credentials and Source host rows. - 2026-10-19: HKUDS/nanobot at
909b277againstacdae3d. Source only. The review added the SQLite session store and its JSONL import (SQLite session store, Session database), the--homeoption, the cron and trigger run records and the WebUI sidebar cache (Other stores), thepairing.jsonshape, the SQLite delete behaviour in What survives a delete, and the permission, model and relay plans of Parser plan.
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:
config.json: providers, channels, MCP servers, agent defaults (Credentials).sessions/<workspace-id>/: one directory per workspace, named by a 32-hex id, holding a.workspacemarker file with the workspace path,<base64url(session key)>.jsonltranscripts,<...>.checkpoint.jsonruntime overlays,.session-files.lockand.migration-conflicts/(nanobot/session/manager.py:58-76,448-477,606-607,777-779,903-927; naming in Transcript stores). Since909b277the directory also holds the session databasesessions.sqlite3with its-waland-shmsidecars and thejsonl-backup/copies (SQLite session store) and the WebUI sidebar cache.webui_session_index.json(WebUI sidebar cache), and thesessions/root holds.workspace-migration.lock(session/location.py:45-48).webui/: WebUI display transcripts<key>.jsonl,<key>.segments/withmanifest.json, legacy<key>.json, plusworkspace-state.json,sidebar-state.json(nanobot/webui/transcript.py:172-188;webui/workspaces.py:45;webui/sidebar_state.py:37).cron/jobs.json(legacy location,nanobot/cli/runtime_config.py:195-196),logs/,media/<channel>/received attachments (config/paths.py:30-48).llm_usage.sqlite3: provider, model and time of every model call (Usage database).pairing.json: approved chat senders (nanobot/pairing/store.py:3,33).auth/xai.json,auth/mcp.json(Credentials),whatsapp-auth/neonize.db,matrix-store/,weixin/,mochat/,linear/state.sqlite3channel state (channels/whatsapp/runtime.py:75;channels/matrix/runtime.py:354;channels/weixin/runtime.py:378;channels/mochat/runtime.py:283;channels/linear/state.py:44).mcp/<name>/working directories for MCP presets (webui/mcp_presets_api.py:563),cli-apps/(apps/cli/service.py:428).cache/tiktoken/,bin/tui/<version>/,run/executables/(Exclusions).
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 serverapiKey(:340), MCP serverenv(:370), among them the GitHub personal access token of the GitHub MCP preset (Source host rows), and channel secrets such as FeishuappSecretandverificationToken(channels/feishu/config.py:18-20) or LinearclientSecret(channels/linear/config.py:22-23). Keys are camelCase viato_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) andauth/mcp.json(MCP OAuth,agent/tools/mcp_oauth.py:102).whatsapp-auth/neonize.dbis a linked-device WhatsApp session;matrix-store/holds Matrix session and encryption state.webui/remote-pairing/<uuid>/identity(an unencrypted OpenSSH private key) andexchange(a raw X25519 private key), the material of the remote WebUI pairing link; secret globs.nanobot*/webui/remote-pairing/*/identityand.nanobot*/webui/remote-pairing/*/exchangefrom collector 1.14.0. Therequest,receiptandssh_routefiles beside them andwebui/remote-instances.jsonstay unflagged. Evidence in Secrets.- No keychain use found.
github-copilot.json, the GitHub OAuth token of the GitHub Copilot model provider (scoperead:user), written bynanobot provider login github-copilotas{access, refresh, expires, account_id}with mode 0600,account_idbeing the GitHubloginof the signed-in account (providers/github_copilot_provider.py:37,193-204;oauth_cli_kit/storage.py:56-70in 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 at909b277{"approved": {<channel>: [<sender_id>...]}, "pending": {<code>: {channel, sender_id, created_at, expires_at, delivery_status}}}, the code anABCD-EFGHvalue 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¶
sessions/<workspace-id>/.workspace: absolute workspace path, one line (session/manager.py:606-607).config.jsonagents.defaults.workspace(config/schema.py:117-120,188-191,428).webui/workspace-state.json(WebUI workspace list; keys not traced).- Per session,
metadata.workspace_scope.project_path(withaccess_moderestrictedorfull): the project folder a WebUI chat was pointed at, which can differ from the workspace in.workspace(security/workspace_access.py:80-84;webui/inbound_commands.py:694). Each WebUI message copies its scope into the session metadata, so the key holds the last one (security/workspace_access.py:145-154). Since909b277it sits in themetadatacolumn ofsessions.sqlite3, sodiscover_projectscannot read it; the analyzer can.
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), socli:directisY2xpOmRpcmVjdA.jsonl. The sibling.workspacefile 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); legacymemory/HISTORY.md..nanobot/webui/<safe key>.jsonland<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>.jsonsnapshot 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~/.nanobotwhatever instance runs unless--homemoves it with the instance root (Per-user storage), so an instance run as--home ~/.nanobot-workwrites.nanobot-work/history/cli_history. Read as a secondary source; the parser selectshistory/cli_historyunder 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-waland-shmsidecars: 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, everysessions/<workspace-id>/*.jsonland<workspace>/sessions/*.jsonlis 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/*.jsonlfiles and.migration-conflicts/are no longer read by nanobot at all (get_legacy_sessions_diris only exported, andmigration-conflictsis not found in nanobot at909b277; grepped forget_legacy_sessions_dirandmigration-conflicts)..nanobot/sessions/<workspace-id>/jsonl-backup/<name>.jsonland<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(andsystemif 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 example2026-10-01T10:15:02.123456(session/manager.py:208-217;agent/loop.py:2430).created_atandupdated_atin 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 namedexec(agent/tools/shell.py:247-248). - Flags:
_hidden_history: truemarks the synthetic user message that stands in for summarised history (session/history_visibility.py:10;session/manager.py:219-238);_runtime_contextmarks injected runtime context on a user message (runtime_context.py:14;loop.py:2428-2429);_command: truemarks 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, at909b277). A switch's reply isSwitched model preset to `<preset>`.followed by- Scope: current session,- Model: `<model id>`,- Context window: <n>and- Max output tokens: <n>lines; a failed switch repliesCould not switch model preset: <reason>, and/modelalone replies## Modelwith- 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.
user:text, optionalmedia_paths[],cli_apps[],mcp_presets[],session_mentions[](:1494-1530), appended when the WebUI channel accepts the message, before the agent turn (webui/inbound_commands.py:658-669).message:text, optionalkind(tool_hint,progress,reasoning),tool_events[],media[],latency_ms(channels/websocket/runtime.py:1185-1227;webui/transcript.py:2245-2278). Nokindis an assistant reply;tool_hintandprogressare breadcrumbs. A tool event is{version, phase, call_id, name, arguments (an object), result, error, files, embeds},phasestartwhen the call is made andendorerrorwhen it returns (utils/progress_events.py:91-102,116-143).stream_endandreasoning_end: the completed text of a streamed answer or reasoning segment intext. Astream_endwithmerge_nextkeeps its buffer, so the nextstream_endrepeats its text (channels/websocket/runtime.py:1273-1297,1338-1377).deltaandreasoning_deltachunks are not written (webui/transcript.py:1229-1247); older files may hold them, folded into the end record atturn_end(:325-362).file_edit:edits[]of{version, call_id, tool, path, absolute_path, phase, added, deleted, approximate, status, binary?, operation?}(channels/websocket/runtime.py:1304-1321;utils/file_edit_events.py:531-558).turn_end(latency_ms,usage,round_usages,context_window_tokens),context_compaction(compaction_id,phase), andfork_marker, the boundary where a forked chat starts (webui/transcript.py:2289-2326,1361-1369).
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>, orunified: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:
.workspacein the same directory. - Tool call to result:
tool_calls[].id=tool_call_id. - Model: by time, against
.nanobot/llm_usage.sqlite3llm_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 metadatakey; its file issessions/<workspace-id>/<base64 of the key>.jsonl, whose.workspacegives the project. Tool calls join oncall_id=tool_calls[].id. Text: the WebUIusertext 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:directunless--sessionnames 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 textScheduled 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), andLocal 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_mstrue 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:
- Langfuse tracing, an LLM-observability service, is opt-in: with the
langfuseextra installed (nanobot plugins enable langfuse) andLANGFUSE_SECRET_KEYset in the environment, the OpenAI-compatible provider's client is swapped for Langfuse's wrapper, which sends each model call's prompt and reply toLANGFUSE_BASE_URL(defaulthttps://cloud.langfuse.com) (providers/openai_compat_provider.py:624-632;webui/settings_capabilities.py:197-205;pyproject.toml:85-86;docs/provider-cookbook.md:529-557). The setting lives only in the process environment, not inconfig.json, and the gateway's systemd unit sets no such variable (gateway/service.py:268-273), so nothing collected says a session was traced; a responder who finds thelangfusepackage in nanobot's Python environment asks the Langfuse project for the traces. Since909b277the Codex provider is wrapped too (providers/openai_codex_provider.py:59;providers/openai_compat_provider.py:595-601). - Remote WebUI pairing connects the local WebUI over SSH to a nanobot gateway on another host the
user runs; that host's sessions stay on it, in its own
.nanobot, and the local side keeps only the server list and the pairing keys (Secrets). "Forget" removes a pairing's files (webui/remote_pairing.py:330-339) and leaves the append-onlyremote-origin-ports.jsonledger (webui/remote_instances.py:105-125). The parser does not read these files; they point the responder at the other host to collect. - The update check is a GET of the PyPI release record on request
(
webui/version_check.py:1-3,19), the GitHub star invitation is local state only (webui/star_prompt.py:1), and the skills marketplace runsnpx skills addwithDISABLE_TELEMETRY=1(webui/skills_marketplace.py:382-388). - A WebUI Temporary Chat runs on a non-persistent session and writes no session file or WebUI
transcript
(
webui/temporary_chats.py:40-44,84-98;session/manager.py:1634-1642), so such a chat leaves no transcript on disk at all.
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-serverin Docker with a personal access token the user pastes, stored inconfig.jsonastools.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 oftools.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 scoperead:user) serves model calls, not repository access; its token and the account's login are ingithub-copilot.jsonoutside.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--configlocation is missed. - Medium: the
thinking_blockselement 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/andmochat/state. - Not determined: whether the Docker image, run without the compose file's
/home/nanobot/.nanobotmount (Docker), keeps its state elsewhere, such as/root/.nanobot. - Not determined: whether subagent runs are persisted anywhere.
- Not determined: the legacy WebUI
<safe key>.jsonsnapshot shape. - Medium: the OAuth token paths outside
.nanobot*, read in theoauth-cli-kit0.1.6 wheel and platformdirs 4.3.6; an install resolves whatever versions satisfyoauth-cli-kit>=0.1.6,<1.0.0.