Agent session archive

Import selected Codex, Claude Code, OpenClaw, and Hermes sessions into a dedicated, redacted, searchable archive that stays separate from your curated vault, manually or through opt-in automation (a Claude Code SessionEnd hook or a daemon schedule).

GNO can make selected local agent conversations searchable while keeping who said what, when, and where. Supported harnesses are Codex, Claude Code, OpenClaw, and Hermes. Each import writes a sanitized archive you own, then indexes it through the ordinary JSONL record path, so search, query, ask, get, and Context Capsules work on it without a separate ranking engine.

Imported dialogue is source evidence, not truth. An assistant suggestion is labelled as assistant output and never shown as a user decision. Nothing is promoted to memory: turning something from a session into a standing fact stays an explicit gno remember call. See Memory.

Manual by default

Archive versus curated vault

Your curated vault holds notes you chose to keep. Session logs are high-volume, repetitive, and full of assistant guesses. The recommended setup therefore keeps them in a dedicated archive outside the vault, with its own config file and its own named index. Plain gno update, gno search, and your curated MCP or Web UI server never read the archive unless you opt in.

Set up an archive

Preview what exists on this machine first. Discovery reports session roots, unit counts, sizes, and sampled format versions. It never imports and needs no archive.

gno sessions discover

Discovery looks in $CODEX_HOME/sessions, else ~/.codex/sessions; ~/.claude/projects and $CLAUDE_CONFIG_DIR/projects; $OPENCLAW_STATE_DIR or ~/.openclaw; and $HERMES_HOME or ~/.hermes. Then create the archive and register the sources you approve:

gno --config ~/gno-sessions/archive.yml --index sessions sessions init --archive ~/gno-sessions/archive --collection sessions-work
gno --config ~/gno-sessions/archive.yml --index sessions sessions source add codex --harness codex --path ~/.codex/sessions --collection sessions-work
gno --config ~/gno-sessions/archive.yml --index sessions sessions source add claude --harness claude-code --path ~/.claude/projects --collection sessions-work

--archive must be an absolute directory (the shell expands ~). Init is idempotent, records the archive binding in the named index immediately, refuses an index that already holds other collections, and never retargets a config already bound to another index or root. Source IDs and collection names are lowercase letters, digits, hyphens, and underscores. Registering a source never imports.

Route projects to their own collections with --project <prefix>=<collection>, repeatable. Threads recorded under that working-directory prefix land in the mapped collection:

gno --config ~/gno-sessions/archive.yml --index sessions sessions source add codex-client --harness codex --path ~/.codex/sessions --collection sessions-work --project ~/work/client-a=sessions-client-a

A thread whose working directories map to different collections is quarantined (skipped_policy, reason mixed_domain) instead of being written to the less restricted one. Add a --project mapping that resolves all its directories to one collection and the next import archives it. Changing a source's collection or mappings re-imports the affected threads on the next run. Removing a source with sessions source remove <id> keeps its archive.

Import

gno --config ~/gno-sessions/archive.yml --index sessions sessions import --source codex --dry-run
gno --config ~/gno-sessions/archive.yml --index sessions sessions import --source codex
gno --config ~/gno-sessions/archive.yml --index sessions embed

Reruns are idempotent. A unit is skipped when its fingerprint and the parser, redaction, and archive-format versions are unchanged; a changed unit is re-rendered and compared byte for byte. Turn IDs stay stable across appends, so a growing session updates its archive file and the index reconciles its records. Imports on one archive serialize; a second concurrent import fails as busy.

Session search is ordinary search, query, ask, get, and Context Capsules against the archive pair. Broad search in your curated index does not see archives.

gno --config ~/gno-sessions/archive.yml --index sessions query "why did we pick sqlite" --category harness/codex --author human
gno --config ~/gno-sessions/archive.yml --index sessions search "migration plan" --tags-all project/api,role/human

Harness and project identity are tags, so one project stays searchable across agents:

Opt-in mixed retrieval

Nothing federates automatically, and there is no cross-index server context. To search sessions from your curated index, register an archive collection folder in the curated config yourself:

gno collection add ~/gno-sessions/archive/sessions-work --name sessions-work --pattern '**/*.jsonl'

Then copy that collection's recordAdapters.jsonl.fieldMapping block from the archive config into the new collection entry in the curated config, so author, tags, session, and date metadata keep working:

collections:
  - name: sessions-work
    path: /absolute/path/to/gno-sessions/archive/sessions-work
    pattern: "**/*.jsonl"
    recordAdapters:
      jsonl:
        fieldMapping:
          id: /id
          title: /title
          body: /body
          author: /author
          categories: /categories
          sessionId: /sessionId
          threadId: /threadId
          dateFields:
            recorded: /recordedAt

From then on, session records take part in that index's ordinary retrieval, graph, listing, and tag behaviour, and any agent connected to the curated index can read them. Only do this for collections whose content you are comfortable exposing there.

What is imported and what is excluded

Human speech is kept only where the harness structurally marks it as human; assistant final text is kept. Excluded by default:

Slash commands a person typed in Claude Code are archived as /name args. Forks and subagents stay distinct threads linked to their parent. Text that imitates another speaker inside a message stays quoted content. Unknown record kinds produce bounded, content-free drift diagnostics. The archive is a record of dialogue, not a complete execution audit.

Provenance and citation

Each archived turn is one record. Its body starts with Human: or Assistant: and ends with one Provenance: line whose fields are separated by · :

Titles read like Human · Codex · api. Thread and session IDs, harness, project and role travel as record fields and tags. Cite session evidence with the record's gno:// URI; URIs from the archive index carry ?index=sessions.

Receipts and partial outcomes

Every import returns the same receipt on the CLI, MCP, REST, and SDK. Receipts, status, and diagnostics carry no session content and no host paths.

A file still being written (its final line is cut) or one showing format drift is incomplete: readable threads are archived, the checkpoint does not advance, and the next run retries it. Archive writes are atomic, so an interrupted import heals on rerun without duplicates. Units count as complete only after the index sync succeeds; a failed or interrupted sync is retried by the next import. Units with malformed records stay incomplete. A thread withheld by policy (quarantined, or at a limit, which is skipped whole rather than archived truncated) has any earlier archived copy removed from search. When a registered source root is missing or cannot be read, import still rescans its retained archive for new redaction rules, then fails with SESSIONS_SOURCE_UNAVAILABLE (exit 2) instead of reporting the source as up to date. A directory or session file inside a source that cannot be read is a failed unit (a directory has the locator ".", since its name can be a host path), so the run is partial, never nothing_to_do, and those units keep their checkpoints until they are read.

Error codes and exit statuses are listed under Error codes.

Supported formats and versions

Parsers are verified against the store layouts below. Where no live store was available, the table says so.

