Skip to content

goose

Catalog agent goose, Block's open-source agent: a Rust CLI and an Electron desktop app (Goose) that share the same Rust config and data directories and keep their sessions in one SQLite database, with a gateway (a chat-platform front end) and roaming (peer-to-peer remote control) beside them. Researched together with Zed (zed.md); the two share nothing but Rust and SQLite. Parsed: the goose parser reads sessions.db, the legacy JSONL sessions, the llm_request logs, both history.txt files, permission.yaml, roaming_trust.json and schedule.json. Paths cited are relative to the clone.

Source and evidence

block/goose at 591edd47cf2cfea4957d720c607cf2a4def8673d, Apache-2.0. Open source. Rust; sqlx 0.9.0, rmcp 3.4.1, etcetera 0.11.0, rustyline 18.0.1 (Cargo.lock). Third-party crate sources were read to verify serialisation; the platform mapping was verified against etcetera 0.11.0, the version in Cargo.lock. Electron userData locations are Electron documentation level, not goose source.

The repository has since moved to aaif-goose/goose: github.com/block/goose answers with a 301 redirect, so both names are one repository. Citations name 591edd4 unless they name bfcc6ee or fb9e3c8.

Observed

  • No real install.

Source reviews

Collection

Per-user storage

crates/goose/src/config/paths.rs:22-36 uses etcetera::choose_app_strategy with author Block, app goose. etcetera/src/app_strategy.rs:157-164 (151-163) picks Windows on Windows and Xdg everywhere else, including macOS. So:

Scratch directories

A gateway session (the Telegram bot front end) runs in gateway/<platform>/<user_id> under the config dir (gateway/handler.rs:304-306, 826-831), so .config/goose and AppData/Roaming/Block/goose collect it and the analyzer's catalog rule classes it as a scratch directory. A GitHub recipe is cloned with gh repo clone into the OS temp directory, <temp>/<owner>/<repo> (goose-cli/src/recipes/github_recipe.rs:187-203), outside every home, so no catalog line can collect it; it holds recipe sources, not a session's working directory.

The desktop app's default working directory is the home itself (ui/desktop/src/utils/workingDir.ts:55-59); a scheduled job runs in the scheduler process's current directory (scheduler/full.rs:876-882), and goose roam share in its --cwd or the directory it was started in (commands/roam.rs:652-658). The agent creates no worktree.

Config directory

~/.config/goose: config.yaml (config/base.rs:34,200), secrets.yaml (base.rs:393-395), custom_providers/ (config/declarative_providers.rs:23), permissions/ (permission/permission_store.rs:41), recipes/ (recipe/local_recipes.rs:16), skills/ (skills/mod.rs:223), agents/ (sources.rs:470), prompts/ (prompt_template.rs:67), mcp-apps-cache/ (goose_apps/cache.rs:35), adversary.md (security/adversary_inspector.rs:112), gateway/<platform>/<user_id>/ (gateway/handler.rs:841-845), roaming_trust.json, roaming_peers.json (goose-cli/src/commands/roam.rs:40-46), roaming_node_key (goose-roaming/src/identity.rs:17,114-116), tls/ (acp/transport/tls.rs:88-90), legacy history.txt (goose-cli/src/session/mod.rs:197), and the provider token caches listed in Credentials. System config: /etc/goose/config.yaml, C:\ProgramData\goose\config.yaml (base.rs:163-169).

At fb9e3c8 the config dir also holds permission.yaml and permission.yaml.lock, the tool allow, ask and deny lists, and roaming_trust.json, the peer keys allowed to drive this host's agent (Permission files); permissions/tool_permissions.json is no longer written there, so the file is left only by older releases. These, and the data directory's projects/<slug>.md (Data directory), are collected by the existing entries.

Data directory

~/.local/share/goose: sessions/sessions.db (session/session_manager.rs:29-30,960-962) plus legacy sessions/*.jsonl (session/legacy.rs:13-19, imported at session_manager.rs:1149); schedule.json, scheduled_recipes/ (scheduler/common.rs:95-103); apps/<name>.html agent-written apps (agents/platform_extensions/apps.rs:57,137,250); models/ whisper weights (dictation/whisper.rs:94); model_catalog/ (model_catalog.rs:8); roam/serve.json, roam/serve.lock (goose-cli/src/cli.rs:1713, roam.rs:616).

At fb9e3c8 it also holds projects/<slug>.md, a project name with its working directories (Scheduler and project files).

State directory

~/.local/state/goose: logs/<component>/<date>/ and logs/llm_request.<id>.jsonl full request bodies (logging.rs:117-136, providers/utils.rs:60-125); history.txt CLI input history (goose-cli/src/session/mod.rs:196); instance_id (instance_id.rs:9); telemetry_installation.json (posthog.rs:114); roaming_directory.json (roam.rs:37); codex/images/ temp (providers/codex.rs:121); claude-code system prompt temp files (providers/claude_code.rs:397).

Shared cross-agent directories

Read by goose: ~/.agents/plugins, ~/.agents/agents (paths.rs:33-35), ~/.goose/agents, ~/.claude/agents (sources.rs:467-468,541-542).

Electron desktop app

productName Goose (ui/desktop/package.json:3); writes settings.json, logs/startup/, logs/main.log, recent-dirs.json under app.getPath('userData') (ui/desktop/src/main.ts:182-183, utils/logger.ts:6, utils/recentDirs.ts:5). Electron userData is ~/.config/Goose, ~/Library/Application Support/Goose, %APPDATA%\Goose. The desktop talks to the same Rust config and data dirs.

Of the catalog entries that issue 23 questioned, .config/Goose is this Electron userData directory on Linux, derived from productName rather than written as a literal, and ~/.goose is read for its agents/ subdirectory at 591edd4 (Shared cross-agent directories); whether anything writes there is not determined.

Credentials

Secrets go to the OS keyring (service goose, base.rs:31) unless GOOSE_DISABLE_KEYRING is set or the keyring fails, then ~/.config/goose/secrets.yaml (base.rs:85-91,216-220,1163-1185). config.yaml holds non-secret config but GOOSE_PROVIDER__* style values can be placed there.

Provider token caches are always files under the config dir (providers/provider_secrets.rs:109-152): githubcopilot/info.json and githubcopilot/<host>/info.json (githubcopilot.rs:181-184), chatgpt_codex/tokens.json, gemini_oauth/tokens.json, kimicode/token.json and kimicode/device_id, muse_code/token.json, huggingface/oauth/tokens.json (huggingface_auth.rs:19), databricks/oauth/ (providers/oauth.rs:94-96), xai_oauth/tokens.json (xai_oauth.rs:106). Also roaming_node_key (private identity key), tls/ (private key, tls.rs:92), and logs/llm_request.*.jsonl, which contain full prompts (Secrets).

Exclusions

.local/share/goose/models, .local/share/goose/model_catalog, .local/state/goose/codex, .config/goose/mcp-apps-cache, AppData/Roaming/Block/goose/data/models, AppData/Roaming/Block/goose/data/model_catalog, AppData/Roaming/Block/goose/data/codex, Electron */Goose/Cache, Code Cache, GPUCache, DawnGraphiteCache, DawnWebGPUCache, blob_storage. apps/ is agent output (HTML written by the model), not a binary cache; excluding it is a judgement call.

