openhands¶
OpenHands is an open-source coding agent, now split across several MIT-licensed repositories, all
open source; All-Hands-AI/OpenHands redirects to OpenHands/OpenHands. Its surfaces are Agent
Canvas (a React front end with a local-stack launcher, also shipped as an Electron app), the Agent
Server and the SDK behind it, the openhands terminal CLI, and the pre-Canvas Python app, whose
legacy layouts share ~/.openhands. Catalog agent openhands. Current OpenHands (Agent Canvas and
the openhands CLI) persists conversations through the Software Agent SDK as one JSON file per
event; the legacy 1.x and 0.x apps used other layouts (Format versions). Parsed:
the openhands parser reads the SDK event files with their meta.json and base_state.json.
Source and evidence¶
- OpenHands/OpenHands at
a6bba78ffd5a8b31620770f52383b1a2c0477fcd(2026-10-03): now only Agent Canvas, the React frontend plus the local-stack launcher; backend, conversations and events belong to the SDK (AGENTS.md:31-35). - OpenHands/software-agent-sdk at
b347047e2dcdd8f4b2be810aa8bc6632bc756d41: the SDK and the Agent Server, which owns every file under~/.openhandsthat matters. - OpenHands/OpenHands-CLI at
954f2ba646e8d749261a8f2b2b7e3031fa39be9f: theopenhandsterminal CLI, "no longer actively maintained" (README.md:31-35) but still installed in the field. - The pre-Canvas Python app, read at tags
1.11.0(11ca68ab2e15dcd85c21e4d7d3409e7a259369ac) and0.62.0(7fbb48c40679afd674970966b96185657d92a487), for legacy layouts that share~/.openhands.
All claims are from source.
Observed¶
- No real install.
Source reviews¶
- 2026-10-21: OpenHands/software-agent-sdk at
33d8aa2, OpenHands/OpenHands-CLI at0403f1aand OpenHands/OpenHands at8d39305: the Parser plan now describes the permission, model, prompt-history and scratch rows the parser writes from source, themars-credentials.jsonsecret globs and the.openhands/workspacesexclusion are in the tables, and Source host rows states the GitHub repository search and its token. - 2026-10-19: SDK at
33d8aa2(previouslyb347047andfd26808), Agent Canvas at8d39305(previouslya6bba78), CLI at0403f1a(previously954f2ba; unchanged since 2026-10-09), from source with no install. It added the permission and model records of Record schema, the Agent Canvas browser storage of Other stores, the DigitalOcean credential store, the scratch directories and the delete behaviour, and corrected an earlier statement that the Canvas front end is not in these repositories. - 2026-10-17: SDK at
fd26808and CLI at0403f1a, for sync and upload. It added the finding of Sync, relay and origin rows that nothing local records a synced conversation.
Collection¶
Per-user storage¶
One root, ~/.openhands, on every OS: Python Path.home() / ".openhands"
(openhands-sdk/openhands/sdk/utils/path.py:32-59)
and Node homedir()
(scripts/dev-safe.mjs:80-102),
so %USERPROFILE%\.openhands on Windows. OH_PERSISTENCE_DIR replaces it (SDK); the CLI uses
OPENHANDS_PERSISTENCE_DIR and OPENHANDS_CONVERSATIONS_DIR
(openhands_cli/locations.py:5-23);
the 1.x app also honoured FILE_STORE_PATH
(openhands/app_server/config.py:75-89).
Scratch directories¶
OpenHands has three, all chosen by the agent rather than the user:
~/.openhands/agent-canvas/workspaces/<conversation hex>/, the working directory of a Canvas conversation opened on no project (scripts/dev-safe.mjs:648-649,703). It also holds automation run workspaces when Canvas runs natively (dev-safe.mjs:649;scripts/dev-with-automation.mjs:1064-1069;src/api/agent-server-config.ts:203-211). Excluded by default (Exclusions).~/.openhands/workspaces/<run>/, automation runs under Canvas-in-Docker (Automation workspaces). Excluded by default (Exclusions)./tmp/conversation-worktrees/<dir>/, the git worktree the Agent Server makes for a conversation started with worktree isolation (openhands-agent-server/openhands/agent_server/config.py:286-293; the same default atb347047:config.py:256-281). Outside the home, so no catalog line can collect it; the conversation records it asworkspace.working_dir(Where the project path is recorded), and Canvas records the choice in its browser storage (Other stores).
The first two are under the .openhands catalog entry, so the analyzer's catalog rule classes them
as scratch directories; the third is not.
Agent Canvas¶
Agent Canvas (agent-canvas, npm install -g @openhands/agent-canvas, or the Electron app) runs
the Agent Server with OH_PERSISTENCE_DIR=~/.openhands and its own state in
~/.openhands/agent-canvas (OH_CANVAS_SAFE_STATE_DIR)
(scripts/dev-safe.mjs:643-703,
817-821;
bin/agent-canvas.mjs:157-166):
agent-canvas/dev_conversations/<conversation hex>/when run natively,agent-canvas/conversations/<hex>/when Canvas runs in its Docker image (docker/entrypoint.sh:174-178;config/defaults.json:23-26). Each holdsmeta.json,base_state.json,events/event-NNNNN-<id>.jsonandbash_events/(Where the project path is recorded, and Transcript stores).agent-canvas/bash_events/: commands typed in the Canvas terminal, one file per event named<YYYYMMDDHHMMSSffffff>_<kind>_...(openhands-agent-server/openhands/agent_server/bash_service.py:61-72).agent-canvas/workspaces/<conversation hex>/: the scratch working directories of Scratch directories (dev-safe.mjs:703).agent-canvas/logs/agent-canvas.%DATE%.log(scripts/logger.mjs:23-26,64);agent-canvas/storage/(dev-with-automation.mjs:600-608);agent-canvas/tmux/sockets (dev-safe.mjs:682-698).automation/automations.db, SQLite, the automation service's schedules and run history (config/defaults.json:26;dev-with-automation.mjs:1047-1048).
Agent Server stores¶
Directly under ~/.openhands
(openhands-agent-server/openhands/agent_server/persistence/store.py:802-822):
settings.json
(store.py:306-314),
secrets.json
(444-450),
workspaces.json
(725-742),
provider-connections/provider_connections.json
(895-913;
openhands-sdk/openhands/sdk/llm/provider_connection_store.py:49-50),
profiles/<name>.json LLM profiles
(openhands-sdk/openhands/sdk/llm/llm_profile_store.py:28),
agent-profiles/
(openhands-sdk/openhands/sdk/profiles/agent_profile_store.py:29),
auth/
(openhands-sdk/openhands/sdk/llm/auth/credentials.py:21-28),
memory/MEMORY.md
(openhands-sdk/openhands/sdk/context/memory.py:23),
skills/, skills/installed/, microagents/
(openhands-agent-server/openhands/agent_server/skills_service.py:6-10),
plugins/installed/
(openhands-sdk/openhands/sdk/plugin/installed.py:23),
cache/skills/, cache/plugins/
(openhands-sdk/openhands/sdk/skills/fetch.py:20;
openhands-sdk/openhands/sdk/plugin/fetch.py:20).
Docker and Podman volume¶
In a Docker or Podman volume the catalog's openhands|*openhands* line matches, the LLM profiles
profiles/<name>.json are flagged by the volume-relative glob profiles/*.json
(openhands-sdk/openhands/sdk/llm/llm_profile_store.py:28).
A save writes the profile first to a temporary profiles/tmp<random>.tmp and then renames it, so
an interrupted save leaves that file holding the profile; profiles/tmp*.tmp flags it
(llm_profile_store.py:240-249).
Docker runtime¶
The Docker runtime (OH_CONVERSATION_RUNTIME=docker, one container per conversation,
README.md:107-117)
keeps runtime-control/<hex>.json (per-conversation identity) and runtime-data/<hex>/persistence/
under ~/.openhands
(openhands-agent-server/openhands/agent_server/docker_runtime/provisioning.py:54-68).
The container gets the host conversation directory, that persistence directory (as its HOME) and
the host workspace as bind mounts
(docker_runtime/registry.py:40-42,354-404),
so transcripts and the container user's dotfiles land on the host. Only what the agent writes
outside those mounts (container /tmp, packages it installs) stays in the container and is lost
with it. Canvas-in-Docker likewise bind-mounts host ~/.openhands to /home/openhands/.openhands
(README.md:93-101).
Git worktrees go outside the home (Scratch directories).
Electron app¶
Product name OpenHands Agent Canvas
(electron/package.json:3),
so Electron's default user data directory (~/Library/Application Support/OpenHands Agent Canvas,
~/.config/OpenHands Agent Canvas, %APPDATA%\OpenHands Agent Canvas) holds the frontend's
localStorage; the backend state is the same ~/.openhands. At Canvas
8d39305
it also holds mars-credentials.json, the store of DigitalOcean connections for Managed Agents
sessions (MARS, DigitalOcean's name for its hosted agent sandboxes: a microVM that runs its own
Agent Server with its own disk and conversations, so those conversations stay on DigitalOcean;
the file's fields are in Credentials)
(electron/main.mjs:773-777;
src/api/mars/AGENTS.md:3).
The localStorage keys that matter (openhands-backends, openhands-last-conversation-by-backend,
openhands-agent-server-conversation-metadata, archived-conversations) are listed in
Other stores.
Automation workspaces¶
The automation service unpacks each run into AUTOMATION_WORKSPACE_BASE: ~/.openhands/workspaces
under Canvas-in-Docker, ~/.openhands/agent-canvas/workspaces natively
(docker/entrypoint.sh:334-336;
scripts/dev-with-automation.mjs:1066-1075).
The Docker path is the same at
a6bba78.
CLI¶
Files of the CLI
(openhands_cli/locations.py:5-54):
conversations/<hex>/ (SDK layout, plus TASKS.json,
tui/panels/plan_side_panel.py:107),
projects/<sha256 of cwd>/prompt_history.json, agent_settings.json, mcp.json, cli_config.json
(stores/cli_settings.py:60-63),
cloud/api_key.txt, cache/acp/
(acp_impl/utils/resources.py:43),
hooks.json
(acp_impl/agent/local_agent.py:180).
openhands serve runs the legacy app image with ~/.openhands mounted at /.openhands
(gui_launcher.py:125-140).
Legacy app¶
The legacy app uses the same root. 1.x: openhands.db SQLite
(openhands/app_server/services/db_session_injector.py:192),
[<user_id>/]v1_conversations/<hex>/<event hex>.json
(conversation_paths.py:12,38-55;
event/event_service_base.py:89),
settings.json, secrets.json
(settings/file_settings_store.py:15;
secrets/file_secrets_store.py:15),
analytics_id.txt
(analytics/oss_install_id.py:21).
0.x: sessions/<sid>/ with events/<n>.json, event_cache/<start>-<end>.json, metadata.json,
init.json, agent_state.pkl, conversation_stats.pkl, llm_registry.json, or
users/<id>/conversations/<sid>/
(openhands/storage/locations.py:1-42;
openhands/events/event_store.py:159);
default root ~/.openhands
(openhands/core/config/openhands_config.py:73).
Paths inside the sandbox¶
Paths inside the sandbox and not on the host: in the 0.x app, the runtime container's /workspace
unless SANDBOX_VOLUMES mounted a host directory
(gui_launcher.py:161-170);
in Canvas-in-Docker, everything except ~/.openhands and PROJECTS_PATH.
Credentials¶
~/.openhands/agent-canvas/secret-key.txt:OH_SECRET_KEY, the master key (dev-safe.mjs:90-102;docker/entrypoint.sh:185-196). Secret fields insettings.json,secrets.json,profiles/*.json,meta.jsonandbase_state.jsonare Fernet tokens keyed by SHA-256 of it (openhands-sdk/openhands/sdk/utils/cipher.py:73-80;pydantic_secrets.py:48-78), and are written in plaintext when no key is configured (store.py:372-383). Collecting the key with the stores makes every secret readable.~/.openhands/agent-canvas/api-key.txt: the local backend session key (dev-safe.mjs:80-85).provider-connections/*andauth/<vendor>_oauth.json, beside the stores of the first bullet (credentials.py:73-75).runtime-control/<hex>.json: per-conversationapi_keyandencryption_key, themselves encrypted with the master key (provisioning.py:22-45).- CLI:
agent_settings.jsonsaved withexpose_secrets: True, so the LLM API key is plaintext (stores/agent_store.py:474-476);cloud/api_key.txt, the OpenHands Cloud account key in plain text (mode 0600), written byopenhands loginand read byopenhands cloudandopenhands acp --cloud(auth/token_storage.py:19-25;19-37at0403f1a), flagged by the.openhands/cloud/*secret glob;mcp.jsonholds MCP server definitions, which may embed keys; it is flagged. - Legacy:
.jwt_secretand.keysin the root (openhands/app_server/utils/encryption_key.py:51-57;openhands/core/config/utils.py:35). - A conversation driving Codex through ACP reads
~/.codex/auth.json(openhands-sdk/openhands/sdk/agent/acp_file_credentials.py:32,65-68); older conversations may holdacp/codex/auth.jsonin their own directory (event_service.py:338-339), flagged by.openhands/*/acp/codex/auth.jsonand, in a volume,conversations/*/acp/codex/auth.jsonandagent-canvas/*/acp/codex/auth.json. - Agent Canvas Electron app:
<user data directory>/mars-credentials.json, the DigitalOcean connections (connections[]withkindoauthorpat,label,teamName,expiresAtand the token, and adeviceId); only the token is encrypted, with ElectronsafeStorage(the OS keychain), and is written asnullwhen the keychain is unavailable, so the connection then lasts only for the session; the file is mode 0600 (scripts/mars-credentials.mjs:18,42-49,77-88,118-135,195-210). Flagged by the three secret globsLibrary/Application Support/OpenHands Agent Canvas/mars-credentials.json,.config/OpenHands Agent Canvas/mars-credentials.jsonandAppData/Roaming/OpenHands Agent Canvas/mars-credentials.json. - A GitHub, GitLab or Bitbucket token the Agent Server uses to clone and to search the user's
repositories is a custom secret in
secrets.json(custom_secrets.<name>.secret, under one of the namesgithub_token,GITHUB_TOKEN,GH_TOKENorgithubfor the search), encrypted like the other secret fields of the first bullet (Source host rows). - The Canvas
Local Storageholds each backend's API key in plain JSON (Secrets).
No keychain use found, apart from the safeStorage encryption above.
Exclusions¶
.openhands/cache (git clones of skill and plugin sources), and
.openhands/agent-canvas/workspaces, which can hold whole repositories the agent cloned. It is also
the agent's work product for scratch conversations, so it is excluded by default like
.claude/worktrees and .codex/worktrees, recorded as skipped_excluded with its size, and
collected with --full. .openhands/agent-canvas/tmux holds sockets only. Keep bash_events,
runtime-data (container HOME, may hold shell history) and logs.
.openhands/workspaces (automation run workspaces under Canvas-in-Docker,
Per-user storage) holds the same kind of content as agent-canvas/workspaces and is
excluded the same way; workspaces.json beside it is a file of its own and is collected.
Project-local files¶
<project>/.openhands/: hooks.json
(openhands-agent-server/openhands/agent_server/hooks_service.py:36),
skills/, microagents/, memory/MEMORY.md, setup.sh, pre-commit.sh
(skills_service.py:6-10;
memory.py:23;
openhands/app_server/app_conversation/app_conversation_service_base.py:66-74).
Also read: .agents/skills/
(app_conversation_router.py:1364),
AGENTS.md, .cursorrules (skills service, above). No per-project database.
Where the project path is recorded¶
workspaces.jsonworkspaces[].pathandworkspaceParents[].path(openhands-agent-server/openhands/agent_server/persistence/models.py:615-636).- Each conversation's
meta.jsonandbase_state.json:workspace.working_dir(openhands-sdk/openhands/sdk/conversation/request.py:105;openhands-sdk/openhands/sdk/workspace/base.py:43). Under Canvas-in-Docker these are container paths (/projects/<name>), not host paths. - CLI
projects/<sha256>/names are hashes of the path (locations.py:34-42); not reversible, but checkable against a candidate path; the analyzer checks each conversation'sworkspace.working_dir(Past-use rows and secondary sources). - Agent Canvas
localStorage(ElectronLocal Storage)openhands-agent-server-conversation-metadata: per conversation id,selected_workspace(the folder the user attached),selected_repository,selected_branchandworkspace_mode(local_repoornew_worktree) (src/api/conversation-metadata-store.ts:4-56). LevelDB, so not grep-able bydiscover_projects. - A worktree conversation's
workspace.working_diris under/tmp/conversation-worktrees/by default, a scratch directory (Scratch directories), not the user's project. Whether the conversation files also name the repository the worktree was made from was not traced.
Parsing¶
Transcript stores¶
One directory per conversation, named by the conversation UUID as 32 hex digits
(openhands-sdk/openhands/sdk/conversation/base.py:331-345),
under:
.openhands/agent-canvas/dev_conversations/<hex>/(Canvas, native) and.openhands/agent-canvas/conversations/<hex>/(Canvas in Docker);.openhands/conversations/<hex>/(CLI).
Inside
(conversation/persistence_const.py:4-10):
events/event-<idx, 5+ digits>-<event id>.json, one compact JSON object per event (conversation/event_store.py:225-232,313-318), plus an empty.eventlog-len-<n>.marker(event_store.py:27).base_state.json:ConversationState(agent with its LLM config, workspace, stats, secret registry) (conversation/state.py:82-225,435-447).meta.json(Agent Server only):StoredConversation(openhands-agent-server/openhands/agent_server/event_service.py:276-297).bash_events/for terminal sessions bound to the conversation (agent_server/dependencies.py:72-80); the global.openhands/agent-canvas/bash_events/has the same files.TASKS.json(CLI plan panel,openhands_cli/tui/panels/plan_side_panel.py:107).
The CLI also keeps .openhands/projects/<sha256(cwd)>/prompt_history.json
(openhands_cli/locations.py:34-47),
described in CLI prompt history.
What survives a delete¶
The Agent Server's delete, which Agent Canvas uses, notifies webhooks with status DELETING and
removes the conversation directory and, once no other conversation uses it, the worktree directory;
the workspace and the worktree's branches in the user's repository stay, and children and forks keep
their now dangling parent_conversation_id or forked_from_conversation_id
(agent_server/conversation_service.py:1774-1838;
local_storage.py:26-37;
the same delete at SDK
fd26808:
agent_server/conversation_service.py:1774-1838).
Canvas also deletes the hidden planning conversation and the metadata entry, and for a cloud backend
the remote conversation; its archive only adds the id to archived-conversations. The CLI has no
delete; openhands logout removes cloud/api_key.txt
(auth/token_storage.py:55-61).
What remains: the CLI's prompt_history.json, the last 100 prompts per working directory with text
and local time and no conversation (CLI prompt history); a fork, which is a
deep copy of its source's events and state
(agent_server/conversation_service.py:1952-1995)
and joins on meta.json forked_from_conversation_id;
the global agent-canvas/bash_events/ record of terminal commands; and Canvas browser-storage keys
with no message text, such as openhands-last-conversation-by-backend
(Other stores). Nothing local records a deleted conversation. The analyzer reads a
fork as its own conversation and the prompt history as a secondary source (see
Secondary sources), so a deleted CLI
conversation's prompts are still in the timeline; it does not read the global terminal record or
the browser storage (Parser plan).
Record schema¶
Every event has kind (the Python class name,
openhands-sdk/openhands/sdk/utils/models.py:202-205),
id (UUID string), timestamp, source in agent|user|environment|hook, parent_id
(event/base.py:24-40;
event/types.py:4-5).
timestamp is datetime.now().isoformat(): naive local time with microseconds and no zone
(base.py:28-31).
The parser needs the host's zone to convert it; meta.json created_at/updated_at are UTC with
offset
(agent_server/models.py:120-121;
agent_server/utils.py:53-55)
and can anchor the offset. Files are written with exclude_none.
Kinds:
MessageEvent:sourceuser or agent,llm_message(roleuser|system|assistant|tool,contentlist of{type:"text",text}or{type:"image",image_urls},tool_calls?,reasoning_content?,thinking_blocks?),activated_skills,extended_content,sender(event/llm_convertible/message.py:25-60;llm/message.py:174-176,205-207,219-245).ActionEvent:thought(text list),reasoning_content,thinking_blocks({type:"thinking",thinking,signature}orredacted_thinking),action(object with its ownkind, for exampleTerminalActionwithcommand),tool_name,tool_call_id,tool_call(id,name,argumentsJSON string,origin),llm_response_id,security_risk,summary(event/llm_convertible/action.py:24-80;llm/message.py:25-40,123-147;openhands-tools/openhands/tools/terminal/definition.py:86-89). Tool names are the snake-cased class name without_tool, soterminal,file_editor(tool/tool.py:397).ObservationEvent:tool_name,tool_call_id,action_id,observation(ownkind,contentlist,is_error;TerminalObservationaddscommand,exit_code) (event/llm_convertible/observation.py:17-40;tool/schema.py:367-382;terminal/definition.py:146-152).UserRejectObservation(rejection_reason,rejection_source,action_id) andAgentErrorEvent(error,classification) (observation.py:86-147).SystemPromptEvent:system_prompt,tools,dynamic_context(event/llm_convertible/system.py:12-35).ACPToolCallEvent(Claude Code, Codex, Gemini driven over ACP):tool_call_id,title,status,tool_kind,raw_input,raw_output,content,is_error(event/acp_tool_call.py:46-65).HookExecutionEvent(hook_event_type,hook_command,exit_code,stdout,stderr,blocked;event/hook_execution.py:22-80),CondensationandCondensationSummaryEvent(summary;event/condenser.py:11-37,120-126),ConversationStateUpdateEvent(key,value;event/conversation_state.py:18-35),PauseEvent,InterruptEvent(event/user_action.py:7-32).
meta.json: id, title, created_at, updated_at, forked_from_conversation_id, workspace
(kind, working_dir), parent_conversation_id, secrets, initial_message
(agent_server/models.py:102-143;
conversation/request.py:93-156).
base_state.json: id, agent.llm.model
(llm/llm.py:272;
agent/base.py:117),
workspace.working_dir
(workspace/base.py:43),
execution_status, stats.usage_to_metrics.
Bash events (bash_events/<YYYYMMDDHHMMSSffffff>_<kind>_[<command id>_]<event id>, no extension,
indented JSON;
agent_server/bash_service.py:61-85):
BashCommand (id, timestamp UTC, command, cwd), BashOutput (command_id, order,
exit_code, stdout, stderr), BashError
(agent_server/models.py:616-660).
Permission records¶
At SDK
33d8aa2:
SecurityAnalysisEvent(new sinceb347047;sourceenvironment):analyzer(the analyzer's class name, for exampleLLMSecurityAnalyzer),policy(the confirmation policy's class name:AlwaysConfirm,NeverConfirmorConfirmRisky),risks(a map fromActionEventid toUNKNOWN,LOW,MEDIUMorHIGH) anddetails(analyzer-specific detail per action id) (event/security.py:11-39). It is written once per batch of pending actions whenever an analyzer is configured, before the policy decides whether to ask (agent/agent.py:1130-1140,1164-1190).- An approval writes no event: the Agent Server's
ConfirmationResponseRequest(accept,reason) resumes the run onacceptand callsreject_pending_actionsotherwise (agent_server/models.py:438-442;agent_server/event_service.py:2015-2029), so an approved action shows only as anActionEventfollowed by itsObservationEvent. APreToolUsehook's block is aUserRejectObservationwithrejection_sourcehook(agent/agent.py:360-368). base_state.jsonconfirmation_policy(kindAlwaysConfirm,NeverConfirmorConfirmRiskywiththreshold),security_analyzer(kind, or absent) andblocked_actions(action id to the hook's reason) (conversation/state.py:123-127,152-157;security/confirmation_policy.py:27-61). A field assignment rewrites the file (state.py:596-610), so it holds the policy in force at the last write, not its history. The CLI's "always proceed" and "risk based" answers setNeverConfirmorConfirmRiskyon the conversation through the same call (tui/core/confirmation_flow_controller.py:38-48;tui/core/confirmation_policy_service.py:25-29;acp_impl/confirmation.py:116-128), and--always-approveor headless mode starts it atNeverConfirm(tui/textual_app.py:729-735). The Agent Server's default for new conversations issettings.jsonconversation_settings.confirmation_mode(defaultfalse) andsecurity_analyzer(defaultllm) (sdk/settings/model.py:1175-1201,1221-1232;agent_server/persistence/models.py:150-183).
Model records¶
At SDK
33d8aa2:
- Model switches. The agent's own
switch_llmtool loads a saved LLM profile and returns aSwitchLLMObservationwithprofile_name,reasonandactive_model; theclassify_and_switch_llmtool returns aClassifyAndSwitchLLMObservationwithchosen_class,modelandactive_model(tool/builtins/switch_llm.py:43-57,136-147;tool/builtins/classify_and_switch_llm.py:57-75). Both tools were already present atb347047. A switch through the Agent Server'sswitch_profileorswitch_llmendpoints writes no event; it replacesagent.llminbase_state.json(agent_server/conversation_router.py:586-651;conversation/impl/local_conversation.py:1673-1743). Canvas says so itself: "manual/modelswitches go through REST and leave no observation in history" (src/api/conversation-metadata-store.ts:38-48). The CLI's ACPset_session_modelis a no-op (acp_impl/agent/base_agent.py:331-339). - Model per response.
base_state.jsonstats.usage_to_metrics.<usage id>is a fullMetricsobject (not the snapshot form) withmodel_name,costs[].model,response_latencies[](model,response_id) andtoken_usages[](model,response_id) (llm/utils/metrics.py:21-59,76-130;conversation/state.py:430-451).response_idmatches thellm_response_idof theActionEventand agentMessageEventthat the response produced (event/llm_convertible/message.py:36-41;action.py:58-61), so the model of each turn can be recovered even after a REST switch.meta.jsonalso hasmetrics(a snapshot withmodel_name),forked_from_event_idandlaunched_agent_profile(agent_server/models.py:115-146).
Joins and timestamps¶
Session to project: meta.json or base_state.json workspace.working_dir (a container path under
Canvas-in-Docker). Tool call to result: ActionEvent.tool_call_id =
ObservationEvent.tool_call_id; also ObservationEvent.action_id = ActionEvent.id. Parent to
child conversation: meta.json parent_conversation_id, forked_from_conversation_id. Event
order: the idx in the file name; parent_id forms a tree when the user navigated back and
branched.
Event timestamp is naive local time and meta.json times are UTC; see
Record schema.
Other stores¶
None for current transcripts. Legacy 1.x kept conversation metadata in .openhands/openhands.db
(openhands/app_server/services/db_session_injector.py:192).
Legacy 0.x wrote agent_state.pkl and conversation_stats.pkl (Python pickle; never unpickle
evidence)
(openhands/storage/locations.py:29-38).
.openhands/automation/automations.db is SQLite (automation schedules and runs), outside this
parser's scope.
CLI prompt history¶
.openhands/projects/<sha256 of cwd>/prompt_history.json is a JSON array of the last 100 prompts
per working directory, text and timestamp (naive local datetime.now().isoformat()), oldest
first
(stores/prompt_history.py:11-15,
59-95).
It names no conversation, and its project is only a hash of the path; it survives the removal of a
conversation directory and joins on text and time.
Agent Canvas browser storage¶
At Canvas
8d39305
the front end keeps its own records in browser localStorage: in the Electron app that is the
Chromium Local Storage/leveldb directory (LevelDB, the key-value file format Chromium stores it
in) under the app's user data directory, which the catalog collects; in a browser it is the browser
profile, outside the catalog. The analyzer has no LevelDB reader for these keys. Each value is JSON:
| Key | Holds | Survives |
|---|---|---|
openhands-backends |
every backend the front end talks to: id, name, host, apiKey, kind (local or cloud), and for a DigitalOcean session marsSessionId and marsConfigId (src/api/backend-registry/types.ts:4-22; storage.ts:13-14, 95-101) |
the backend's removal |
openhands-last-conversation-by-backend |
backend id (with ::<org id> for an organisation) to the last conversation opened on it, a remote id for a cloud backend (last-conversation-store.ts:10-18) |
the conversation's deletion |
openhands-agent-server-conversation-metadata |
conversation id to selected_repository, selected_branch, git_provider, selected_workspace, workspace_mode (local_repo or new_worktree), active_profile and stamped_at (the LLM profile last chosen and when), plugins, local_planning_conversation_id (src/api/conversation-metadata-store.ts:4-56) |
removed on delete (agent-server-conversation-service.api.ts:1047-1081) |
archived-conversations |
archivesByBackendId: backend id to the conversation ids the user archived (src/stores/archived-conversations-store.ts:4-8, 75-78) |
archive is a hide; the conversation directory stays |
None of these holds message text, so none repeats an event; they mark a conversation's origin, workspace choice, archive state and the profile the user picked.
Format versions¶
- Current SDK: as above. The 5-digit index is a minimum
(
persistence_const.py:6-10). Every path, the event file layout and every field the parser reads are the same at SDK33d8aa2, Canvas8d39305and CLI0403f1aas at the earlier commits; new at those commits aremars-credentials.json(Credentials) and theSecurityAnalysisEventkind (Permission records). - Legacy 1.x app:
.openhands/[<user_id>/]v1_conversations/<hex>/<event hex>.json(openhands/app_server/conversation_paths.py:12;event/event_service_base.py:89), SDK events stored by id rather than index (inferred: the app server relays SDK events; field shapes not re-read). - Legacy 0.x:
.openhands/sessions/<sid>/events/<n>.jsonwith top-levelid,timestamp,source,message,cause,actionorobservation,tool_call_metadata,llm_metrics, andargs/extras(openhands/events/serialization/event.py:15-25;openhands/storage/locations.py:1-18). A different parser.
Secrets¶
base_state.json agent.llm.api_key, aws_secret_access_key
(llm/llm.py:277,
327)
and secret_registry, and meta.json secrets, are Fernet ciphertext when the server has
OH_SECRET_KEY (in .openhands/agent-canvas/secret-key.txt), else redacted as **********
(conversation/state.py:435-447;
utils/pydantic_secrets.py:48-78).
thinking_blocks[].signature, responses_reasoning_item.encrypted_content are opaque blobs.
Terminal output in observations and bash events can contain pasted tokens.
The Canvas Local Storage key openhands-backends (Other stores) stores each
backend's apiKey in plain JSON, an OpenHands Cloud key for a cloud backend
(src/api/backend-registry/types.ts:4-22;
storage.ts:95-101).
It shares the LevelDB files with the conversation metadata, so it is collected unflagged and the
key is the one to redact.
Parser plan¶
Globs: .openhands/agent-canvas/dev_conversations/*/events/event-*.json,
.openhands/agent-canvas/conversations/*/events/event-*.json,
.openhands/conversations/*/events/event-*.json; read the sibling meta.json and base_state.json
and every event file once per conversation. The same directories' base_state.json is also read
as an artifact of its own for the standing rows, and
.openhands/projects/*/prompt_history.json as a secondary source. Optional: bash_events/*. One
row per event; an ActionEvent also yields a thinking row for thought/reasoning_content.
Detect and report legacy sessions/*/events/*.json without parsing.
| Column | Source |
|---|---|
| timestamp_utc | event timestamp (naive local; convert with the host zone, else mark) |
| session_id | conversation directory name (hex) or meta.json id |
| project_path | workspace.working_dir |
| git_branch | empty |
| turn_type | MessageEvent by llm_message.role; ActionEvent, ACPToolCallEvent → tool_use; ObservationEvent, UserRejectObservation → tool_result; others → system |
| model | the model in force at the event (Model rows), else base_state.json agent.llm.model |
| tool_name, tool_use_id | tool_name, tool_call_id |
| text | message text; action.command or tool_call.arguments; observation content[].text; error; summary |
Permission rows¶
Written (issue 96 and issue 97):
permission decision: rejected rejection_source=<rejection_source> <rejection_reason>: asystemrow immediately after theUserRejectObservation'stool_resultrow, with itstool_nameandtool_call_id, fromrejection_source(userorhook;-when missing) andrejection_reason.security_risk=<value>at the end of anActionEvent'stool_usetext, fromsecurity_risk.
An approval writes nothing, so none can be shown. Also written, from the records of Permission records, source-derived; not observed on any real install:
permission decision: analyzed risk=<risk> analyzer=<analyzer> policy=<policy>[ details=<JSON>]: onesystemrow per entry of aSecurityAnalysisEvent'srisks, at the event's time, with thetool_nameandtool_call_id(astool_use_id) of theActionEventwhoseidis the key;action_id=<id>takes their place when no event file of the conversation has that id, anddetails=carries the entry ofdetailsfor that action as compact JSON when there is one.- Standing state, from
base_state.jsonread as an artifact of its own, at its modification time:permission setting: confirmation_policy=<kind>[ threshold=<threshold>][ confirm_unknown=<true|false>] (from base_state.json, file mtime),permission setting: security_analyzer=<kind> (from base_state.json, file mtime)(nonewhen the key is absent, which withexclude_nonemeans no analyzer), and onepermission setting: blocked_actions.<action id>=<reason> (from base_state.json, file mtime)per entry, with the blocked action'stool_nameandtool_use_idwhen itsActionEventis on disk. The values are the state at the last write, not its history.
Model rows¶
Written (issue 96 and
issue 97): model: <id>, a system row
after the conversation's header row, at the header's time, once per conversation.
From the records of Model records, source-derived; not observed on any real install:
- The
<id>of that first row is the model of the conversation's first response, fromstats.usage_to_metrics.*.token_usages[]joined on the event'sllm_response_id, when one is recorded before any switch; elsebase_state.jsonagent.llm.model, which holds only the current model. With no response joined and a switch first, the first row is the switch's. model: <old> -> <new>before the first event of a response whose model differs from the one in force (a switch through the REST endpoints between two responses shows only this way).model: <old> -> <active_model> profile=<profile>[ class=<chosen_class>]after a successfulSwitchLLMObservation(profile_name) orClassifyAndSwitchLLMObservation(model, the profile name, andchosen_class), at the observation's time.model: <old> -> <new> (from base_state.json agent.llm, file mtime)onbase_state.jsonat its modification time, whenagent.llm.modeldiffers from the last model the events show: a REST switch after the last response.- The
modelcolumn of each event row is the model in force at that event.
Past-use rows and secondary sources¶
.openhands/projects/<sha256 of cwd>/prompt_history.json(CLI; secondary, CLI prompt history), source-derived; not observed on any real install: oneuserrow per entry,textas the row text,timestampemitted as if UTC (as the CLI's conversations are, since the CLI writes nometa.jsonto anchor the zone), an emptysession_id, and asproject_paththeworkspace.working_dirof a conversation under the same.openhandswhose SHA-256 equals the directory name, else empty. It joins the CLI conversation'suserrows on text and time; what only it holds is kept.- A fork's directory (What survives a delete): primary, parsed as its own conversation.
- The Canvas browser-storage keys (Other stores) hold no message text and repeat no event; not read.
- The global
agent-canvas/bash_events/(terminal commands typed in Canvas) is a record of its own, not a copy, and is not read.
The agent keeps no search index or summaries database of conversations: not found in
software-agent-sdk at
33d8aa2
(grepped for sqlite3, fts5 and search_index, and for summar, whose only hits under
openhands-agent-server/ are profile-store listings; the one sqlite hit is a system library in
the Docker image).
Sync, relay and origin rows¶
Sync: OpenHands records no sync state on disk. Two features send work to OpenHands Cloud (the
vendor's hosted service at app.all-hands.dev, which runs each conversation in a sandbox of its
own), and neither writes a local file that names the remote conversation:
openhands cloud -t <task>(or-f <file>) posts the task text and the GitHub or GitLabowner/repoand branch of the working directory'soriginremote to/api/v1/app-conversations, then prints the remote conversation id and its browser URL (openhands_cli/cloud/conversation.py:54-133;auth/api_client.py:109-112;argparsers/cloud_parser.py:16-45). The id goes to stdout only; the CLI's only file writes are the API key,cli_config.json, the prompt history and the SDK's local conversation store, which this command does not open (auth/token_storage.py:27-37;stores/cli_settings.py:56-62;stores/prompt_history.py:92-93). A cloud-backed conversation store is a stub that raisesNotImplementedError(conversations/store/cloud.py:10-50).openhands acp --cloud(ACP, the Agent Client Protocol an editor such as Zed uses to drive the CLI as its agent) runs every session as an SDKRemoteConversationin an OpenHands Cloud workspace: the events are kept by the agent server inside the cloud sandbox, and the call that builds the conversation passes nopersistence_dir, unlike the local agent, so nothing is written under.openhands/conversations/(acp_impl/agent/remote_agent.py:186-258;acp_impl/agent/local_agent.py:188;argparsers/acp_parser.py:32-46). The only local file a remote conversation can leave is an LLM completion log, and only whenlog_completionsis set on the agent's LLM (sdk/conversation/impl/remote_conversation.py:1251-1282). The session id the editor holds is the remote conversation id; the editor's own state is outside this catalog entry.
Both need openhands login, which stores the account key in .openhands/cloud/api_key.txt
(Credentials) and pulls the account's settings down rather than pushing anything up
(auth/login_command.py:31;
auth/api_client.py:307-347).
Neither has a local delete: the CLI has no conversation delete command, and the remote copy is
managed in the web app, whose code is not in these repositories.
Other traffic that leaves the host beyond the model call, none of which carries a session marker:
- The critic (an OpenHands model that scores whether the agent's run succeeded) is on by default
(
critic.enable_criticin.openhands/cli_config.json) for models served through the OpenHands provider, and sends the conversation's messages and tools to the vendor's LLM proxy atllm-proxy.app.all-hands.dev/vllm(stores/cli_settings.py:23-26;stores/agent_store.py:91-130;sdk/critic/impl/api/critic.py:58-105). Each critic result is also sent to PostHog (a product analytics service) as acritic_inferenceevent, and each feedback button press other than dismiss ascritic_feedback, with the raw local conversation id as the PostHog identity, the score, the agent model and the critic'sevent_ids, but no message text, with no consent check (tui/utils/critic/feedback.py:29-79,228-270;tui/widgets/richlog_visualizer.py:332-345). Theposthogpackage arrives throughbrowser-use, so it is present in a source install (uv.lock:263-290). - The Agent Server's product telemetry is off by default (consent
unsetis not consent, the exporter defaults tonone), allowlists scalar fields so no prompt or reply text can be sent, and pseudonymises conversation ids; the consent is kept in.openhands/settings.jsonmisc_settings.telemetry.consent(granted,deniedorunset) (agent_server/telemetry/policy.py:160-216;agent_server/config.py:151-172;agent_server/telemetry/models.py:1-16;agent_server/telemetry/factory.py:112-124;agent_server/persistence/store.py:821-829). - Agent Server webhooks post events and conversation info to a configured URL; the list is empty by
default and is set by whoever launches the server, not stored per conversation
(
agent_server/config.py:91-105,322-325).
The trajectory export (GET /download-trajectory/<id>, Agent Canvas's download) is a download to
the user, not an upload: it zips the conversation directory with credential fields redacted into a
temporary <hex>.zip beside it and deletes the zip once the response is sent
(agent_server/file_router.py:179-216,
937-960).
No session synced: row is proposed. Searched: cloud, share, upload, sync, export,
trajectory, feedback, posthog, telemetry, webhook, remote, app_conversation_id,
sandbox_id, all-hands.dev in openhands_cli/, openhands-sdk/, openhands-agent-server/ and
openhands-workspace/. The OpenHands Cloud server is not in these repositories, so a share or sync
feature of its own is not covered here. The Agent Canvas front end is in
OpenHands/OpenHands; its origin records follow.
Origin: not yet written. A Canvas backend of kind cloud lists OpenHands Cloud conversations,
and a backend with marsSessionId is a DigitalOcean Managed Agents session
(Electron app)
(src/api/mars/AGENTS.md:3,
13;
src/api/cloud/conversation-service.api.ts:70-104).
Their transcripts never reach ~/.openhands; the only local traces are the localStorage keys of
Other stores. A session origin: remote openhands-cloud or
remote mars session <id> row would need a LevelDB reader.
Relay: the agent records none. No messaging relay was found in OpenHands at
8d39305,
software-agent-sdk at
33d8aa2
or OpenHands-CLI at
0403f1a
(grepped for telegram, slack, discord, whatsapp and jira; the last is only a cloud
feature flag).
Scratch directory rows¶
The analyzer's catalog rule already classes .openhands/agent-canvas/workspaces/<hex> and
.openhands/workspaces/<run> (both under the .openhands entry). A worktree conversation's
workspace.working_dir under /tmp/conversation-worktrees/<dir>
(Scratch directories;
agent_server/local_storage.py:14-23)
is outside every home, so the parser writes
scratch directory: <working_dir> (conversation worktree under the Agent Server's default
conversation_worktree_root /tmp/conversation-worktrees) after the conversation's model row, at the
header's time, with the working_dir as project_path; /private/tmp/conversation-worktrees/,
the same root as macOS resolves it, counts too. Source-derived; not observed on any real install.
The root is configurable, so a non-default root cannot be recognised from the conversation's files.
Deletion rows¶
The agent records no delete, so the parser writes no deletion row: nothing local records a deleted conversation (What survives a delete).
Bad-line rows¶
No file is read line by line, and an event file that does not decode gives one
parser: event file could not be decoded (<reason>) row. A meta.json or base_state.json that
exists but cannot be read, does not decode or is not a JSON object is read as empty and gives one
whole-file system row on the conversation's first event file, after the header and model rows:
parser: <file name> could not be decoded (<reason>),
parser: <file name> could not be read (<reason>) or parser: <file name> is not a JSON object,
with an empty timestamp and the session id and project_path the other file still gives. The
header then lacks what the file held (title, model, time basis). A missing file is not an error,
since the CLI writes no meta.json. The cut-file shape is not observed on any real install,
source-derived (OpenHands/software-agent-sdk at
33d8aa2).
prompt_history.json is read as a JSON array element by element: a record that does not decode
(a file cut mid-write) is one parser: unparseable record <N> (<reason>): <the text from that
record to the end of the file> row after the rows of the records before it, an element that is
not an object is parser: unparseable record <N> (not a JSON object): <the element>, and a file
that is not an array or cannot be read is one whole-file parser: row.
Fixtures¶
Fixture (.openhands/conversations/0f0e0d0c111140008000000000000001/):
meta.json:
{"id":"0f0e0d0c-1111-4000-8000-000000000001","title":"List files","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-01T10:00:05Z","workspace":{"kind":"LocalWorkspace","working_dir":"/home/alice/proj"}}
{"kind":"MessageEvent","id":"6b1d5c2e-0000-4000-8000-000000000001","timestamp":"2026-10-01T12:00:01.000000","source":"user","llm_message":{"role":"user","content":[{"type":"text","text":"list the files"}]},"activated_skills":[],"extended_content":[]}
{"kind":"ActionEvent","id":"6b1d5c2e-0000-4000-8000-000000000002","timestamp":"2026-10-01T12:00:03.000000","source":"agent","thought":[{"type":"text","text":"I will run ls."}],"action":{"kind":"TerminalAction","command":"ls"},"tool_name":"terminal","tool_call_id":"call_1","tool_call":{"id":"call_1","name":"terminal","arguments":"{\"command\":\"ls\"}","origin":"completion"},"llm_response_id":"resp_1","security_risk":"LOW"}
{"kind":"ObservationEvent","id":"6b1d5c2e-0000-4000-8000-000000000003","timestamp":"2026-10-01T12:00:04.000000","source":"environment","tool_name":"terminal","tool_call_id":"call_1","action_id":"6b1d5c2e-0000-4000-8000-000000000002","observation":{"kind":"TerminalObservation","content":[{"type":"text","text":"README.md"}],"is_error":false,"command":"ls","exit_code":0}}
(files events/event-00000-6b1d5c2e-...0001.json and so on, one object each).
The permission and model records, from the shapes of Permission records and
Model records (a worktree conversation, so its working_dir is under
/tmp/conversation-worktrees/):
{"kind":"SecurityAnalysisEvent","id":"8a2f0c1d-0000-4000-8000-000000000002","timestamp":"2026-10-03T11:00:02.500000","source":"environment","analyzer":"LLMSecurityAnalyzer","policy":"ConfirmRisky","risks":{"8a2f0c1d-0000-4000-8000-000000000001":"LOW"},"details":{"8a2f0c1d-0000-4000-8000-000000000001":{"rationale":"runs the test suite"}}}
{"kind":"ObservationEvent","id":"8a2f0c1d-0000-4000-8000-000000000005","timestamp":"2026-10-03T11:00:05.000000","source":"environment","tool_name":"switch_llm","tool_call_id":"call_w2","action_id":"8a2f0c1d-0000-4000-8000-000000000004","observation":{"kind":"SwitchLLMObservation","content":[{"type":"text","text":"Switched LLM profile to 'fast'"}],"is_error":false,"profile_name":"fast","reason":"cheaper","active_model":"openai/gpt-5-mini"}}
base_state.json, the fields beside agent.llm.model:
{"confirmation_policy":{"kind":"ConfirmRisky","threshold":"HIGH","confirm_unknown":true},"security_analyzer":{"kind":"LLMSecurityAnalyzer"},"blocked_actions":{"8a2f0c1d-0000-4000-8000-000000000006":"rm is blocked by hook"},"stats":{"usage_to_metrics":{"agent":{"model_name":"litellm_proxy/claude-sonnet-4-5","token_usages":[{"model":"litellm_proxy/claude-sonnet-4-5","prompt_tokens":10,"completion_tokens":5,"response_id":"resp_w1"}]}}}}
.openhands/projects/<sha256 of /srv/proj>/prompt_history.json:
[{"text":"list the files","timestamp":"2026-10-02T08:30:01.000000"},{"text":"and the hidden files?","timestamp":"2026-10-02T08:45:00.000000"}]
Source host rows¶
The agent records no connected source-host account, so the parser writes no source host: row.
The Agent Server searches the user's GitHub repositories (/git/repositories/search on its
API router, GitHub only) with a token it reads from its secrets store under the names
github_token, GITHUB_TOKEN, GH_TOKEN or github, and uses the github_token,
gitlab_token or bitbucket_token secret to clone; it never asks the host who the token
belongs to, and returns the repository list (id, full_name, git_provider, is_public,
pushed_at, main_branch) without storing it
(agent_server/git_provider_service.py:26-31,
54-87,
211-220;
agent_server/git_router.py:35,
129-150;
openhands-sdk/openhands/sdk/workspace/repo.py:46-50).
The token is a custom secret in .openhands/secrets.json (custom_secrets.<name>.secret),
Fernet ciphertext under the master key or plaintext without one
(agent_server/persistence/store.py:575-586;
agent_server/persistence/models.py:519-527),
and the file is flagged (Credentials); a conversation's meta.json secrets can
hold the same names, as the fixture's GITHUB_TOKEN does. Agent Canvas keeps the last three
repositories the user picked, with git_provider and full_name, under the localStorage key
home-store (recentRepositories, lastSelectedProvider)
(src/stores/home-store.ts:6-9,
60-63;
src/types/git.d.ts:58-67),
and the repository bound to each conversation under
openhands-agent-server-conversation-metadata (Agent Canvas browser
storage); both are LevelDB in the Electron app, which the analyzer
does not read. An OpenHands Cloud conversation started on a repository (openhands cloud, a
Canvas cloud backend) is stored server-side only
(Sync, relay and origin rows). Grepped for github, gitlab,
bitbucket, provider_tokens, git_provider, repositor and oauth in software-agent-sdk at
33d8aa2,
OpenHands-CLI at
0403f1a
and OpenHands at
8d39305.
Confidence¶
- High: every path in Per-user storage and Credentials under
~/.openhands(constants and env defaults in the cited files), the Fernet scheme, the Docker-runtime bind mounts, the CLI and legacy layouts at the cited commits. - High: the conversation directory layout, file naming, the
kinddiscriminator, the event field names, the naive localtimestamp, and thesecurity_riskvaluesUNKNOWN,LOW, ... (security/risk.py:13-21). - Medium: the Electron user data directory (Electron convention from
productName; the app does not set it explicitly);agent-canvas/storagecontents (created for the automation file store, not traced); whethersecrets.jsonin the legacy root used the same field names; the 1.xv1_conversationsevent shape. - Not determined: Agent Server file logs (
LOG_DIRdefaults to a relativelogs, so the cwd of the server); the automation service's tarballs understorage/; whether a Canvas install leaves a uv tool environment worth collecting (uv caches are outside this tool's directories); whether any surface writes a zone-aware event timestamp; the full set of action and observationkindvalues, which grows with each tool.