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¶
- 2026-10-21: aaif-goose/goose at
fb9e3c8(committed 2026-10-19): it added the file formats of Permission files and Scheduler and project files, the chunk assembly of Request logs and the review of Source host rows, and the parser plan's permission, scheduled-task and request-log rows now describe what the parser writes. - 2026-10-19: aaif-goose/goose at
fb9e3c8(committed 2026-10-19), against591edd4and, for sync,bfcc6ee. It added the tool-confirmation records of Record schema, the permission, scheduler and project files of Other stores, the scratch directories, the delete table of What survives a delete and the schema 17 changes of Format versions, and corrected the parser plan's statement that the answer to a tool confirmation is not recorded. - 2026-10-17: aaif-goose/goose at
bfcc6ee, for sync. It added the gateway pairings of Joins and timestamps and thesession relayed:row of Sync, relay and origin rows.
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:
- Linux and macOS:
~/.config/goose,~/.local/share/goose,~/.local/state/goose(XDG_*_HOMEhonoured;etcetera/src/app_strategy/xdg.rs:37-50). - Windows:
%APPDATA%\Block\goose\config,%APPDATA%\Block\goose\data; no state dir, so state falls back to data (paths.rs:32;etcetera/src/app_strategy/windows.rs:141-165(126-165);base_strategy/windows.rs:190-200). Cache would be%LOCALAPPDATA%\Block\goose\cachebut no caller uses it. GOOSE_PATH_ROOT(absolute only) replaces all of it with<root>/config,<root>/data,<root>/state,<root>/.agents(paths.rs:9-17,40-46).- The comment at
paths.rs:19-21names~/Library/Application Support/Block/goose/as a legacy macOS location; no migration code reads it, so it is historical only.
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¶
sessions.dbtablesessionscolumnworking_dir TEXT NOT NULL(session_manager.rs:1028-1034).- Legacy jsonl first line key
working_dir(legacy.rs:74). - Data-dir
projects/<slug>.mdfront matter keyworkingDirs, a list of paths (atfb9e3c8,sources.rs:258-268). schedule.jsonrecipe_base_dir, the directory a scheduled recipe came from (scheduler/common.rs:182-186).- Desktop
recent-dirs.json(recentDirs.ts:5), a plain-text source usable bydiscover_projects.
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_timestampis epoch seconds (Message::user()usesUtc::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.timestampandsessions.created_at/updated_atare SQLiteCURRENT_TIMESTAMPtextYYYY-MM-DD HH:MM:SSUTC (: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 byid(:1887-1890). - Session id:
sessions.idisYYYYMMDD_<n>(:1633-1647);messages.message_idismsg_<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_headersis#[serde(skip)]); per messagemetadata_json.inference.{provider, requestedModel, resolvedModel?, providerSessionId?}(message.rs:768-775); per requestusage_ledger.model. - Role:
messages.roleinuser,assistant(:720-725).session_typeinuser, 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"?}(rmcpcontent.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; rmcpmodel.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; rmcpmodel.rs:3886-3897,content.rs:256-264), with older rows holding a bare content array invalue(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(atfb9e3c8, and already at591edd4) is internally tagged onactionTypewith 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), andelicitationResponse{"id","userData","action"}.permissionis snake case:always_allow,allow_once,cancel,deny_once,always_deny(permission.rs:3-11). The request is written as anassistantmessage visible to the user only (userVisibletrue,agentVisiblefalse) (ops_tool_approval.rs:116-127), and the answer as ausermessage visible to neither, added tomessagesthe moment the user answers (tool_confirmation.rs:108-116).idin both is thetoolRequest.idit governs. A gateway user answers by typingapprove,approve always,denyordeny alwaysin the chat, which becomes the same record (gateway/handler.rs:215-223).toolRequest._meta(tool_meta) carriesgoose.executable,trueorfalse, the approval step's verdict on the call:falsefor a call the permission check denied or held for confirmation, then the value the user's answer allows (allow_onceandalways_allowgivetrue) (message.rs:133-141;ops_tool_approval.rs:23,57-76,103-112,271-289). A call that runs with no question (modeauto, or a tool on an allow list) is not marked. A denied, cancelled or refused call is answered with atoolResponseholding the fixed text that beginsThe user has declined to run this tool.(tool_execution.rs:151;ops_toolcalling.rs:790-799).sessions.goose_modeis one ofauto(run every tool call),approve(ask before every call),smart_approve(ask only for calls judged sensitive) andchat(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 inauto(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_modeis one value per session row (defaultauto), 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 eachactionRequiredblock ofactionTypetoolConfirmation, an empty prompt left out, at the message'screated_timestamp, withtool_namethe block'stoolNameandtool_use_iditsid.permission decision: <permission> (toolConfirmationResponse)for eachactionRequiredblock ofactionTypetoolConfirmationResponse,tool_use_idthe block'sidandtool_namelooked up from thetoolRequestwith that id.permission decision: not executable (goose.executable=false)after thetool_userow of atoolRequestwhose_metaholdsgoose.executablefalseand that notoolConfirmationResponseof the same session answers (a pre-scan of the session's messages finds the answers), with the call'stool_nameandtool_use_id. Onlysessions.dbis checked; the legacy JSONL sessions and the request logs never carry the key.- An
elicitationorelicitationResponseblock keeps the genericaction required: <compact JSON>row, and an oldertoolConfirmationRequestblock itsconfirmation 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 inpermission.yaml, read without a YAML dependency for the one nesting levelserde_yamlwrites; 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 inroaming_trust.json.gateway_configs[].allowed_user_idsstays unread with the rest ofconfig.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, atcreated_at.model: <old> -> <new> provider=<inference.provider>before the rows of a message whosemetadata_json.inference.resolvedModel(elserequestedModel) differs from the session's current model, withrequested=<requestedModel>when the two differ. User messages carry noinferenceand 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 exportwrites 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 (noshareUrl,share_urlorBASE_URL_SHAREincrates/orui/).- The
sessionstable 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, andcontainer_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: trueinconfig.yaml;GOOSE_TELEMETRY_OFFforces 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 istelemetry_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_KEYandLANGFUSE_SECRET_KEYin the environment, default URLhttp://localhost:3000(langfuse_layer.rs:13,154-172); OTLP (the OpenTelemetry protocol) export byOTEL_EXPORTER_OTLP_ENDPOINTorotel_exporter_otlp_endpointinconfig.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 whenOTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENTistrue(agents/gen_ai_telemetry.rs:9-14;agents/agent.rs:988-994;agents/reply_parts.rs:272-277). An endpoint inconfig.yamlis 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 shareserves 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'ssessions.db;roam connect,delegateandbridgedrive 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 isroaming_directory.jsonin the state directory, one entry per peer withendpoint_id,label,direction(inboundoroutbound),agent_id,first_seen_ms,last_seen_msandconnected, besideroaming_peers.jsonandroaming_trust.jsonin 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 hassession_typeacpunless 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.dbDDL, the content and metadata field names, WAL, version handling. - Medium: Electron userData names (convention, no explicit
setPath); theBlock/gooselegacy macOS dir (comment only). - Medium: that the stored assistant message is the one
Conversation::pushassembles from the logged chunks; the state machine's path from the provider stream toadd_messagewas 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.gooseat home is ever written rather than read.