Project-local files

.goosehints and AGENTS.md walked from cwd upward, overridable with CONTEXT_FILE_NAMES (hints/load_hints.rs:10-17); .goose/memory/ (goose-mcp/src/memory/mod.rs:212,541), .goose/recipes (summon.rs:376, ui/desktop/src/recipe/recipe_management.ts:80), .goose/agents, .agents/agents, .agents/recipes, .claude/agents (sources.rs:534-536, summon.rs:377), .goose/skills (ui/desktop/src/components/skills/SkillsView.tsx:31). No per-project database. .gooseignore has no hits in current Rust source.

Where the project path is recorded

Parsing

Transcript stores

The stores below are under the directories of Per-user storage (crates/goose/src/config/paths.rs:22-32): on macOS the XDG ones, not Library/Application Support, whose catalog entry covers only the Electron desktop shell; on Windows state falls back to data.

Store Linux and macOS Windows Unit
Sessions database .local/share/goose/sessions/sessions.db (+-wal, -shm) AppData/Roaming/Block/goose/data/sessions/sessions.db sessions row = session; messages row = one message (session_manager.rs:29-30,961-963)
Legacy sessions .local/share/goose/sessions/<YYYYMMDD_HHMMSS>.jsonl same directory line 1 metadata, then one message per line (legacy.rs:62-100,111)
LLM request log .local/state/goose/logs/llm_request.<N>.jsonl (rotated; temp llm_request.<uuid>.jsonl) AppData/Roaming/Block/goose/data/logs/ one file per provider call (providers/utils.rs:86,121-131)
CLI history .local/state/goose/history.txt (old .config/goose/history.txt) AppData/Roaming/Block/goose/data/history.txt rustyline V2: #V2 header, one entry per line, \n and \\ escaped (goose-cli/src/session/mod.rs:196-197; rustyline-18.0.1/src/history.rs:463-465)

Opened with journal_mode=WAL, busy_timeout 30 s, directory mode 0700 (session_manager.rs:945-955). Recent rows live in sessions.db-wal until checkpoint. No encryption.

What survives a delete

At fb9e3c8 the session store loses messages in five ways, and history.txt (CLI prompts only, last 100) and the llm_request logs (the last 10 provider calls, LOGS_TO_KEEP, providers/utils.rs:50, rotated at 67-75) are the only local copies left after each:

Action What it does to sessions.db
goose session remove, the desktop app's delete (an ACP delete_session request; ACP is the Agent Client Protocol the app speaks to its backend) deletes the session's messages, usage_ledger and sessions rows (commands/session.rs:45; acp/server/manage_sessions.rs:88-105; session_manager.rs:2477-2508)
/clear in the CLI replaces the conversation with an empty one: every messages row of the session is deleted, the sessions row stays (goose-cli/src/session/mod.rs:1059-1077; session_manager.rs:2149-2160)
goose session --edit rewrites the conversation from an external editor the same way (goose-cli/src/cli.rs:2079-2088); --fork copies the session first and edits the copy (2069-2075)
a rewind (ACP truncate_session_conversation, and goose term) deletes the session's messages from a timestamp or a message id on (manage_sessions.rs:168-183; session_manager.rs:2816-2825)
archive (ACP archive_session) sets sessions.archived_at and deletes nothing; unarchive clears it (manage_sessions.rs:223-252)

Compaction is not a loss: it rewrites only the older messages' metadata_json (their visibility) and inserts the summary, so the compacted turns stay in messages (session_manager.rs:2198-2240). No delete touches permission.yaml, gateway_pairings, schedule.json or the gateway working directory. Source shows no local record of a delete, so the analyzer cannot write a session deleted: row for Goose.

What the history survives: goose session remove deletes the rows as in the table above (at the earlier commit too: commands/session.rs:44-46; session_manager.rs:2302-2315) and leaves history.txt alone, so the prompts of a removed session stay in it. It does not survive many sessions: the editor is built without a history size (session/mod.rs:648-660), so rustyline's default of 100 entries applies and a consecutive repeat is not added (rustyline-18.0.1/src/config.rs:248-251; history.rs:264-277).