HarnessStoreVerified against
Codex~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonlLocal store structure, CLI 0.58 through 0.156.1 (September 2026). Covers the legacy user/agent message events (0.150 and earlier) and completed-item speech events (0.151 and later).
Claude Code~/.claude/projects/<dir>/<session>.jsonl, subagent files under <session>/subagents/, and $CLAUDE_CONFIG_DIR/projectsLocal store structure, CLI 2.1.2xx (September 2026). Files without the origin field use a conservative fallback that could not be verified against a real file.
OpenClaw~/.openclaw/agents/<agent>/agent/openclaw-agent.sqlite (schema 23) and legacy agents/<agent>/sessions/*.jsonlUpstream v2026.9.6 source definitions, tested with synthetic fixtures; not verified against a live installation. zstd-compressed events are supported.
Hermes~/.hermes/state.db and profiles/<name>/state.dbHermes v0.19.0 schema (version 22) from the installed source, tested with synthetic fixtures; no populated store was available.

Consumer chat exports keep using the generic JSONL and transcript export adapters. Account connectors are out of scope.

Limits: a source unit up to 512 MiB; a JSONL line up to 32 MiB (larger lines are skipped and counted); a turn up to 256 Ki characters (skipped and counted); up to 20,000 turns and 64 MiB of archive per thread (larger threads are skipped as over_limit); up to 100,000 units per source per run.

Backup, retention, and pruning

gno --config ~/gno-sessions/archive.yml --index sessions sessions status
gno --config ~/gno-sessions/archive.yml --index sessions sessions prune --source codex
gno --config ~/gno-sessions/archive.yml --index sessions sessions prune --source codex --apply

Automation (opt-in)

Automation imports new session turns without you running sessions import each time. It is off until you switch it on, and it only ever calls the same importer as a manual import: the same sources, destinations, redaction, checkpoints, and receipts. Installing, upgrading, repairing, or restarting GNO never enables it, and a paused trigger stays paused.

Two triggers exist, each switched on separately for one profile:

TriggerWhat fires itWhat it does in the foreground
Claude Code SessionEnd hookClaude Code ending a sessionMarks the profile pending on disk, then returns
Daemon schedulegno daemon on this archive, every cadenceMarks the profile pending when due

Neither trigger imports anything by itself. The import runs later, in one of two places:

Claude Code SessionEnd ──► gno sessions hook ──┐
                                               ├─► pending marker (per profile,
daemon schedule tick (due) ────────────────────┘    durable, coalesced)
                                                         │
                        gno daemon tick / automation run ▼
                                          manual importer (sessions import)

With no daemon running, hook events stay pending (reported as pending, not yet archived) until a daemon starts or you run the profile. gno serve never drains pending work or runs schedules.

Support matrix

TriggerStatus
Claude Code SessionEnd hookSupported. Verified with Claude Code 2.1.280: SessionEnd fires for non-interactive claude -p runs.
Claude Code interactive /exitUnverified: whether SessionEnd fires when an interactive session ends with /exit was not verified. The daemon schedule catches up either way.
Codex, OpenClaw, and Hermes hooksNot supported. enable --hook codex fails with SESSIONS_UNSUPPORTED_INTEGRATION; import manually or on a daemon schedule.
Pre-compaction triggersNot supported in any harness. Manual or daemon schedule only.
Daemon scheduleSupported for every registered source; ticks only inside gno daemon on the archive's config and index.

Quick start

A="gno --config ~/gno-sessions/archive.yml --index sessions"

# 1. A profile names registered sources. It enables nothing.
$A sessions automation set claude --source claude-code

# 2. Review what would run: sources, destinations, the exact hook command,
#    the settings file it edits, and the daemon prerequisite.
$A sessions automation preview claude

# 3. Switch on the Claude Code SessionEnd hook, the schedule, or both.
$A sessions automation enable claude --hook claude-code
$A sessions automation enable claude --schedule --cadence 30m

# 4. Start the daemon that performs the imports (GNO never starts it for you).
$A daemon --detach

# 5. Watch it work.
$A sessions status

Profiles

A profile lives in the archive config under sessions.automation:

sessions:
  index: sessions
  archiveRoot: /absolute/path/to/gno-sessions/archive
  sources:
    - id: claude-code
      harness: claude-code
      path: /absolute/path/to/.claude/projects
      collection: sessions-work
  automation:
    - id: claude
      sources: [claude-code]
      hook:
        harness: claude-code
        enabled: true
        settings: /absolute/path/to/.claude/settings.json
      schedule:
        enabled: true
        cadence: 30m
      limit: 200 # optional: changed units per source per run
      retries: 3 # optional: automatic retries after a failed run

Run state (pending markers, last run, next due time, daemon heartbeat) is machine-written to <archiveRoot>/.gno-sessions/automation.json with mode 0600. It is not configuration; deleting it only forgets pending work and history.

Claude Code SessionEnd hook

enable --hook claude-code adds one entry to a Claude Code settings file, by default $CLAUDE_CONFIG_DIR/settings.json, else ~/.claude/settings.json, which is created when missing. Another file can be chosen only with the CLI (--settings <absolute path>), and it must already exist; the Web UI and REST always use the recorded or default file:

{
  "hooks": {
    "SessionEnd": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "GNO_DATA_DIR='…' GNO_CACHE_DIR='…' '/path/to/bun' run '/path/to/gno/src/index.ts' --config '/absolute/path/to/gno-sessions/archive.yml' --index 'sessions' sessions hook claude-code --profile 'claude'",
            "timeout": 5
          }
        ]
      }
    ]
  }
}

Verified against Claude Code 2.1.280: SessionEnd fires for non-interactive claude -p runs with the fields session_id, transcript_path, cwd, hook_event_name, reason, and prompt_id, and the hook finishes inside Claude Code's default 1.5-second SessionEnd budget. Whether SessionEnd fires when an interactive session ends with /exit was not verified; the schedule catches up either way.

Daemon schedule

Only one resident process (gno serve or gno daemon) may use a GNO data directory. If your curated gno serve or gno daemon already runs, the archive daemon cannot start on the same data directory (Resident runtime already active). Give the archive its own data directory and use it for every archive command, including enable:

export GNO_DATA_DIR=~/gno-sessions/data
gno --config ~/gno-sessions/archive.yml --index sessions update   # rebuilds the disposable index there
gno --config ~/gno-sessions/archive.yml --index sessions sessions automation enable claude --hook claude-code
gno --config ~/gno-sessions/archive.yml --index sessions daemon --detach

Run now

gno sessions automation run <profile> runs one profile through the importer immediately and records the outcome in automation status, whether or not any trigger is enabled. An explicit run resets any backoff and retry budget. It returns not_started (busy) only while another automation run of the same profile is in progress; a manual import holding the archive shows as a failed run with reason busy, retried with backoff.

Status

gno sessions status (and every status surface) reports, per profile:

FieldMeaning
stateoff, idle, pending, running, retrying, partial, or failed
hookHarness, enabled, and whether the owned entry is present in its settings file
scheduleEnabled, cadence, and nextDueAt (null unless a daemon is running)
pendingSince when, and which triggers admitted it
runningA live run and when it started
lastRunTriggers, times, outcome, reason code, thread and unit counts
lastSuccessAtLast run that completed fully (complete or up_to_date)
retryAtWhen a failed run is retried
recoveryThe next action to take, when one is needed

A schedule whose cadence was hand-edited to an invalid value (for example banana, or 5s below the minimum) is reported with enabled: false, a warning, and a recovery action (in status and in preview), and never runs until you fix it with sessions automation set … --cadence.

The daemon writes its heartbeat every 30 seconds on its own timer, so it stays running during its initial sync and during long imports. The daemon block reports running only with a fresh heartbeat from a live daemon on this archive, stale for a live process that stopped ticking, and not_running otherwise; then the schedule shows not running: no daemon instead of a due time.

A profile in failed state keeps its admitted work in the JSON pending field, but the text status and the Web UI show the failure and its recovery action instead of a pending line, because that work only runs after you fix the cause.

A run's outcome is complete, up_to_date (a verified no-op: every source was already current), partial (incomplete or deferred units, or a failed index sync; the next run retries them), or failed. Missing or unreadable sources, stale heartbeats, and failed runs never appear as a successful completion. Status, logs, and receipts carry counts, IDs, and reason codes only: no session content and no host paths.

Failures, retries, and recovery

Reason codeCauseWhat happens
busyAnother import or index writer holds the archive or index (including a locked SQLite index)A running daemon retries it with backoff (1m, 2m, 4m, … up to 30m), retries times; without a daemon, run it again yourself. automation run exits 4 (SESSIONS_BUSY).
runtime_errorFilesystem or index failureRetried with backoff
interruptedThe process running it diedPending work kept; the next daemon tick reruns it
source_revokedA profile source is no longer registeredNo automatic retry; fix the profile or re-register the source
source_unavailableA source or the archive directory is missing or unreadableNo automatic retry; fix it, then automation run
invalid_configurationCollections, binding, or source settings no longer matchNo automatic retry; correct the config
import_failedEvery processed unit failedNo automatic retry; check the receipt of automation run

Schedule ticks and hook events never reset a pending backoff, and never clear a failure marked "no automatic retry". Once the automatic retries of a busy or runtime_error failure are used up, the next scheduled tick or hook event starts a fresh retry budget. A failure that needs correction stays blocked until you act: sessions automation run, set, or enable clear it.

A trigger that could not be admitted (lock not acquired in time, disk full, archive directory removed) is reported as not accepted and leaves no pending marker. An accepted trigger is never dropped silently: it stays pending until a run consumes it, and a trigger that arrives while a run is in progress starts another run afterwards. Imports are idempotent, so a rerun after a crash creates no duplicate turns and never changes provenance.

Pause, disable, and remove

$A sessions automation disable claude            # pause everything
$A sessions automation disable claude --hook     # only the hook
$A sessions automation disable claude --schedule # only the schedule
$A sessions automation remove claude             # uninstall and delete the profile

Automation creates no tasks, reminders, notifications, or OS services, promotes nothing to remembered facts, and makes no network calls.

Privacy and redaction limits

Everything stays on your machine. New writes happen only in the archive root you chose (archive files, checkpoint state, and automation run state in .gno-sessions/automation.json), the archive config (including its sessions.automation block), and the named archive index. When you enable the Claude Code hook, GNO also edits one owned entry in the Claude Code settings file and keeps the previous file as settings.json.bak. Automation makes no network calls. Collection egress policy, local_only by default, applies to archive collections like any other.

Redaction runs before anything is persisted: archive text, titles, labels, metadata, locators, receipts, and diagnostics. It replaces common credential shapes with markers such as [REDACTED:api-key]: AWS key IDs, GitHub tokens, sk- style API keys, Stripe and Slack tokens, Google API keys, JWTs, bearer tokens, credentials in URLs, password= or api_key: style assignments, and private key blocks. A value detected once is redacted everywhere in that thread. Add your own exact strings (at least 4 characters) to the archive config; a change takes effect on the next import, which re-renders units whose source still exists and rescans archives whose source is gone in place:

sessions:
  redaction:
    literals:
      - internal.example.corp
      - my-project-codename

Redaction is best effort. It does not promise to catch every secret or personal detail in free text. Treat the archive as sensitive, keep it local_only unless you have reviewed it, and review before mixing it into a curated index.

Remote REST and MCP errors never include host paths: a filesystem or index failure surfaces as SESSIONS_RUNTIME_FAILURE with a fixed message.

Web UI, MCP, REST, and SDK

Web UI — start a server on the archive pair and open /sessions to manage sources and the archive destination, preview a dry run (redactions, destination collections), import manually with partial-outcome details, and search sessions with harness, project, and role filters and explicit Human and Assistant badges. Only one server or daemon may own a GNO data directory, so while your curated server runs, give the archive server its own GNO_DATA_DIR and a free port. Normal curated servers do not attach the archive.

The Automation panel shows each profile off by default, separate hook and schedule switches (each confirmed after a preview of sources, destinations, the settings file, and the daemon prerequisite), daemon availability, pending, running, partial, and failed state, last success, Run now, Pause, and Remove. Run now works for any allowed client; profile changes, Pause, and Remove are same-host only. While a run is in progress the page refreshes its status every 2 seconds, so the profile shows running, and after each action keyboard focus moves to the resulting status, switch, or error. Status requests to a gno serve that is itself running an import can take several seconds. The server itself never drains pending work or runs schedules.

gno --config ~/gno-sessions/archive.yml --index sessions serve --port 3100

MCP — a server started on the archive pair offers gno_sessions_status (read, including automation status), gno_sessions_import, and gno_sessions_automation_run (write tools, requiring --enable-write; without it they are not advertised, like the other write tools) in the full profile only, not core. Import takes sourceId, optional dryRun, and limit; any other key, such as paths, is rejected. The run tool takes a configured profileId only. There is no MCP discovery tool, so remote clients never learn host directories, and hooks and schedules cannot be enabled over MCP.

REST — on a server started with the archive pair: GET /api/sessions/status and POST /api/sessions/import with {sourceId, dryRun?, limit?}; paths are rejected. The import runs in a separate process, so the server keeps answering health, status, and Web UI requests while it runs. POST /api/sessions/automation/run with {profileId} is open to any allowed client too, and runs its imports the same way. GET /api/sessions/discover, POST /api/sessions/sources, DELETE /api/sessions/sources/:id, POST /api/sessions/init, and every automation profile change (PUT and DELETE /api/sessions/automation/:id, GET …/preview, POST …/enable, POST …/disable) answer only a same-host client (a local process; cross-origin browser pages are refused) and return 403 otherwise. Errors carry the specific code in details.sessionsCode; see Error codes.

SDK — open a client on the archive pair:

import { createGnoClient } from "@gmickel/gno"

const client = await createGnoClient({
  configPath: "/absolute/path/to/archive.yml",
  indexName: "sessions",
})
const preview = await client.importSessions({ sourceId: "codex", dryRun: true })
const receipt = await client.importSessions({ sourceId: "codex" })
const status = await client.sessionsStatus()
// Run a configured automation profile now (hooks and schedules are enabled
// from the CLI or a same-host client only).
const run = await client.runSessionsAutomation({ profileId: "claude" })
await client.close()

client.discoverSessions() previews local roots, and importSessions also accepts { paths, collection, format? } for explicit files. status.automation carries daemon state and per-profile hook and schedule status; the SDK can run a configured profile but cannot switch hooks or schedules on. Errors are GnoSdkError with code VALIDATION or RUNTIME and the sessions code in details.code.

Troubleshooting

Error codes

CodeMeaningCLI exitHTTPREST / SDK kind
SESSIONS_NOT_CONFIGUREDThe config has no sessions block1400VALIDATION
SESSIONS_BINDING_MISMATCHArchive config and index used apart1400VALIDATION
SESSIONS_SELECTION_REQUIRED, SESSIONS_DESTINATION_REQUIREDNo source or paths selected; path import without a collection1400VALIDATION
SESSIONS_UNKNOWN_SOURCE, SESSIONS_UNKNOWN_COLLECTIONUnregistered source ID or archive collection1400VALIDATION
SESSIONS_UNKNOWN_PROFILEUnknown automation profile ID1400VALIDATION
SESSIONS_UNSUPPORTED_INTEGRATIONHook for a harness without a verified integration1400VALIDATION
SESSIONS_UNSAFE_PATH, SESSIONS_UNSUPPORTED_FORMAT, SESSIONS_INVALID_INPUTUnsafe path, unknown harness, or malformed input1400VALIDATION
SESSIONS_SOURCE_UNAVAILABLESource path missing or unreadable (no host path when remote)2500RUNTIME
SESSIONS_BUSYAnother import holds the archive lock4409BUSY / RUNTIME
SESSIONS_RUNTIME_FAILUREFilesystem or index failure on REST/MCP (fixed, path-free message)—500RUNTIME

An import whose status is failed exits 2, except a selection with no supported session format, which exits 1 with SESSIONS_UNSUPPORTED_FORMAT. An automation run whose outcome is failed, and a hook that was not accepted, also exit 2. The CLI JSON envelope and REST errors carry the code in details.sessionsCode; MCP returns it in structuredContent.error; the SDK puts it in details.code. A same-host refusal on REST is 403 FORBIDDEN without a sessions code.