Skip to content

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

All claims are from source.

Observed

  • No real install.

Source reviews

  • 2026-10-21: OpenHands/software-agent-sdk at 33d8aa2, OpenHands/OpenHands-CLI at 0403f1a and OpenHands/OpenHands at 8d39305: the Parser plan now describes the permission, model, prompt-history and scratch rows the parser writes from source, the mars-credentials.json secret globs and the .openhands/workspaces exclusion are in the tables, and Source host rows states the GitHub repository search and its token.
  • 2026-10-19: SDK at 33d8aa2 (previously b347047 and fd26808), Agent Canvas at 8d39305 (previously a6bba78), CLI at 0403f1a (previously 954f2ba; 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 fd26808 and CLI at 0403f1a, 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:

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 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 in settings.json, secrets.json, profiles/*.json, meta.json and base_state.json are 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/* and auth/<vendor>_oauth.json, beside the stores of the first bullet (credentials.py:73-75).
  • runtime-control/<hex>.json: per-conversation api_key and encryption_key, themselves encrypted with the master key (provisioning.py:22-45).
  • CLI: agent_settings.json saved with expose_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 by openhands login and read by openhands cloud and openhands acp --cloud (auth/token_storage.py:19-25; 19-37 at 0403f1a), flagged by the .openhands/cloud/* secret glob; mcp.json holds MCP server definitions, which may embed keys; it is flagged.
  • Legacy: .jwt_secret and .keys in 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 hold acp/codex/auth.json in their own directory (event_service.py:338-339), flagged by .openhands/*/acp/codex/auth.json and, in a volume, conversations/*/acp/codex/auth.json and agent-canvas/*/acp/codex/auth.json.
  • Agent Canvas Electron app: <user data directory>/mars-credentials.json, the DigitalOcean connections (connections[] with kind oauth or pat, label, teamName, expiresAt and the token, and a deviceId); only the token is encrypted, with Electron safeStorage (the OS keychain), and is written as null when 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 globs Library/Application Support/OpenHands Agent Canvas/mars-credentials.json, .config/OpenHands Agent Canvas/mars-credentials.json and AppData/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 names github_token, GITHUB_TOKEN, GH_TOKEN or github for the search), encrypted like the other secret fields of the first bullet (Source host rows).
  • The Canvas Local Storage holds 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

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):

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:

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:

Model records

At SDK 33d8aa2:

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

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>: a system row immediately after the UserRejectObservation's tool_result row, with its tool_name and tool_call_id, from rejection_source (user or hook; - when missing) and rejection_reason.
  •  security_risk=<value> at the end of an ActionEvent's tool_use text, from security_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>]: one system row per entry of a SecurityAnalysisEvent's risks, at the event's time, with the tool_name and tool_call_id (as tool_use_id) of the ActionEvent whose id is the key;  action_id=<id> takes their place when no event file of the conversation has that id, and  details= carries the entry of details for that action as compact JSON when there is one.
  • Standing state, from base_state.json read 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) (none when the key is absent, which with exclude_none means no analyzer), and one permission setting: blocked_actions.<action id>=<reason> (from base_state.json, file mtime) per entry, with the blocked action's tool_name and tool_use_id when its ActionEvent is 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, from stats.usage_to_metrics.*.token_usages[] joined on the event's llm_response_id, when one is recorded before any switch; else base_state.json agent.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 successful SwitchLLMObservation (profile_name) or ClassifyAndSwitchLLMObservation (model, the profile name, and chosen_class), at the observation's time.
  • model: <old> -> <new> (from base_state.json agent.llm, file mtime) on base_state.json at its modification time, when agent.llm.model differs from the last model the events show: a REST switch after the last response.
  • The model column 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: one user row per entry, text as the row text, timestamp emitted as if UTC (as the CLI's conversations are, since the CLI writes no meta.json to anchor the zone), an empty session_id, and as project_path the workspace.working_dir of a conversation under the same .openhands whose SHA-256 equals the directory name, else empty. It joins the CLI conversation's user rows 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:

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 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 kind discriminator, the event field names, the naive local timestamp, and the security_risk values UNKNOWN, 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/storage contents (created for the automation file store, not traced); whether secrets.json in the legacy root used the same field names; the 1.x v1_conversations event shape.
  • Not determined: Agent Server file logs (LOG_DIR defaults to a relative logs, so the cwd of the server); the automation service's tarballs under storage/; 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 observation kind values, which grows with each tool.