What the pairing survives: unpairing a user and removing a gateway delete its entries from gateway_pairings (gateway/manager.rs:175-190, 331-343) and leave the gateway session row, its name and its working directory; goose session remove deletes the rows (session_manager.rs:2471-2498) and leaves the gateway_pairings entry, which then names a session that no longer exists. The working directory under .config/goose/gateway/ is never removed by either. No gateway code deletes chat messages on the platform (no deleteMessage call in crates/goose/src/gateway/), so the chat copy on Telegram outlives every local delete.

The analyzer reads history.txt and the llm_request logs on every run, both as secondary sources, so a removed, cleared or rewound session's prompts and latest replies are kept where the database lost them; see Past use and secondary sources and Secondary sources.

Record schema

sessions.db, schema version 16 (session_manager.rs:28,1012-1098):

CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
CREATE TABLE sessions (
  id TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
  user_set_name BOOLEAN DEFAULT FALSE, session_type TEXT NOT NULL DEFAULT 'user',
  working_dir TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, extension_data TEXT DEFAULT '{}',
  total_tokens INTEGER, input_tokens INTEGER, output_tokens INTEGER, cache_read_tokens INTEGER,
  cache_write_tokens INTEGER, accumulated_total_tokens INTEGER, accumulated_input_tokens INTEGER,
  accumulated_output_tokens INTEGER, accumulated_cache_read_tokens INTEGER,
  accumulated_cache_write_tokens INTEGER, accumulated_cost REAL, schedule_id TEXT, recipe_json TEXT,
  user_recipe_values_json TEXT, provider_name TEXT, model_config_json TEXT,
  goose_mode TEXT NOT NULL DEFAULT 'auto', archived_at TIMESTAMP, project_id TEXT, parent_session_id TEXT);
CREATE TABLE messages (
  id INTEGER PRIMARY KEY AUTOINCREMENT, message_id TEXT, session_id TEXT NOT NULL REFERENCES sessions(id),
  role TEXT NOT NULL, content_json TEXT NOT NULL, created_timestamp INTEGER NOT NULL,
  timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, tokens INTEGER, metadata_json TEXT);
CREATE TABLE usage_ledger (
  id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
  created_timestamp INTEGER NOT NULL, model TEXT, input_tokens INTEGER, output_tokens INTEGER,
  total_tokens INTEGER, cache_read_tokens INTEGER, cache_write_tokens INTEGER, cost REAL,
  cost_source TEXT, is_compaction INTEGER DEFAULT 0);

Migration v10 also creates threads and thread_messages (:1462-1487) with no writer; treat as empty. v11 adds provider_inventory_* (model lists, no secrets).

  • Timestamp: messages.created_timestamp is epoch seconds (Message::user() uses Utc::now().timestamp(), goose-provider-types/src/conversation/message.rs:1035); some clients write milliseconds, normalised with > 10_000_000_000 → /1000 (session_manager.rs:31,729-741). messages.timestamp and sessions.created_at/updated_at are SQLite CURRENT_TIMESTAMP text YYYY-MM-DD HH:MM:SS UTC (:1958); legacy-imported sessions bind chrono values, which sqlx writes as RFC 3339 (sqlx-sqlite-0.9.0/src/types/chrono.rs:69). Order within a second by id (:1887-1890).
  • Session id: sessions.id is YYYYMMDD_<n> (:1633-1647); messages.message_id is msg_<session_id>_<uuid> (:1941-1943).
  • Working directory: sessions.working_dir. Git branch: none.
  • Model and provider: sessions.provider_name, sessions.model_config_json {"model_name","temperature","max_tokens","toolshim","toolshim_model", "request_params"?,"reasoning"?,"supports_vision"?} (goose-provider-types/src/model.rs:41-59; request_headers is #[serde(skip)]); per message metadata_json.inference.{provider, requestedModel, resolvedModel?, providerSessionId?} (message.rs:768-775); per request usage_ledger.model.
  • Role: messages.role in user, assistant (:720-725). session_type in user, scheduled, sub_agent, hidden, terminal, gateway, acp (:46-57).
  • content_json: JSON array, "type" tag in camelCase (message.rs:317-330): text {"type":"text","text":"...","annotations"?,"_meta"?} (rmcp content.rs:23-34); image {"data","mimeType"}; document {"data", "mimeType","name"?}; toolRequest {"id","toolCall":{"status":"success", "value":{"name","arguments":{...}}} | {"status":"error","error":"..."}, "metadata"?,"_meta"?} (message.rs:131-141; tool_result_serde.rs:7-25; rmcp model.rs:4142-4150); toolResponse {"id","toolResult":{"status": "success","value":{"content":[{"type":"text","text":..}|{"type":"image",..}| resource|resource_link|audio],"structuredContent"?,"isError"?}} | {"status":"error","error"}} (message.rs:175-183; rmcp model.rs:3886-3897, content.rs:256-264), with older rows holding a bare content array in value (tool_result_serde.rs:142-145); thinking {"thinking", "signature"}; redactedThinking {"data"}; toolConfirmationRequest {"id","toolName","arguments","prompt"}; actionRequired {"data":{"actionType":...}}; systemNotification {"notificationType", "msg","data"?}; error {"kind","message"}.
  • metadata_json: {"userVisible","agentVisible","inference"?, "outputTokenLimitReached"?,"steer"?,"turnContext"?,"usage"?,"operations"?} (message.rs:826-854).

Tool confirmations and permission mode

  • actionRequired.data (at fb9e3c8, and already at 591edd4) is internally tagged on actionType with four variants (message.rs:194-219): toolConfirmation {"id","toolName","arguments","prompt"}, the question put to the user before a tool runs; toolConfirmationResponse {"id","permission"}, the answer; elicitation {"id","message","requestedSchema"}, an MCP server's request for input (MCP elicitation: a server asks the user to fill a form), and elicitationResponse {"id","userData","action"}. permission is snake case: always_allow, allow_once, cancel, deny_once, always_deny (permission.rs:3-11). The request is written as an assistant message visible to the user only (userVisible true, agentVisible false) (ops_tool_approval.rs:116-127), and the answer as a user message visible to neither, added to messages the moment the user answers (tool_confirmation.rs:108-116). id in both is the toolRequest.id it governs. A gateway user answers by typing approve, approve always, deny or deny always in the chat, which becomes the same record (gateway/handler.rs:215-223).
  • toolRequest._meta (tool_meta) carries goose.executable, true or false, the approval step's verdict on the call: false for a call the permission check denied or held for confirmation, then the value the user's answer allows (allow_once and always_allow give true) (message.rs:133-141; ops_tool_approval.rs:23, 57-76, 103-112, 271-289). A call that runs with no question (mode auto, or a tool on an allow list) is not marked. A denied, cancelled or refused call is answered with a toolResponse holding the fixed text that begins The user has declined to run this tool. (tool_execution.rs:151; ops_toolcalling.rs:790-799).
  • sessions.goose_mode is one of auto (run every tool call), approve (ask before every call), smart_approve (ask only for calls judged sensitive) and chat (no tool calls) (goose_mode.rs:20-32). A mode change rewrites the column in place (session_manager.rs:1897), so only the last mode survives. A scheduled job's session is created in auto (scheduler/full.rs:876-882), so its tool calls ran without any confirmation.

Joins and timestamps

messages.session_id = sessions.id; usage_ledger.session_id likewise. Tool pairing: toolRequest.id (assistant row) with toolResponse.id (user row). Project path: sessions.working_dir.

Timestamps and their precision are in Record schema.

CLI history

CLI history (history.txt) to sessions.db, on text alone (issue 122). The CLI adds every input line that is not blank to the rustyline history, slash commands included, before it decides what the line is (goose-cli/src/session/input.rs:193-195), and sends a prompt as the line with surrounding whitespace trimmed (input.rs:210). The file is saved before the message is added to the session (session/mod.rs:786-787), so a prompt's history line, stripped, equals the text block of its messages row. The line records no time, session id or working directory, so the only join is the text, against any session of the same user. A saved history replaces .config/goose/history.txt with .local/state/goose/history.txt and deletes the old file (session/mod.rs:225-230), so both exist only when the save or that deletion failed. No other code at this commit names history.txt (the ui/ desktop app included), so the file holds CLI prompts only.

Gateway pairings

Gateway pairings to sessions.db, on session id (at bfcc6ee). The gateway is Goose's chat-platform front end: goose gateway start runs a bot (Telegram is the one platform implemented, through https://api.telegram.org) that relays each message from a paired chat user into a Goose session and sends the reply back to the chat (gateway/telegram.rs:13; gateway/handler.rs:451-458). Pairing a chat user creates one session with session_type gateway, name <platform>/<display name or user id> and working_dir .config/goose/gateway/<platform>/<user_id> (handler.rs:304-326, 826-830; session_manager.rs:50-61), then records the pairing under the gateway_pairings key of .config/goose/config.yaml (handler.rs:355-363; gateway/pairing.rs:11, 104-120). Each list item is {platform, user_id, display_name, state} (strings, display_name may be null), and state is internally tagged on its own state key: {state: unpaired}, {state: pending_code, code, expires_at} or {state: paired, session_id, paired_at}, with paired_at in epoch seconds (pairing.rs:15-21; gateway/mod.rs:59-65; handler.rs:355). state.session_id equals sessions.id. A sample, with the platform id and name invented:

gateway_pairings:
- platform: telegram
  user_id: '123456789'
  display_name: alice
  state:
    state: paired
    session_id: 20261017_4
    paired_at: 1792224000

Request logs

Request logs to sessions.db, on text and tool call id (at fb9e3c8). Each provider call writes a log whose first line is {model_config, input}, input being the provider's own request body, then one {data, usage} line per message the provider yields and an {error} line on failure (goose-provider-types/src/request_log.rs:66-133). A streaming provider yields one partial message per chunk, built with Message::assistant(), so each line carries the message's id, role assistant, its own created and the chunk's content blocks (goose-provider-types/src/formats/anthropic.rs:1144-1149; conversation/message.rs:1042-1049). Conversation::push assembles consecutive messages with the same id into one: a one-block text chunk extends a last text block of the same audience, a one-block thinking chunk extends a last thinking block that is unsigned or has its signature, and anything else is appended; the message keeps its first chunk's created (goose-provider-types/src/conversation.rs:75-119). The message is stored with created_timestamp the later of its created and the newest created_timestamp already stored for its session (session_manager.rs:2104-2118), so the stored time is never earlier than the log's and can be later by as long as the tool calls before it ran. The log names no session and no working directory, so the join is the assembled text, or the tool call id, against any session of the same user. A provider call that yields no message id leaves every chunk its own line and its own time. The logs keep the last 10 calls (What survives a delete), so a removed, cleared or rewound session's latest requests and replies may survive there alone.

Other stores

The CLI history and the request logs are in the table of Transcript stores and the gateway pairings under Gateway pairings.

Permission files

Standing grants at fb9e3c8, read by the parser (Permission rows) except gateway_configs:

File Shape Meaning
.config/goose/permission.yaml (AppData/Roaming/Block/goose/config/permission.yaml on Windows), with a permission.yaml.lock beside it a YAML map from category to {always_allow: [], ask_before: [], never_allow: []}, each a list of tool names; the categories are user (the user's choices) and smart_approve (tools the smart_approve mode caches as needing a question) (config/permission.rs:13, 18-33, 42-44, 169-195) an always_allow answer adds the tool to user.always_allow, an always_deny answer to user.never_allow (ops_tool_approval.rs:57-70); goose configure sets any level by hand (commands/configure.rs:1913). The file keeps no time and no session, so its modification time is the last change
.config/goose/roaming_trust.json JSON {"allowed": [<peer key>], "revoked_keys": [<peer key>]} (goose-roaming/src/trust.rs:19-26; commands/roam.rs:40-42) the peer keys allowed to drive this host's agent over roaming (goose roam), each with "goose's full ACP surface" (trust.rs:1-12)
gateway_configs[].allowed_user_ids in .config/goose/config.yaml list of chat user ids the chat users allowed to pair with the gateway (gateway/handler.rs:64, 832-835); config.yaml is a secret glob

permission.yaml is rewritten whole on every change by serde_yaml 0.9.34, the map serialised with serde_yaml::to_string (config/permission.rs:260-262; Cargo.lock:9357-9358), so each category is a line <category>: at the left margin, each level an indented <level>: line, or <level>: [] when its list is empty, and each tool a block list item - <tool> below its level, quoted only where YAML needs it. roaming_trust.json is pretty-printed JSON (goose-roaming/src/trust.rs:78-82); its entries are public peer keys, not secrets.

.config/goose/permissions/tool_permissions.json, the per-call store named in Config directory (records {tool_name, allowed, context_hash, readable_context, timestamp, expiry} keyed <tool>:<hash of the arguments>, permission/permission_store.rs:11-20, 45-48), has no writer at fb9e3c8: record_permission and ToolPermissionStore are not called outside their own module (grepped for record_permission and ToolPermissionStore in crates/ and ui/), so the file exists only where an older release wrote it.

Scheduler and project files

Other files that record use: <data>/schedule.json, read by the parser (Past-use rows and secondary sources), a JSON list of scheduled jobs {id, source, cron, last_run, currently_running, paused, current_session_id, process_start_time, parameters, recipe_base_dir}, written pretty-printed with last_run and process_start_time as RFC 3339 times or null, source the job's recipe file and parameters a list of [name, value] pairs (scheduler/full.rs:78-84), whose current_session_id and sessions.schedule_id join a job to its sessions (scheduler/common.rs:93-104, 167-187; scheduler/full.rs:80-84); and <data>/projects/<slug>.md, not read, a Markdown file with YAML front matter (name, description, and a workingDirs list) that groups working directories under a project name, with no session content (sources.rs:69-75, 258-268). Neither repeats a transcript.

Format versions

schema_version table, CURRENT_SCHEMA_VERSION=16; migrations 1-16 are additive ALTER TABLE ADD COLUMN (session_manager.rs:1318-1613), so older databases lack message_id, metadata_json, provider_name, model_config_json, parent_session_id and usage_ledger. Legacy JSONL sessions are imported once when schema_version is absent (:982-989,1145-1250): line 1 {"description","id"?,"created_at", "updated_at","extension_data","message_count","working_dir"?, token fields}, then {"id","role","created","content":[...],"metadata"?} (legacy.rs:62-100,128-130); the file name YYYYMMDD_HHMMSS gives the created time (legacy.rs:111). Imports from Claude Code, Codex and Pi JSONL also land in sessions.db (import_formats/mod.rs:1-12).

The schema version is 17 at bfcc6ee (session_manager.rs:32). At fb9e3c8 (2026-10-19) CURRENT_SCHEMA_VERSION is 17, sessions is still sessions/sessions.db in the data directory (session_manager.rs:32-34), and the create statement (session_manager.rs:1141-1175) adds only system_prompt_override, system_prompt_extras_json and container_id to the columns of Record schema, beside the thread_id column that migration 10 adds (1559-1575); messages and usage_ledger are unchanged (1182-1215), and threads and thread_messages still have no writer (no INSERT INTO threads or INSERT INTO thread_messages in crates/). session_type keeps the same seven values (session_manager.rs:52-61), the content block tags and metadata_json.inference keys are as in Record schema (message.rs:318-329, 766-775), the history paths are unchanged (goose-cli/src/session/mod.rs:197-198), and the request logs are still logs/llm_request.<N>.jsonl in the state directory, the last 10 kept. The provider code has been split into crates of its own (crates/goose-providers, crates/goose-local-inference): the Hugging Face token cache is now written from goose-local-inference/src/huggingface_auth.rs:33 at the same path. Nothing the parser reads has moved or stopped being written.

Secrets

llm_request.*.jsonl holds the entire provider request body (system prompt, all messages, tool outputs) and responses (request_log.rs:81-86,110-113); headers and keys are not logged (ModelConfig.request_headers is #[serde(skip)], model.rs:58-59). Credentials live in the keyring or .config/goose/secrets.yaml (Credentials). recipe_json and user_recipe_values_json can carry user-entered parameters, as can schedule.json parameters (Scheduler and project files).

The gateway keeps its platform configuration, the Telegram bot token included, as the secret gateway_platform_config_<type> (keyring or secrets.yaml) and only {gateway_type, max_sessions, allowed_user_ids} under gateway_configs in config.yaml (gateway/manager.rs:15-28). The gateway_pairings sync marker (Gateway pairings) also lives in config.yaml, which the collector flags as a secret, so a --no-secrets collection loses it. The Langfuse keys (LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY) are read from the environment only (tracing/langfuse_layer.rs:154-172).

Parser plan

Globs: .local/share/goose/sessions/sessions.db*, .local/share/goose/sessions/*.jsonl, .local/state/goose/logs/llm_request.*.jsonl, .local/state/goose/history.txt, .config/goose/history.txt, AppData/Roaming/Block/goose/data/sessions/sessions.db*, AppData/Roaming/Block/goose/data/logs/llm_request.*.jsonl, AppData/Roaming/Block/goose/data/history.txt.

Rows: one per content_json block, plus one system row per session from sessions.

Column Source
timestamp_utc created_timestamp (divide by 1000 if above 1e10)
session_id messages.session_id
project_path sessions.working_dir
git_branch empty
turn_type role user → user; assistant text → assistant; toolRequest → tool_use; toolResponse → tool_result; thinking/redactedThinking → thinking; systemNotification/error/actionRequired → system
model metadata_json.inference.requestedModel, else model_config_json.model_name prefixed with provider_name/
tool_name toolCall.value.name (responses look the name up by id)
tool_use_id toolRequest.id / toolResponse.id
text text, arguments JSON, result text

Permission rows

Written (issue 96 and issue 97), all system, from sessions.db:

  • permission mode: <goose_mode> after each session's start row; goose_mode is one value per session row (default auto), so there is no change row.

Written (issue 209), source-derived; not observed on any real install. Per call, from sessions.db (Tool confirmations and permission mode):

  • permission request: <toolName> | <prompt> | <arguments> for each actionRequired block of actionType toolConfirmation, an empty prompt left out, at the message's created_timestamp, with tool_name the block's toolName and tool_use_id its id.
  • permission decision: <permission> (toolConfirmationResponse) for each actionRequired block of actionType toolConfirmationResponse, tool_use_id the block's id and tool_name looked up from the toolRequest with that id.
  • permission decision: not executable (goose.executable=false) after the tool_use row of a toolRequest whose _meta holds goose.executable false and that no toolConfirmationResponse of the same session answers (a pre-scan of the session's messages finds the answers), with the call's tool_name and tool_use_id. Only sessions.db is checked; the legacy JSONL sessions and the request logs never carry the key.
  • An elicitation or elicitationResponse block keeps the generic action required: <compact JSON> row, and an older toolConfirmationRequest block its confirmation requested: row.

Standing (Permission files), at the file's modification time with an empty session_id, <file> the path relative to the home:

  • permission setting: <category>.<level>=<tool> (from <file>, file mtime) per tool in permission.yaml, read without a YAML dependency for the one nesting level serde_yaml writes; any other line is a bad line (Bad-line rows).
  • permission setting: roaming_trust.<allowed or revoked_keys>=<key> (from <file>, file mtime) per peer key in roaming_trust.json.
  • gateway_configs[].allowed_user_ids stays unread with the rest of config.yaml (secret glob, nested YAML).

Model rows

Written (issue 96 and issue 97), all system, from sessions.db:

  • model: <model_config_json.model_name> provider=<provider_name> after each session's start row, at created_at.
  • model: <old> -> <new> provider=<inference.provider> before the rows of a message whose metadata_json.inference.resolvedModel (else requestedModel) differs from the session's current model, with  requested=<requestedModel> when the two differ. User messages carry no inference and change nothing.
  • The legacy JSONL sessions record no model and the request log has no session id, so neither yields model rows.

Past-use rows and secondary sources

Sources (issue 122): sessions.db is the primary store, and the legacy JSONL sessions are read as primary records. The llm_request logs are a secondary source (issue 126; source-derived; not observed on any real install): their response messages repeat the database's assistant messages, so secondary() is True for them and secondary_tolerance() is 600 seconds, because the database stamps a message up to the tool calls' run time later than the log (Request logs). The parser assembles a streamed reply's chunks by message id before it makes rows, so the assembled text matches the database row, and a tool call matches on its id; the llm request row, which no database row repeats, is always kept. Both history.txt files are secondary sources: one user row per entry, with an empty timestamp_utc, session_id and project_path, which dedup.deduplicate drops when a database user row of the same user has the same text and keeps otherwise (a removed session's prompt, a slash command). See CLI history.

Written (issue 209), source-derived; not observed on any real install: one system row per schedule.json job (Scheduler and project files), a configuration record, neither primary nor secondary, at the file's modification time with an empty session_id: scheduled task: <id> <cron> paused=<true|false> running=<true|false> source=<source> last_run=<last_run> current_session=<current_session_id> recipe_base_dir=<recipe_base_dir> (from <file>, file mtime), with  parameters=<compact JSON> before (from when the job has parameters and - for an absent value. The job's sessions name it in their start row's schedule= and ran in mode auto. projects/<slug>.md holds no session content and needs no row.

Sync, relay and origin rows

Sync: the one session that leaves the host with a local marker is a gateway session, whose whole conversation is relayed through the Telegram bot and stays in that chat on Telegram's servers. The marker is the gateway_pairings entry in .config/goose/config.yaml (AppData/Roaming/Block/goose/config/config.yaml on Windows), written once when the chat user pairs and removed on unpair or gateway removal, plus the session's own session_type gateway, which survives the unpair (Gateway pairings). A relayed chat is a copy on the messaging platform, not a vendor sync, so the parser leaves records_sync off and reads sessions.db rather than the pairing: one system row after each gateway session's start row, at the session's created_at, session relayed: gateway <platform> user <user_id> display <display or -> (from sessions.db session_type gateway, created_at), the platform and user id taken from working_dir (.config/goose/gateway/<platform>/<user_id>), the display from name (<platform>/<display name or user id>, - when it is the user id). The gateway_pairings entry repeats the platform and user id and adds paired_at, but it is nested YAML, which the analyzer has no dependency to read, inside config.yaml, which is a secret glob, so it is not read; the session row survives an unpair, which deletes the entry, and is present with --no-secrets. A session deleted with goose session remove leaves only its pairing entry, naming a missing session id, and so yields no row. Fixture only: no real install has shown a gateway session. Nothing else at bfcc6ee uploads a session to a Goose or Block account; what was read:

  • goose session export writes Markdown, JSON, YAML or HTML to a local file or stdout and records nothing (goose-cli/src/cli.rs:558-578; commands/session.rs:293-334). No share, sync, cloud, upload or publish command exists in the CLI's command list, and the desktop app's former share feature is gone (no shareUrl, share_url or BASE_URL_SHARE in crates/ or ui/).
  • The sessions table at schema version 17 has no remote, share or sync column; its columns beyond those in Record schema are local (thread_id, system_prompt_override, system_prompt_extras_json, and container_id, a local Docker container for extensions) (session_manager.rs:1140-1174, 1560-1575, 1728-1749).
  • Telemetry goes to PostHog (a product-analytics service) only after the user opts in (GOOSE_TELEMETRY_ENABLED: true in config.yaml; GOOSE_TELEMETRY_OFF forces it off) and carries counts, versions, provider, model, extension names and a pattern-redacted error message, never prompt or reply text (posthog.rs:16-52, 389-450, 530-536); its only local file is telemetry_installation.json (an installation id, first-seen time and session count) in the state directory (posthog.rs:97-114). The desktop app's own analytics send nothing at this commit (ui/desktop/src/utils/analytics.ts:1-18), and its bug-report button opens a GitHub issue form with system details only (Diagnostics.tsx:185-191).
  • Tracing uploads content with no local marker. Langfuse (an LLM tracing service, self-hosted or cloud) is enabled by LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY in the environment, default URL http://localhost:3000 (langfuse_layer.rs:13, 154-172); OTLP (the OpenTelemetry protocol) export by OTEL_EXPORTER_OTLP_ENDPOINT or otel_exporter_otlp_endpoint in config.yaml, with the host and user name as resource attributes (otel/otlp.rs:224-283). Both are off by default, and prompt, reply and message text enter the spans only when OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT is true (agents/gen_ai_telemetry.rs:9-14; agents/agent.rs:988-994; agents/reply_parts.rs:272-277). An endpoint in config.yaml is the only trace on disk; the environment variables leave none. No row is proposed.
  • Roaming (goose roam) is peer-to-peer remote control, not an upload: roam share serves this host's agent over ACP (the Agent Client Protocol) through iroh relays (a peer-to-peer QUIC library) to peers whose keys were accepted, and the sessions they drive stay in this host's sessions.db; roam connect, delegate and bridge drive a peer's agent, whose sessions stay on the peer (goose-roaming/src/lib.rs:1-24; commands/roam.rs:157-197). The local record is roaming_directory.json in the state directory, one entry per peer with endpoint_id, label, direction (inbound or outbound), agent_id, first_seen_ms, last_seen_ms and connected, beside roaming_peers.json and roaming_trust.json in the config directory (roam.rs:36-46; goose-roaming/src/directory.rs:17-44). It names no session id, so it is not a sync marker; a session hosted for a peer has session_type acp unless the client names itself (acp/server/new_session.rs:275-285).

At fb9e3c8 the agent still records no sync and no origin (grepped for shareUrl, share_url and BASE_URL_SHARE in crates/ and ui/desktop/src), and the Telegram factory is still the only gateway (gateway/mod.rs:95-100).

Scratch directory rows

No parser row is needed: the gateway working directory is classed by the catalog rule, and the desktop app's default working directory is the home (Scratch directories).

Deletion rows

The agent records no delete, so there is no session deleted: row (what each delete removes is under What survives a delete); sessions.archived_at is already on the session start row (archived=).

Bad-line rows

The legacy JSONL and request logs are covered by iter_jsonl and error_rows; history.txt cannot fail to decode (each line is a row). A permission.yaml line that is not a category, a level or a list item is one row, parser: unparseable line (not a category, level or list item): <line>, and the entries before and after it are still yielded; a roaming_trust.json or schedule.json that cannot be read, is not JSON or is not the type Goose writes (an object, a list) is one parser: <message> row, and a schedule.json job that is not an object is parser: unparseable record <N> (not a JSON object): <record>. In sessions.db (issue 183) a content_json, metadata_json or model_config_json that does not decode, or decodes to another type than Goose writes (an array for content_json (message.rs:317-330), an object for the other two), is one system row through error_rows: parser: unparseable record <N> (<table>.<column>: <reason>): <stored value>, the stored text in full, <N> the messages.id or the sessions rowid (also source_line), with the session's session_id and working_dir and an empty timestamp_utc. A NULL or empty content_json gives the row with reason empty and no value. The rest of the session is still yielded: a bad model_config_json row follows the session start row and the session has no model: row; a bad metadata_json row precedes the message's content rows, which keep the session's model and count as visible; a bad content_json row stands in for the message's rows. Not observed on any real install, source-derived (block/goose at 591edd4).

Fixtures

Fixture:

INSERT INTO schema_version(version) VALUES (16);
INSERT INTO sessions(id,name,session_type,working_dir,created_at,updated_at,extension_data,provider_name,model_config_json,goose_mode)
VALUES ('20260301_1','Fix flaky test','user','/home/alice/proj','2026-03-01 09:00:00','2026-03-01 09:01:05','{}','anthropic',
 '{"model_name":"claude-sonnet-4-5","temperature":null,"max_tokens":null,"toolshim":false,"toolshim_model":null}','auto');
INSERT INTO messages(message_id,session_id,role,content_json,created_timestamp,metadata_json) VALUES
('msg_20260301_1_a1','20260301_1','user','[{"type":"text","text":"run the tests"}]',1772355600,'{"userVisible":true,"agentVisible":true}'),
('msg_20260301_1_a2','20260301_1','assistant','[{"type":"toolRequest","id":"call_1","toolCall":{"status":"success","value":{"name":"developer__shell","arguments":{"command":"pytest -q"}}}}]',1772355601,
 '{"userVisible":true,"agentVisible":true,"inference":{"provider":"anthropic","requestedModel":"claude-sonnet-4-5"}}'),
('msg_20260301_1_a3','20260301_1','user','[{"type":"toolResponse","id":"call_1","toolResult":{"status":"success","value":{"content":[{"type":"text","text":"3 passed"}]}}}]',1772355603,'{"userVisible":true,"agentVisible":true}'),
('msg_20260301_1_a4','20260301_1','assistant','[{"type":"thinking","thinking":"all green","signature":"sig"},{"type":"text","text":"All 3 tests pass."}]',1772355605,'{"userVisible":true,"agentVisible":true}');
INSERT INTO usage_ledger(session_id,created_timestamp,model,input_tokens,output_tokens,total_tokens) VALUES ('20260301_1',1772355605,'claude-sonnet-4-5',120,30,150);

Legacy 20260301_090000.jsonl:

{"description":"old chat","working_dir":"/home/alice/old","created_at":"2026-03-01T09:00:00Z","updated_at":"2026-03-01T09:00:10Z","extension_data":{},"message_count":1}
{"id":"m1","role":"user","created":1772355600,"content":[{"type":"text","text":"hello"}]}

llm_request.0.jsonl:

{"model_config":{"model_name":"gpt-4.1"},"input":{"model":"gpt-4.1","messages":[{"role":"user","content":"hello"}]}}
{"data":{"role":"assistant","created":1772355601,"content":[{"type":"text","text":"hi"}]},"usage":{"input_tokens":5,"output_tokens":1}}

A tool confirmation, as the parser's fixture holds it (the answer is a user message visible to neither side; the refused call's _meta keeps false):

[{"type":"toolRequest","id":"call_p1","toolCall":{"status":"success","value":{"name":"developer__shell","arguments":{"command":"rm -rf build"}}},"_meta":{"goose.executable":true}}]
[{"type":"actionRequired","data":{"actionType":"toolConfirmation","id":"call_p1","toolName":"developer__shell","arguments":{"command":"rm -rf build"},"prompt":null}}]
[{"type":"actionRequired","data":{"actionType":"toolConfirmationResponse","id":"call_p1","permission":"allow_once"}}]
[{"type":"toolRequest","id":"call_p2","toolCall":{"status":"success","value":{"name":"developer__text_editor","arguments":{"path":"/etc/hosts"}}},"_meta":{"goose.executable":false}}]

permission.yaml:

user:
  always_allow:
  - developer__shell
  ask_before: []
  never_allow:
  - developer__text_editor
smart_approve:
  always_allow: []
  ask_before:
  - 'computercontroller__automation_script'
  never_allow: []

roaming_trust.json is {"allowed": ["<peer key>"], "revoked_keys": ["<peer key>"]}, and a schedule.json job:

{"id": "nightly-report", "source": "/home/alice/.local/share/goose/scheduled_recipes/nightly-report.yaml",
 "cron": "0 0 2 * * *", "last_run": "2026-10-21T02:00:00.123456Z", "currently_running": false,
 "paused": false, "current_session_id": null, "process_start_time": null, "parameters": [],
 "recipe_base_dir": "/srv/proj/recipes"}

A streamed reply in a request log, two text chunks of one message:

{"data":{"id":"msg_s1","role":"assistant","created":1772355605,"content":[{"type":"text","text":"All 3 "}]},"usage":null}
{"data":{"id":"msg_s1","role":"assistant","created":1772355606,"content":[{"type":"text","text":"tests pass."}]},"usage":null}

Source host rows

The agent records no source-host integration. At fb9e3c8 the only GitHub features are the recipe repository and the Copilot provider. goose recipe with GOOSE_RECIPE_GITHUB_REPO set (an owner/repo value in config.yaml) shells out to gh auth status, gh auth login, gh api and gh repo clone, so the GitHub account and its token are the GitHub CLI's, in its own configuration outside every Goose directory, and the clone lands in the OS temp directory (Scratch directories) (goose-cli/src/recipes/github_recipe.rs:32, 104-126, 281-286; commands/configure.rs:1928-1945). The GitHub Copilot provider signs in to a GitHub account by the device flow to buy model inference, not repository access, and caches its token in githubcopilot/info.json (Credentials; providers/githubcopilot.rs:126-133). An MCP server's OAuth grant (a GitHub MCP server's included) is a secret oauth_creds_<name> in the keyring or secrets.yaml (oauth/persist.rs:14-50). No repository list is cached and no conversation is bound to a repository (grepped crates/ and ui/desktop/src for github, gitlab, bitbucket, oauth, installation, repositor, pull request and clone), so the parser writes no source host: row.

Confidence

  • High: the Rust paths and their derivation, the Windows mapping, the credential files, the sessions.db DDL, the content and metadata field names, WAL, version handling.
  • Medium: Electron userData names (convention, no explicit setPath); the Block/goose legacy macOS dir (comment only).
  • Medium: that the stored assistant message is the one Conversation::push assembles from the logged chunks; the state machine's path from the provider stream to add_message was not traced, so the request-log join rests on the shared coalescing rule.
  • Not determined: whether Goose desktop writes millisecond timestamps (only the normaliser proves that some client does); .gooseignore; whether .goose at home is ever written rather than read.