# kint, the full documentation in one file > Sibyl Memory that outlives the laptop. This file inlines the repository README and every documentation page, separated by horizontal rules. Each page names its source files. Install: `uv tool install git+https://github.com/s0nderlabs/kint@v0.3.0` (not on PyPI yet; Python 3.10+). --- ## README > Source: https://github.com/s0nderlabs/kint/blob/main/README.md

kint

Sibyl Memory that outlives the laptop.
Wipe the machine, connect the wallet, and the agent comes back knowing what it knew, and proves it was not tampered with.

Docs · Docs as markdown · EpochAnchor on Base · Claims a judge can check · v0.3.0 · MIT

[Sibyl Memory](https://github.com/Sibyl-Labs/Sibyl-Memory) decides what a coding agent remembers: five tiers, full-text search, eight MCP tools, one SQLite file. Today that file lives on one machine. kint keeps it wallet-owned: every change is packed, padded, encrypted to a key only the owner's wallet (or vault passphrase) can produce, and written to Base as calldata under a small contract. On any machine the owner connects, kint pulls, verifies every epoch against the chain, and replays the rows back through Sibyl's own write methods. Before the agent acts on anything it recalled, `memory_verify` re-reads the exact stored text, hashes it, and checks it against the leaf the chain vouches for. A row that drifted is refused, naming the block that anchored the last good value, and the refusal is written back as a Sibyl entity. Because the chain keeps every version, `memory_history` answers what the agent believed at an earlier block, which Sibyl cannot. Built on Sibyl Memory, under it: `kint-server` imports their MCP server untouched, keeps their eight tools exactly as shipped, and adds six of its own.
For hackathon judges · the memory walkthrough, a 30-second on-chain check, and where to look
**Persist.** Every write goes through Sibyl's own tools. When the agent goes quiet, kint diffs the store against the last anchored mirror and writes the changed rows to Base as one encrypted epoch (compressed, padded to a size bucket, AES-256-GCM under a key only the owner's wallet or vault passphrase can produce), under the `EpochAnchor` contract. **Recall (fresh session).** On a wiped machine the owner connects the wallet; kint pulls every epoch, checks each against the chain, and replays the rows through `MemoryClient`'s write methods. The agent then finds a rule by free-text `memory_search` (Sibyl's four FTS5 indexes and the shadow fallback), which returns ranked hits and a typed verdict from `verdicts.py`. **Changes the agent's decision by.** kint hashes exactly the `body` text that search returned and proves it against the `rows_root` anchored on Base. A row whose text no longer matches its anchored leaf is refused with a reason naming the block that anchored the last good value and the head block, and the refusal is written back as a Sibyl entity so the next fresh session sees it. `memory_history` shows every anchored version of the row, each with a block-height upper bound. **What breaks when memory is deleted.** Without Sibyl there is no ranked candidate and no verdict to key on: the epoch is a decrypted blob with no query surface, so a fresh session neither refuses nor proceeds. See [Delete the Sibyl Memory layer and what breaks](#delete-the-sibyl-memory-layer-and-what-breaks). **Verify on chain in 30 seconds** (no install beyond [foundry](https://getfoundry.sh)). The demo tenant's head on Base mainnet, read from the verified contract: ``` $ cast keccak "kint-space-v1kint-demo" 0xbd2a3b5b8f3fb4c81658d863b90018f1af511c28b33356fc81c2de406be98cb0 $ cast call 0xa22E03f7a4145Bf4909a83595C90a38E14d79600 "head(address,bytes32)(bytes32,uint64,uint64)" \ 0xC635e6Eb223aE14143E23cEEa9440bC773dc87Ec \ 0xbd2a3b5b8f3fb4c81658d863b90018f1af511c28b33356fc81c2de406be98cb0 \ --rpc-url https://mainnet.base.org 0x7df5378a8c4fa8664365cf8956033cac2f4e3cbf7a4767a377d759b262f01a13 2 51081880 ``` That is epoch 2 of the demo memory: digest `7df5378a…`, sequence 2, anchored at block 51081880. Its ciphertext is the calldata of the transaction in that block ([`0x369907fb…`](https://basescan.org/tx/0x369907fb1bccc3222365b2e07b4d6d831642831d481a5f7cb99357823e2a9729)), and `keccak256` of that ciphertext is the digest. **Executed on Base mainnet** (chain id 8453): | what | where | |---|---| | `EpochAnchor` (verified, no admin, no upgrade path) | [`0xa22E03f7a4145Bf4909a83595C90a38E14d79600`](https://basescan.org/address/0xa22e03f7a4145bf4909a83595c90a38e14d79600) | | epoch 1 of the demo tenant, 14 rows, 4 KB bucket | [`0xbe9bb859…`](https://basescan.org/tx/0xbe9bb8596c825a8891c3eafe4381e0c6522d647641735c635ca58fcf4bbe4455), block 51081867 | | epoch 2, the release rule rewritten | [`0x369907fb…`](https://basescan.org/tx/0x369907fb1bccc3222365b2e07b4d6d831642831d481a5f7cb99357823e2a9729), block 51081880 | | a Base Account (Coinbase Smart Wallet) authorizing a machine from the browser, then its epoch | owner `0xd3390EDAC3d0EB41248C132792B0DcC5f0b0D4E3`, [`0x9c64e2c2…`](https://basescan.org/tx/0x9c64e2c21b294843888680bd34d6da8242cb5313bc894864552163e167049c12), block 51082012 | **See the demo memory itself.** The demo tenant is a purpose-built set of fixtures, so its vault passphrase is public: `an0Co1E8--gzFk8VNoGTX6lS`. Two ways in, both read-only (a write needs a session key funded and authorized by the owner wallet): - **Browser.** Open [kint.s0nderlabs.xyz/app](https://kint.s0nderlabs.xyz/app), press **Fill the demo lane** (owner `0xd3390EDAC3d0EB41248C132792B0DcC5f0b0D4E3`, tenant `kint-demo`) and type the passphrase. The page reads the epochs off Base, opens them in the tab and shows every row with its anchored versions. Nothing decrypted leaves the page. - **Terminal, the cold-start beat from the film.** On any machine: ``` uv tool install git+https://github.com/s0nderlabs/kint@v0.3.0 kint join --owner 0xd3390EDAC3d0EB41248C132792B0DcC5f0b0D4E3 --base-account --tenant kint-demo --db /tmp/kint-demo.db --no-setup kint --tenant kint-demo --db /tmp/kint-demo.db status ``` `join` prompts `vault passphrase:`, pulls the epochs, checks each against its `Epoch` event, replays the rows through Sibyl's own write methods and reports `store root matches the anchored root`. It writes nothing to the chain and registers no harness; `--db` keeps the restore out of your own Sibyl store. **Where to look in under two minutes.** [`docs/judge.md`](docs/judge.md) maps every claim to a file and line: `src/kint/verify.py` for the decision beat, `src/kint/restore.py` for the replay through the SDK, `src/kint/store.py` for the cap volunteering, `src/kint/server.py` for the untouched import of Sibyl's server. `pytest -q` (52 tests, six of them on anvil), `forge test` (29, including a Base mainnet fork) and `bun test` (110) pin them. **Reproduce.** [Try it](#try-it-5-minutes) below: install from the tagged release, one signature per machine, a few cents on a session key, and every harness you run starts `kint-server` in place of Sibyl's. Claude Code, Codex, Hermes and OpenClaw each recalled the demo rule through it and reported `proceed` at block 51081880 ([`docs/agents.md`](docs/agents.md)).
## Delete the Sibyl Memory layer and what breaks The decision beat reaches the row through Sibyl's `memory_search` (four FTS5 indexes plus the shadow fallback) and keys on the typed verdict from `verdicts.py`. Without Sibyl there is no ranked candidate and no verdict to key on: the epoch is a decrypted blob with no query surface, so a fresh session neither refuses nor proceeds. Base holds the ciphertext; Sibyl is what turns it back into an answerable store. See `docs/judge.md` for the claims table with file and line. ## Live on Base mainnet | what | where | |---|---| | EpochAnchor (verified) | [`0xa22E03f7a4145Bf4909a83595C90a38E14d79600`](https://basescan.org/address/0xa22e03f7a4145bf4909a83595c90a38e14d79600) | | first epoch of the demo tenant | tx `0xbe9bb8596c825a8891c3eafe4381e0c6522d647641735c635ca58fcf4bbe4455`, block 51081867 | | a Base Account (Coinbase Smart Wallet) authorizing a machine from the browser | tx from `0xd3390EDAC3d0EB41248C132792B0DcC5f0b0D4E3`, then epoch tx `0x9c64e2c21b294843888680bd34d6da8242cb5313bc894864552163e167049c12`, block 51082012 | A 4 KB epoch costs about 0.000002 ETH on Base today (EIP-7623 calldata floor, 40 gas per byte, plus the L1 data fee). Restoring and reading never send a transaction. ## Try it (5 minutes) ``` uv tool install git+https://github.com/s0nderlabs/kint@v0.3.0 # Python 3.10+; not on PyPI yet kint session-key create # this machine's key; put a few cents of ETH on it (Base) kint canonical-payload --owner 0xYOU > kint-canonical.json cast wallet sign --data --from-file kint-canonical.json --ledger | kint connect --owner 0xYOU --signature - KINT_OWNER_KEY=... kint authorize direct # or: kint authorize page (Base Account owners) kint setup all # Claude Code, Codex, Hermes, OpenClaw now start kint-server ``` If your shell exports `PYTHONPATH`, run the binaries with it unset (`env -u PYTHONPATH kint ...`): a polluting `PYTHONPATH` shadows the tool's own environment and the binary fails to start. `kint setup` bakes `/usr/bin/env -u PYTHONPATH` into every harness registration for that reason. Base Account (Coinbase Smart Wallet) owners have no deterministic signature to derive a key from, so their vault key is a passphrase: `kint connect --owner 0xYourBaseAccount --smart-account`, then `kint authorize page` opens a loopback page where the account sends `setSessionKey` itself (that transaction also deploys a counterfactual account). On a wiped machine: `kint join --owner 0xYOU ...` does the read half in one command (session key, connect, restore, harness registration) and writes nothing to the chain; or run `kint connect ...` again (the data key comes out of the head epoch's own header), then `kint pull`. A cold start reads the head from two different RPC operators (`KINT_RPC_URL`, `KINT_RPC_URL_2`; by default an Alchemy key from the macOS Keychain item `dev.api.alchemy` when one exists, otherwise public Base endpoints) and refuses if they disagree; `KINT_ALLOW_SINGLE_RPC=1` accepts a single endpoint. `kint verify "release rule"` from a shell, or `memory_verify` from the agent. `kint doctor` checks every moving part. `kint status` shows the head, the mirror, the unanchored change count and the cap accounting: kint's own state is volunteered into the same 5 MiB free-tier cap Sibyl enforces, through Sibyl's public `CapGate(db_size_fn=...)` hook. `kint compact` anchors one snapshot epoch holding the whole state, which is where the next cold start stops (`kint pull --full` walks past it for the older versions). `kint rekey` rotates the data key itself: a new key, new wraps under the same wallet or passphrase, one snapshot epoch under the new key, a new recovery code. Rekey is a CLI command only, because the wallet signature and the vault passphrase it needs are typed in a terminal, never in an agent chat. Every command, flag, tool parameter and environment variable is in the [documentation](docs/site/): [Quickstart](docs/site/02-quickstart.md), [Restore on a new machine](docs/site/03-new-machine.md), [MCP tools](docs/site/08-tools.md), [CLI](docs/site/09-cli.md), [Configuration](docs/site/10-configuration.md), [Harnesses](docs/site/13-harnesses.md), and [For agents](docs/site/14-agents.md) when an AI agent is doing the install. ## Where memory is written and read (the critical path) - Write: Sibyl's own tools, unchanged (`memory_remember`, `memory_set_state`, ...), served by `kint-server` (`src/kint/server.py`, `build_server()` from `sibyl_memory_mcp`). - Anchor: `src/kint/push.py` diffs the store against the last anchored mirror and pushes the changed rows as one encrypted epoch (`src/kint/crypto.py`), after the Sibyl write returned, never inside it; kint-server holds the push (`HELD`) when a row changed behind Sibyl's tools. - Restore: `src/kint/pull.py` walks epochs backwards through the `prevBlock` field of each `Epoch` event, checks `keccak(ct)` against the event digest and the AEAD tag against an AAD that binds chain id, owner, space, seq, prev and the merkle root, then `src/kint/restore.py` replays rows through `MemoryClient.set_entity / set_state / set_reference / write_event`. - Read and decide: `src/kint/verify.py` calls `multi_record_search(client, query)`, the same gated call Sibyl's own `memory_search` makes (typed verdict, precision gates), refuses when the chain head has moved past what this machine last pulled, re-reads the stored TEXT by the key the search returned (`src/kint/export.py`), hashes it (`src/kint/canon.py`) and proves inclusion against the anchored `rows_root`. ## The six added tools `memory_status`, `memory_connect`, `memory_pull`, `memory_push`, `memory_verify`, `memory_history`. Every kint operation is a tool; the human does two things ever: sign once per machine (or type the vault passphrase) and put a few cents on a session key. `memory_push(snapshot=true)` anchors the whole state as one snapshot epoch instead of the diff, and `memory_pull(full=true)` walks past snapshots. Rotating the data key is deliberately NOT a tool: `kint rekey` needs the wallet signature or the vault passphrase, and those never travel through a chat. The derive signature is never accepted by a tool: it travels only on stdin, through `kint connect --signature -`. `memory_connect` does accept a vault passphrase or a recovery code as parameters, for harnesses whose only door is a tool call; type them in the terminal instead (`kint connect --passphrase-stdin`, `--recovery-code-stdin`) whenever you can, because anything that passes through an agent chat lands in its transcript. ## Honest limits - Content-exact and search-exact across the four tiers the SDK writes (entities, state, reference, journal); uuids and entity timestamps regenerate on replay, the journal ts survives. The export reads those tables by SQL in rowid order; the restore goes through the SDK. Those are four of Sibyl's eleven tables; the other seven (entity_relations, revenue_events, error_events, archived_entities, flagged_actors, skill_proposals, learning_runs) are not exported. Sibyl's `memory_forget` moves an entity into `archived_entities`, so a restored machine has the deletion, not the archive. - Every exported entity line carries its category and name by construction; Base holds ciphertext, but the row count and the size bucket are public. - One wallet owns one memory; many machines may write under it, one at a time. Two machines writing at once is a fork: kint refuses and tells you (`kint pull --discard-local`). - The ciphertext lives in transaction calldata; reading it back needs a node that keeps its transaction index, and Base's L1 data availability is the blob retention window. kint keeps its own ciphertext cache per epoch. - A phished derive signature is a permanent key. The EIP-712 message says so in the one field every wallet renders. kint never accepts that signature as a login and never sends it anywhere. - A space has one data key at a time. `kint rekey` rotates it: a new key, new wraps, one snapshot epoch under the new key, a new recovery code. Epochs sealed before the rotation stay readable to whoever held the old key; that is a property of any ledger, not something a rotation can undo. - A cold start stops at the newest snapshot epoch (`kint compact` writes one), so restore cost is bounded by the size of the memory, not its history. `kint pull --full` walks past snapshots for the older versions, as far back as the newest key rotation: epochs sealed under a retired key stay closed to a machine that only holds the current one. - kint-server holds every push it makes itself (error `HELD`, nothing anchored, nothing dropped) when a row the chain vouches for changed behind Sibyl's tools while it ran, or still holds a value `memory_verify` refused; `kint push` from a terminal is never held. The hold only sees edits made while kint-server runs and since its last successful pull or push: a row edited behind Sibyl's back while no server was running is anchored by the next automatic push unless `memory_verify` refused it first. - A hold stops the whole push, so every other unanchored change waits until the human runs `kint push` or `kint pull --discard-local`. Verifying your own unpushed edit to an anchored row refuses it as drifted and holds every server push: push an edit before you verify it. A second writer on the same store while kint-server runs (another harness's server, an SDK-direct process) also reads as drift. - A session key can append any bytes as an epoch. A pull steps past an epoch it cannot open when a later snapshot opens, and connect and rekey read the newest epoch whose header parses (up to 32 back). Otherwise the owner revokes the leaked key and anchors a snapshot over it with `kint compact --over-skipped` or `kint rekey --over-skipped` (terminal only). An epoch forged with a well-formed header still stops connect, rekey and the recovery code until that snapshot sits on top. - The exit push is best effort: a harness that kills the server within a second of closing it (Claude Code does) ends the push before its transaction lands. The next kint-server session pushes the leftover rows at its first quiet period, or `kint push` anchors them at once. - The cached data key (`KINT_KEY_TTL`, 24 hours by default) is renewed after every successful pull and push while kint-server runs; a machine that goes a whole TTL without one still lapses and needs `kint connect` again. - Two machines that both connect before the first push each create their own vault; the second opens nothing until it runs `kint connect` again, which then adopts the vault in the head epoch. - `kint pull --rebase` is not built; `kint pull --discard-local` is the way out of a fork. It moves the whole `memory.db` aside, so other tenants' rows in the same store end up only in the backup copy. - A push whose transaction lands but whose confirmation wait fails is later reported as another machine's push and a fork; the Epoch writer on Basescan is this machine's session key, and `kint pull --discard-local` recovers the same rows. - Tool results and kint's log lines keep only the scheme and host of an RPC URL; an error the CLI does not catch still prints a raw Python traceback on your own terminal. - `kint authorize page` loads the Base Account SDK from esm.sh with no integrity check. - kint is not on PyPI yet; install from the tagged release on GitHub. ## Prior work - **Sibyl Sovereign** (Sibyl Labs, in development): a cryptographic seal on every critical file, re-checked before every action, fail-closed, with an append-only audit trail. kint applies that mechanism one tier down, to the memory store itself, on Base. Same idea, theirs first. - **anima** (s0nderlabs, 0G): anchors a keystore root hash on chain, roots only. On 0G the storage primitive is native; on Base a root alone is a receipt for data you still have to store somewhere, which is why kint puts the ciphertext itself in calldata. - **ERC-8350** (Agent Memory State Registry, draft): the vocabulary of a per-space linear chain of committed deltas describes kint's epochs exactly. Reference shape only, not implemented. - The verify-a-local-artifact-against-an-anchor pattern exists in several hackathon entries; restoring the memory itself from the chain and showing a superseded belief do not. ## Partner stack Base: the contract, every epoch, and the authorization transactions above are executed on Base mainnet and shown in the demo. ## Develop ``` uv sync env -u PYTHONPATH .venv/bin/python -m pytest -q # 52 tests: vectors, fidelity, stdio MCP, anvil lifecycle, snapshots and rekey, review and audit regressions cd contracts && forge test # 29 tests incl. a Base mainnet fork scripts/e2e_cli_anvil.sh # the CLI, the way a human runs it scripts/e2e_cli_snapshot_anvil.sh # compact, rekey, a cold start and pull --full through the CLI cd js && bun install && bun test # the browser decrypt path against Python-generated vectors python3 scripts/build_docs.py --out web # the documentation site from docs/site/*.md ``` `js/` is `@s0nderlabs/kint-core`, the browser side (read epochs off Base, open them locally, nothing decrypted leaves the page); `scripts/gen_vectors.py` regenerates its test vectors from the Python. The documentation is markdown in `docs/site/` (one file per chapter), rendered by `scripts/build_docs.py` into static pages, per-page markdown, `llms.txt` and `llms-full.txt`. MIT. Built for the Sibyl Labs Hackathon, September 2026. --- ## Introduction > Source: https://github.com/s0nderlabs/kint/blob/main/README.md Wipe the machine, connect the wallet, and the agent comes back knowing what it knew, and proves it was not tampered with. kint keeps Sibyl Memory wallet-owned, on Base, and checked before the agent acts on it. ## What kint is kint is a drop-in MCP server for coding agents that keeps [Sibyl Memory](https://github.com/Sibyl-Labs/Sibyl-Memory) wallet-owned: encrypted to a key only the owner can produce, written to Base, restored on any machine the owner connects, and checked against the chain before the agent acts on a recalled row. This page covers what kint does in v0.3.0, the pieces it is made of, what is live on Base mainnet, and how the rest of these docs are laid out. ## The bet Sibyl decides what the agent remembers. kint decides where that memory lives and who can read it: the wallet. Sibyl Memory gives a coding agent five tiers, full-text search, eight MCP tools and one SQLite file. That file lives on one machine. kint is built on Sibyl Memory and sits under it: `kint-server` imports Sibyl's MCP server untouched (`build_server()` from `sibyl_memory_mcp.server`), keeps their eight tools unmodified, and adds six of its own. The agent keeps writing memory the way it always did. kint takes the rows that changed since the last anchored epoch, compresses them, pads them to a size bucket, encrypts them and appends them to a chain of epochs on Base. s0nderlabs runs no server in that path. The pieces are your machine, the owner wallet, a small contract on Base with no admin, and the RPC endpoints you read it through (`KINT_RPC_URL` and `KINT_RPC_URL_2` when you set them; otherwise public Base RPC endpoints, or an Alchemy key when the macOS Keychain holds one as `dev.api.alchemy`). Take Sibyl away and nothing is left to decide on. The decision step reaches the row through `multi_record_search`, the same gated search Sibyl's `memory_search` runs, and keys on its typed verdict. Without Sibyl an epoch is a decrypted blob with no query surface. Base holds the ciphertext; Sibyl is what turns it back into a store the agent can ask. [Built on Sibyl Memory](/docs/sibyl) walks through this with file and line. ## What it does today **Restore by wallet connect.** On a new or wiped machine, `kint connect` recovers the data key: an EOA owner signs one frozen EIP-712 message, a Base Account owner types the vault passphrase, and a recovery code covers the day both are gone. `kint pull` then walks the epochs back from the head. It checks each ciphertext's keccak against the digest in the contract's `Epoch` event and opens it under an AAD that binds chain id, owner, space, seq, prev, bucket, rows_root and dek_id. Each row is replayed through Sibyl's own write methods (`set_entity`, `set_state`, `set_reference`, `write_event`), then re-read and compared. A cold start reads the head from two different RPC operators and refuses if they disagree. An epoch that no key on the machine can open is a gap, unless a later snapshot in the same walk does open: then the pull records the skipped epoch and resumes at that snapshot, whose rows are the whole state. `kint-server` starts a pull as it starts, whenever the machine is connected and its cached data key has not expired, and begins serving after at most `KINT_BOOTSTRAP_SECONDS` (default 20) while a slower pull finishes behind it. **Verify before acting.** `memory_verify(query)` runs the same gated search Sibyl's `memory_search` runs, and refuses when Sibyl's verdict is anything but `ok`. It re-reads the exact stored text of every hit, hashes it, and proves it against the `rows_root` this machine last saw anchored. When the chain head has moved past what this machine last pulled, it refuses and says to pull first. It returns `proceed` or `refuse`. A row that drifted is refused, and the reason names the block that anchored the last good value and the head block this machine last saw. The refusal is written back as a Sibyl entity (category `kint_refusal`, status `refused`), so the next fresh session sees it too. A row written after the last anchored epoch is never counted as verified. `kint verify "release rule"` does the same from a shell. **History across an overwrite.** Because the chain keeps every version, `memory_history` (and `kint history`) lists what an entity, state or reference row held at every anchored epoch. With `block` it returns the version live at that block. Each version carries a block-height upper bound, "existed no later than block N", never a wall-clock time. Sibyl cannot answer this question on its own. **Snapshots and key rotation.** `kint compact` (or `memory_push(snapshot=true)`) anchors one epoch that holds the whole state, flagged `FLAG_SNAPSHOT` (0x02). A cold start stops at the newest snapshot, so restore cost is bounded by the size of the memory, not its history. `kint pull --full` (or `memory_pull(full=true)`) walks past snapshots and caches every older epoch it had not decrypted yet, for history. `kint rekey` rotates the data key: a new key, new wraps under the keys you supply, one snapshot epoch under the new key, and a new recovery code. Rekey is CLI only. There is no rekey tool: rotating needs the wallet signature or the vault passphrase, and `kint rekey` takes them in your own terminal, never in an agent chat. When a pull stops at an epoch that no key will ever open (a leaked session key can append one), the owner runs `kint compact --over-skipped` or `kint rekey --over-skipped` on that machine. It anchors a snapshot of the machine's store on top of the chain head, and every later pull resumes there. Neither flag is a tool argument. Writing needs no extra step from the agent. While `kint-server` runs on a connected machine, a watcher pushes unanchored changes once the store has been quiet for `KINT_QUIET_SECONDS` (default 300), or earlier when the estimated size of the change set passes `KINT_SIZE_TRIGGER_BYTES` (default 32 KiB); `memory_push` pushes on demand, and an exit hook tries once more when the server stops. Rows a session leaves unanchored are pushed by the next session at its first quiet period. `KINT_NO_WATCHER=1` turns the watcher off. The anchor always happens after the Sibyl write has returned, never inside it. Every push `kint-server` makes is held, with error `HELD`, when a row the chain vouches for changed behind Sibyl's tools while the server was running, or still holds a value `memory_verify` refused. Nothing is anchored, nothing is dropped, and `memory_status` says why. `kint push` from your own terminal is never held; it is the way past a hold once you have checked the store. ## The layers | Layer | Lives on | What it holds or does | |---|---|---| | Sibyl store | `~/.sibyl-memory/memory.db`, or `$SIBYL_MEMORY_DB` | Sibyl's own SQLite file, written only by Sibyl's code. kint reads it and replays into it through the SDK. | | kint-server | a stdio MCP process your harness starts | Sibyl's server with their eight tools unmodified, plus `memory_status`, `memory_connect`, `memory_pull`, `memory_push`, `memory_verify`, `memory_history`. | | Local state | `~/.kint`, or `$KINT_HOME` (mode 0700) | The machine's session key, the enrolment, the cached data key, the mirror of what was last anchored, a ciphertext cache per epoch, the freshness watermark, the head lock, the recovery code file and `kint.log`. | | EpochAnchor | Base mainnet, chain id 8453 | Per owner and space, a hash-linked chain of epochs. The ciphertext travels in `push` calldata; the contract keeps only the latest digest, seq and block per owner and space, and emits every epoch's digest in an `Epoch` event. | | kint-core | the browser | `@s0nderlabs/kint-core`, a TypeScript port of the decrypt path. It reads epochs off Base and opens them locally. It never writes to the chain. | Two more details hold the layers together. First, kint's own state lives outside every path Sibyl's cap accounting walks, and kint volunteers its footprint back into Sibyl's 5 MiB free-tier cap through Sibyl's public `CapGate(db_size_fn=...)` hook, so `kint status` shows Sibyl's bytes, kint's bytes and the total against the same cap. Second, a space is `keccak256("kint-space-v1" || tenant_id)`, so the Sibyl tenant decides which chain of epochs a machine reads and writes. Install puts two commands on your path: `kint`, the CLI a human runs, and `kint-server`, which your harness starts. kint is not on PyPI yet; [Quickstart](/docs/quickstart) has the install line. > **Note.** The signature an EOA owner signs to connect a machine is the key itself, not a login. It travels only on stdin (`kint connect --signature -`), never on argv and never through a tool. `memory_connect` does accept `passphrase` and `recovery_code` parameters, but anything typed into an agent chat lands in the transcript, so prefer the terminal: `kint connect --passphrase-stdin` or `kint connect --recovery-code-stdin`. ## Live on Base mainnet | What | Where | |---|---| | EpochAnchor (verified) | [`0xa22E03f7a4145Bf4909a83595C90a38E14d79600`](https://basescan.org/address/0xa22e03f7a4145bf4909a83595c90a38e14d79600), deployed at block 51081696 | | Demo tenant `kint-demo`, epoch 1 | [`0xbe9bb8596c825a8891c3eafe4381e0c6522d647641735c635ca58fcf4bbe4455`](https://basescan.org/tx/0xbe9bb8596c825a8891c3eafe4381e0c6522d647641735c635ca58fcf4bbe4455), block 51081867, 14 rows, 4 KB bucket | | Demo tenant `kint-demo`, epoch 2 | [`0x369907fb1bccc3222365b2e07b4d6d831642831d481a5f7cb99357823e2a9729`](https://basescan.org/tx/0x369907fb1bccc3222365b2e07b4d6d831642831d481a5f7cb99357823e2a9729), block 51081880 | | A Base Account owner authorizing a machine from the browser | owner `0xd3390EDAC3d0EB41248C132792B0DcC5f0b0D4E3`, then epoch [`0x9c64e2c21b294843888680bd34d6da8242cb5313bc894864552163e167049c12`](https://basescan.org/tx/0x9c64e2c21b294843888680bd34d6da8242cb5313bc894864552163e167049c12), block 51082012 | The contract has no owner, no admin, no pause and no upgrade path; a redeploy would be a new address and a new chain of epochs. A 4 KB epoch costs about 0.000002 ETH on Base (the EIP-7623 calldata floor at 40 gas per byte, 191,040 gas for the 4 KB bucket, plus the L1 data fee). Only the bucket is public, never the true length: 4096, 8192, 16384, 32768, 65536 or 98304 bytes. Restoring and reading never send a transaction. ## Who this is for kint is for you if you already run Sibyl Memory under a coding agent and want that memory to outlive the machine it was written on, owned by your wallet rather than by any one disk, and checked before the agent acts on it. Claude Code, Codex, Hermes and OpenClaw were each verified reading memory through `kint-server` on Sep 9 2026, and `kint setup` wires any of them. EOA owners (Ledger, MetaMask, Rabby, a keystore) sign once per machine. Base Account owners use a vault passphrase instead. Each machine writes through its own session key, which the owner authorizes with an expiry (30 days by default) and which holds a few cents of ETH on Base for gas. It is not the right tool if several machines need to write at the same moment. One wallet owns one memory, and many machines may write under it one at a time. Two machines writing at once is a fork: kint refuses and tells you, and `kint pull --discard-local` moves the local store aside and restores from the chain. There is no rebase. Base holds only ciphertext, but each epoch's size bucket is public, and the bucket bounds how many rows it can carry. [Limits and threat model](/docs/limits) lists the rest. ## How the docs are organized Five groups, sixteen pages. - **Get started.** This page, [Quickstart](/docs/quickstart) (install, connect, authorize, wire a harness) and [Restore on a new machine](/docs/new-machine). - **Concepts.** [How it works](/docs/how-it-works) follows one row from a Sibyl write to an epoch and back. [Keys and custody](/docs/keys) covers the signature, the passphrase, the recovery code and the session key. [Epochs on Base](/docs/epochs) covers the envelope, buckets, snapshots and the walk. [Verify before acting](/docs/verify) covers the decision and the refusal. - **Reference.** [MCP tools](/docs/tools), [CLI](/docs/cli), [Configuration](/docs/configuration), [EpochAnchor contract](/docs/contract) and [Browser core](/docs/kint-core). Every tool, command, flag and environment variable, read from the code. - **Operate.** [Harnesses](/docs/harnesses) for Claude Code, Codex, Hermes and OpenClaw, and [For agents](/docs/agents), the page an agent reads to run kint itself. - **Background.** [Built on Sibyl Memory](/docs/sibyl), what kint uses from Sibyl and what breaks without it, and [Limits and threat model](/docs/limits). kint 0.3.0 is MIT licensed and was built for the Sibyl Labs Hackathon, September 2026. Read [Quickstart](/docs/quickstart) next. Source: [`README.md`](https://github.com/s0nderlabs/kint/blob/main/README.md), [`CHANGELOG.md`](https://github.com/s0nderlabs/kint/blob/main/CHANGELOG.md), [`src/kint/server.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/server.py), [`src/kint/paths.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/paths.py), [`src/kint/verify.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/verify.py), [`src/kint/pull.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/pull.py), [`src/kint/push.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/push.py), [`src/kint/connect.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/connect.py), [`src/kint/chain.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/chain.py), [`contracts/src/EpochAnchor.sol`](https://github.com/s0nderlabs/kint/blob/main/contracts/src/EpochAnchor.sol), [`contracts/deployments/base-mainnet.json`](https://github.com/s0nderlabs/kint/blob/main/contracts/deployments/base-mainnet.json). --- ## Quickstart > Source: https://github.com/s0nderlabs/kint/blob/main/src/kint/cli.py This page takes one machine from nothing to a wallet-owned Sibyl store whose changes land on Base. The human part: sign the key message (or type the vault passphrase), authorize this machine, and fund its session key. ## Before you start You create a session key, connect the wallet, authorize the key, point your harness at `kint-server`, and watch the first epoch anchor. You need: - **Python 3.10 or newer** and `uv`. kint is not on PyPI yet; it installs from the tagged repository. - **A wallet that owns the memory.** Either an EOA that can sign EIP-712 typed data through foundry's `cast` (a Ledger with `--ledger`, a Trezor with `--trezor`, a foundry keystore with `--account `), or a Base Account (Coinbase Smart Wallet), which uses a vault passphrase instead of a signature. - **A little ETH on Base** for this machine's session key. It pays the gas for every epoch it anchors. `kint doctor` warns while the balance is at or below 0.00002 ETH, or while the key is not authorized. - **A Sibyl store.** kint wraps the file at `$SIBYL_MEMORY_DB`, else `~/.sibyl-memory/memory.db`. The global `--db PATH` flag overrides it for one command. - **A tenant.** The global `--tenant` flag wins, and it goes before the command: `kint --tenant my-tenant connect ...`. Without it kint takes `KINT_TENANT`, else `tenant_id` or `account_id` from Sibyl's `credentials.json` (`$SIBYL_CREDENTIALS`, else `~/.sibyl-memory/credentials.json`), else Sibyl's default tenant. The vault is keyed by owner and tenant together, so use the same tenant in the terminal and in the harness (step 5). Joining a vault that already exists from another machine? After the install, `kint join` does the session key, the connect, the restore and the harness registration in one command and writes nothing to the chain; see [One command: kint join](/docs/new-machine#one-command-kint-join). This page is the first machine, where the vault starts. ## 1. Install ```sh uv tool install git+https://github.com/s0nderlabs/kint@v0.3.0 ``` That puts two binaries on your PATH: `kint` (the CLI) and `kint-server` (Sibyl's MCP server with kint's tools added). If your shell exports `PYTHONPATH`, it shadows the tool's environment; run the binaries as `env -u PYTHONPATH kint ...`. For the same reason, `kint setup` keeps it out of every harness registration: Claude Code launches the server through `/usr/bin/env -u PYTHONPATH`, and Codex, Hermes and OpenClaw start it with `PYTHONPATH` set empty. ## 2. Create this machine's session key ```sh kint session-key create ``` ```text created: 0x fund it with a little ETH on Base and authorize it from the owner (kint authorize ...) ``` The key is a plain EOA stored as a V3 scrypt keystore at `~/.kint/session.key` (mode 0600). On macOS its passphrase lives in the Keychain item `dev.kint-session-key`, created on first use; on a server, set `KINT_SESSION_PASSPHRASE`. The session key signs Base transactions to EpochAnchor and nothing else. It can never decrypt the memory. A second `create` prints `exists: 0x...` and changes nothing; `kint session-key rotate` replaces the key and keeps the old keystore as `session.key.old`. Send a little ETH on Base to the printed address. `kint session-key show` prints JSON: the address, its balance and, once connected (or with `--owner 0x...`), whether it is authorized and its expiry as a unix time. ## 3. Connect the wallet Connecting derives this machine's key from the wallet and, on a fresh vault, generates the data key and writes the recovery code. ### An EOA owner Print the frozen EIP-712 payload, sign it with `cast`, and pipe the signature straight into `kint connect` on stdin: ```sh kint canonical-payload --owner 0xYOU > kint-canonical.json cast wallet sign --data --from-file kint-canonical.json --ledger | kint connect --owner 0xYOU --signature - ``` The message carries two fields, your address and a `purpose` string the wallet shows you: "kint-memory-v1: signing this reveals your memory encryption key. Only sign it in a kint terminal or page you opened yourself." On a fresh vault the output is: ```text connected: owner 0xYOU space key tag <8 hex> data key from fresh vault fresh vault: recovery code written to /.kint/RECOVERY-.txt copy that code somewhere that is not this machine; push refuses until the file exists session key: 0x ``` On a vault that already has epochs, on a machine with no key wraps of its own yet, `data key from` reads `chain head header`: the data key comes out of the head epoch's own header on Base, and no recovery code is written. When the head epoch is not a readable kint epoch (any authorized session key can append bytes that are not one), connect takes the wraps from the newest epoch before it whose header parses, looking back through at most the 31 epochs before the head. A fetch that fails, or calldata whose digest disagrees with the `Epoch` event, is never walked past: connect refuses instead. > **Warning.** The derive signature is the key itself. A leaked one opens every epoch this wallet has anchored, and it cannot be revoked. kint reads it only from stdin (`--signature -`) or from a file it reads and then deletes (`--signature-file PATH`), never from argv, and never through an agent tool. Never paste it anywhere else. Three optional flags in EOA mode: - `--passphrase-prompt` salts the wallet key with a passphrase (a second factor). The same passphrase is then needed alongside the signature to open the vault. - `--add-passphrase` prompts for an extra passphrase and adds a second wrap of the data key under it, so the vault also opens with that passphrase alone (on another machine, once the next epoch carries the new wrap in its header). - `--passphrase-stdin` reads the salt passphrase from stdin instead. It cannot share stdin with `--signature -`; kint refuses that combination and tells you to use `--passphrase-prompt` or `--signature-file`. ### A Base Account owner A passkey-owned account has no deterministic signature to derive a key from, so its vault key is a passphrase, stretched with scrypt and salted with the owner address: ```sh kint connect --owner 0xYourBaseAccount --smart-account ``` kint prompts `vault passphrase: `. To feed it from a pipe or a secrets manager instead, add `--passphrase-stdin`. The output has the same shape as above. The `memory_connect` tool also accepts a `passphrase` argument, but anything typed into an agent chat lands in the transcript; connect from the terminal. ## 4. Authorize the session key The owner tells EpochAnchor that this machine's session key may append epochs under its name, for `--days` days (default `30`). `kint authorize` needs a session key on this machine and a known owner (from `kint connect`, or `--owner 0x...`). Pick one mode: | mode | who sends the transaction | use it when | |---|---|---| | `direct` | the owner EOA, from `KINT_OWNER_KEY` | the owner key is at hand on this machine | | `page` | the Base Account, from a browser page | the owner is a Base Account | | `payload` then `submit` | the session key, carrying the owner's signature | the owner signs offline (a Ledger) and should not pay gas | | `burn-nonce` | the owner EOA, from `KINT_OWNER_KEY` | cancelling a signed authorization that was never submitted | **Direct.** kint reads the owner key from the environment, never argv, and refuses when it is not the owner's key (`KINT_OWNER_KEY is 0x..., not the owner 0x...`): ```sh KINT_OWNER_KEY=0x... kint authorize direct ``` ```text submitted 0x authorized 0x under 0xYOU; cost ETH, block ``` **Page, for a Base Account.** kint serves one page on `127.0.0.1` at a random port with a 32-byte token in the path, and prints the URL: ```sh kint authorize page ``` ```text open this page in the browser where your Base Account is logged in (owner 0xYourBaseAccount): http://127.0.0.1:/kint/ ``` On the page, connect the Base Account (it refuses any other address), then press Authorize. The account sends `setSessionKey` to EpochAnchor itself; if it has never sent a transaction, that one also deploys it. Nothing secret crosses the page. kint then polls `canWrite` on the chain every 3 seconds, up to 120 times, and prints `authorized: 0x may push under 0xYourBaseAccount until `. The page server gives up after 15 minutes without a result. The page loads the Base Account SDK from esm.sh at a pinned version (`@base-org/account@2.5.10`) with no integrity check, so that CDN is trusted for this one transaction; see [Limits and threat model](/docs/limits). **Payload, then submit.** kint prints the authorization typed data on stdout and the exact follow-up on stderr. The owner signs it; the session key submits it and pays the gas: ```sh kint authorize payload > kint-auth.json # nonce , deadline , expiry ; sign with: cast wallet sign --data --from-file # then: kint authorize submit --deadline --expiry --signature - cast wallet sign --data --from-file kint-auth.json --ledger \ | kint authorize submit --deadline --expiry --signature - ``` The deadline is 300 seconds after `payload` runs. The typed data uses its own EIP-712 domain (`kint EpochAnchor`, bound to the contract address), so an authorization signature can never double as the derive signature. On success: `authorized 0x under 0xYOU until ; cost ETH, block `. **Burn the nonce.** Every owner transaction to `setSessionKey` consumes the owner's authorization nonce, which voids any signed authorization still waiting to be submitted. `KINT_OWNER_KEY=0x... kint authorize burn-nonce` does exactly that without changing this key's expiry, and prints `nonce consumed: authNonce(0xYOU) is now `. Like `direct`, it refuses when `KINT_OWNER_KEY` is not the owner's key, before sending anything. ## 5. Point the harness at kint-server ```sh kint setup claude --env KINT_TENANT=my-tenant ``` The target is one of `claude`, `codex`, `hermes`, `openclaw` or `all`; `--env KEY=VALUE` (repeatable) is passed to the server process. Pass `--env KINT_TENANT=...` whenever you connected with `--tenant` or `KINT_TENANT`: the server resolves its own tenant and does not see the flag you gave the CLI. - `claude` registers `kint` at user scope and prints `claude: registered kint (user scope)`. If Sibyl's own server is also registered, remove it (`claude mcp remove -s user sibyl-memory`): one store, one server. For project scope, register by hand: `claude mcp add --scope project kint -e PYTHONPATH=x -e KINT_TENANT=... -- /usr/bin/env -u PYTHONPATH /abs/path/kint-server` (writes `.mcp.json`; Claude Code asks you to approve it until the folder is trusted). - `codex` appends `[mcp_servers.kint]` to `~/.codex/config.toml`, after copying the old file to `config.toml.bak-`. - `hermes` runs `hermes mcp add kint ...`; `openclaw` runs `openclaw mcp set kint ...`. When the `claude`, `hermes` or `openclaw` CLI is missing, that target is skipped with `: CLI not found, skipped`; `codex` only edits its config file, and prints `codex: already configured` when the block is already there. Each harness has quirks worth knowing; see [Harnesses](/docs/harnesses). `kint-server` lists its tools within `KINT_BOOTSTRAP_SECONDS` of starting (default 20), even when an RPC hangs: its first pull runs on its own thread and finishes behind the running server. Each RPC call gives up after `KINT_RPC_TIMEOUT` seconds (default 10) and is retried at most twice, with a short backoff, on a connection, HTTP or timeout error. ## 6. The first write Start a session and ask the agent to remember something. The write goes through Sibyl's own tool, unmodified, for example: ```text memory_remember(category="rules", name="release-gate", body={"rule": "never ship on a Friday"}) ``` Their eight tools run unmodified against the same store (the one difference is the cap they enforce, which now also counts kint's own footprint), and kint never touches the chain inside a Sibyl write. `kint status` now counts the row under `unanchored: changed, 0 deleted`. On a fresh vault nothing is anchored yet, so every row already in the store counts too. If the agent calls `memory_connect` on a machine that is not connected, it gets back the terminal commands from step 3 instead of an error. ## 7. The first push Three things push, and all three diff the store against the last anchored epoch: - **On quiet.** While `kint-server` runs, a watcher checks the store every `KINT_POLL_SECONDS` (default 15) and pushes once it has been quiet for `KINT_QUIET_SECONDS` (default 300). Once a minute it also estimates the pending change at 400 bytes a row and pushes early when that passes `KINT_SIZE_TRIGGER_BYTES` (default 32768). A server that starts with rows an earlier session left unanchored counts them as pending and pushes them at its first quiet period. When the server stops, on SIGTERM or SIGHUP included, it tries one more push after the running tool call has unwound; a harness that kills the process outright anchors nothing, and those rows wait for the next session. `KINT_NO_WATCHER=1` turns the watcher off. - **From the agent.** `memory_push` anchors now and returns `pushed`, `head_seq`, `head_digest` and the epochs. - **From a shell.** `kint push` (add `--dry-run` to see the counts first; `--confirmations` defaults to 2): ```text push: epoch 1, rows, 0 deletions, bucket B, B calldata anchored 1 epoch(s); head seq 1 epoch 1: tx 0x block bucket B rows del 0 cost ETH ``` A 4 KB epoch costs about 0.000002 ETH on Base. When nothing changed, push says `nothing to push: the store matches the last anchored epoch` and sends nothing. The watcher, the exit hook and `memory_push` run one check first. When a row the chain vouches for changed while `kint-server` was running without a write through Sibyl's tools (edited straight in SQLite, say), or still holds exactly the value a `memory_verify` refusal named, the push is held: error `HELD`, nothing anchored, nothing dropped. The message starts `refusing to anchor:`, names up to three rows, and ends by pointing you at `kint push` from your own terminal, or `kint pull --discard-local` to restore the anchored value. The watcher then waits for the store to change again instead of retrying. `kint push` and `kint compact` never run this check: once you have looked at the row, the terminal is how you anchor it. Push also refuses, and says why: - when the session key is not authorized; - when the data key cache has expired (`KINT_KEY_TTL`, default `24h`). `kint-server` moves that deadline forward after every successful pull and push, so it lapses only after that long without either. Run `kint connect` again; nothing is dropped; - when another machine pushed first. Run `kint pull`; the message names `kint pull --discard-local` for a store that holds unanchored changes; - when this machine created the vault but its recovery code file is missing; - when the last pull stopped at an epoch this machine could not apply ([Gaps](/docs/new-machine#gaps)). If that epoch will never open, because a leaked session key wrote it, revoke the key and run `kint compact --over-skipped` from this machine. It anchors a snapshot of this store on top of the chain head, and every later pull resumes there. The CLI exits 3 for a missing key, 4 when the chain moved, 5 for other refusals. ## 8. Check it ```sh kint status ``` ```text tenant space store owner 0xYOU data key cached last anchored on this machine: seq 1 block rows unanchored: 0 changed, 0 deleted chain head: seq 1 digest <16 hex> block (in step) contract 0xa22E03f7a4145Bf4909a83595C90a38E14d79600 session key 0x balance ETH authorized True cap accounting (the same 5 MiB free cap Sibyl enforces): sibyl stores kint state (mirror, epoch cache, keys; volunteered) enforced total of 5.00 MB ``` `in step` means this machine's mirror matches the chain head; otherwise it reads `NOT in step: pull or push`. From the agent, `memory_status` returns the same picture as JSON, and its `held` field carries the message, the reason (`watcher`, `exit`, `tool` or `status`) and the time while `kint-server` is holding a push, or `null`. `kint doctor` checks every moving part and prints one `[ok ]`, `[WARN]` or `[FAIL]` row each: Sibyl's packages, `~/.kint` and its mode, the Sibyl store, the enrolment, the cached data key, the recovery code, the primary and secondary RPC (keyed URLs are redacted), the contract's code, the session key's balance and authorization, the head against the mirror, unanchored changes, and whether `PYTHONPATH` is set, plus a failing placement row if `KINT_HOME` sits inside `~/.sibyl-memory`. It exits 1 if any row fails. ## Where the recovery code lands A fresh vault writes the recovery code to `~/.kint/RECOVERY-.txt` (under `KINT_HOME` when set), mode 0600: ```text kint recovery code for owner 0xYOU, space <64 hex> Keep this somewhere that is not this machine. It opens the vault without the wallet. ``` It is the second, independent way to the data key: `kint connect --owner 0xYOU --recovery-code-stdin` opens the vault with neither the wallet nor the passphrase, once there is something to check the code against (an epoch on the chain, or an epoch or data key cached on the machine). `memory_connect` also accepts a `recovery_code` argument, but anything typed into an agent chat lands in the transcript; use the terminal. Copy the code off the machine now. On the machine that created the vault, push refuses while the file is missing or empty; `kint recovery-code` writes it again from the cached data key. After the quickstart, `~/.kint` holds: ```text ~/.kint/ ├── session.key the session key (V3 scrypt keystore) ├── enrol-.json owner, tenant and key tags for this space ├── wraps-.json the data key, wrapped under each key that opens it ├── vault-.aes the cached data key, encrypted, with a TTL ├── RECOVERY-.txt the recovery code (fresh vault only) ├── mirror-.json what this machine last anchored ├── watermark-.json the newest head this machine has seen ├── head-.lock one pusher or puller per space ├── kint.log kint-server's log └── epochs// every epoch's ciphertext, plaintext and metadata ``` Every state file is written with mode 0600 and the directory is 0700; `kint.log` alone takes your umask's default mode, and the 0700 directory keeps it private. With `KINT_KEY_TTL=session` the data key stays in memory and no `vault-*.aes` is written. On a machine without the macOS Keychain (or with `KINT_NO_KEYCHAIN=1`) and without `KINT_SESSION_PASSPHRASE`, a `local.secret` file holds the passphrase that protects the session key and the data key cache. None of it sits under the paths Sibyl's cap accounting walks, and all of it is counted into that cap anyway (the `kint state` line above). Read [Restore on a new machine](/docs/new-machine) next. Source: [`src/kint/cli.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/cli.py), [`src/kint/connect.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/connect.py), [`src/kint/keys.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/keys.py), [`src/kint/page.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/page.py), [`src/kint/server.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/server.py), [`src/kint/push.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/push.py), [`src/kint/chain.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/chain.py), [`src/kint/paths.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/paths.py). --- ## Restore on a new machine > Source: https://github.com/s0nderlabs/kint/blob/main/src/kint/pull.py The disk is gone and the wallet is not. This page walks the restore end to end and, for each step, says what kint checks against Base and what makes it refuse. ## What you need The steps: install, a session key for this machine, `kint connect` again, `kint pull`. Restoring and reading never send a transaction; the one transaction on this page authorizes the new session key, and a machine that only restores and reads can skip it until it needs to write. You need: - **The owner.** The wallet address that owns the memory. An EOA owner signs the same frozen EIP-712 message again, a Base Account owner types the vault passphrase, and the recovery code opens the vault when neither is at hand. - **The tenant.** The memory lives in a space, `keccak256("kint-space-v1" || tenant)`, so a different tenant is a different memory. kint takes it from `--tenant` (given before the subcommand), else `KINT_TENANT`, else `tenant_id` or `account_id` in Sibyl's `credentials.json`, else Sibyl's default tenant. - **For writing only:** a little ETH on Base for this machine's session key. ## Install ```sh uv tool install git+https://github.com/s0nderlabs/kint@v0.3.0 ``` That puts `kint` and `kint-server` on your PATH (Python 3.10 or newer). If your shell sets `PYTHONPATH`, run them with it unset (`env -u PYTHONPATH kint ...`); `kint setup` keeps it out of every harness registration for that reason: Claude Code starts the server through `/usr/bin/env -u PYTHONPATH`, and Codex, Hermes and OpenClaw start it with `PYTHONPATH` set empty. The Sibyl store kint restores into is `$SIBYL_MEMORY_DB`, else `~/.sibyl-memory/memory.db` (`--db` overrides it for one command). kint's own state goes to `$KINT_HOME`, else `~/.kint`. ## One command: kint join `kint join` runs the read half of this page in one go: this machine's session key, connect, restore, and harness registration. It writes nothing to the chain and spends nothing. Give it the owner and exactly one secret: ```sh kint join --owner 0xYourBaseAccount --base-account # prompts: vault passphrase: kint canonical-payload --owner 0xYOU > kint-canonical.json cast wallet sign --data --from-file kint-canonical.json --ledger | kint join --owner 0xYOU --signature - kint join --owner 0xYOU --recovery-code-stdin ``` It prints what it is about to join first: the owner, the tenant and where that came from (`--tenant`, `KINT_TENANT`, `credentials.json` or Sibyl's default), the space, the contract and the store. Then, in order: 1. **Session key.** An existing one is kept; otherwise one is created. 2. **Vault.** It reads the head. With no epochs under that owner and tenant it stops and says so (pass `--tenant` if you anchored under another one); only `--new-vault` lets it start a vault here. 3. **Connect.** The same three ways in as `kint connect`, described below. 4. **Restore.** The same pull as `kint pull`, with `--discard-local` and `--full` passed through. 5. **Harnesses.** It registers kint-server with every harness it finds (`--setup claude`, `codex`, `hermes` or `openclaw` for one, `--no-setup` for none), always with `KINT_TENANT` pinned to the tenant it joined, plus any `--env KEY=VALUE`. 6. **Report.** `writes: on` when the session key is authorized and holds at least 0.00002 ETH, otherwise `writes: off until the session key is funded and authorized by the owner; reads work now` and the next step. A failure stops it with `join stopped at : `, and the exit code says where: 4 for a fork, 6 for a freshness refusal and 5 for another pull refusal, as `kint pull` uses them, and 2 when it stops before the restore. Every step reads the state first, so running it again resumes rather than starting over. Writing stays a separate, deliberate step: fund the session key and authorize it, as in the next section. The rest of this page is what each step does, by hand. On a wiped machine against the demo vault (a Base Account owner, `--no-setup`), it took 4.6 seconds: ```text $ kint --tenant kint-demo join --owner 0xd3390EDAC3d0EB41248C132792B0DcC5f0b0D4E3 --base-account vault passphrase: join: owner 0xd3390EDAC3d0EB41248C132792B0DcC5f0b0D4E3 tenant kint-demo (from --tenant), space bd2a3b5b8f3fb4c8 contract 0xa22E03f7a4145Bf4909a83595C90a38E14d79600, store ~/.sibyl-memory/memory.db this command writes nothing to the chain session key: 0xA43874410423cf3482Ec63193c94eB71106AA1FF (created; it signs epochs for this machine and can never decrypt) vault: head epoch 1 at block 51082012 connected: key tag 14e3812b, data key from chain head header pull: cold start, both RPCs agree: head seq 1, digest b64dcde033b03249, block 51082012 pull: applied epoch 1 (5 rows, 0 deletions) from block 51082012 restore: restored 1 epoch(s), 5 rows; head seq 1 at block 51082012; store root matches the anchored root harnesses: not registered (--no-setup) writes: off until the session key is funded and authorized by the owner; reads work now next: put a few cents of ETH on 0xA43874410423cf3482Ec63193c94eB71106AA1FF (Base), then kint authorize page ``` Run again on the same machine, it keeps the key (`(kept)`), takes the data key `from local wraps` and reports `restore: up to date`. ## A session key for this machine The old machine's session key went with its disk. Every machine signs its own Base transactions with its own key: ```sh kint session-key create ``` It prints `created: 0x...` and writes a V3 scrypt keystore to `~/.kint/session.key` (mode 0600). The keystore passphrase comes from `KINT_SESSION_PASSPHRASE` when it is set, else the macOS Keychain item `dev.kint-session-key` (created on first use), else a `local.secret` file under `~/.kint`. The key signs transactions to EpochAnchor and nothing else; it can never decrypt. Send it a little ETH on Base. Then the owner authorizes it on the contract. Until `kint connect` has run there is no enrolment on this machine, so pass `--owner`. `--days` sets the expiry (default 30). | command | who signs | what happens | |---|---|---| | `kint authorize payload`, then `submit` | the owner, over typed data | `payload` prints the `SessionKeyAuthorization` typed data on stdout and the exact `submit` line on stderr, with a deadline 300 seconds out; `submit` reads the signature on stdin and the session key sends `setSessionKeyBySig`, paying its own gas | | `kint authorize direct` | the owner key in `KINT_OWNER_KEY` | the owner sends `setSessionKey` itself; the key is read from the environment, never argv | | `kint authorize page` | a Base Account, in the browser | a loopback page where the account sends `setSessionKey`; kint then polls `canWrite` on the chain | For an EOA owner on a hardware wallet: ```sh kint authorize payload --owner 0xYOU > kint-auth.json cast wallet sign --data --from-file kint-auth.json --ledger | kint authorize submit --owner 0xYOU --deadline --expiry --signature - ``` `kint session-key show --owner 0xYOU` prints the address, the balance, whether it is authorized and until when (after `kint connect`, the owner comes from the enrolment). If the old machine was lost rather than wiped, its key stays authorized until its expiry. The contract's `revokeSessionKey(key)`, sent from the owner, drops it in the same block; kint v0.3.0 has no command for that call. Then consider `kint rekey` from a machine you trust ([Keys and custody](/docs/keys)). ## Connect again The same three ways in as on the first machine. An EOA owner (Ledger, MetaMask, Rabby, a foundry keystore) signs the frozen payload again, and the derive signature travels on stdin, never argv: ```sh kint canonical-payload --owner 0xYOU > kint-canonical.json cast wallet sign --data --from-file kint-canonical.json --ledger | kint connect --owner 0xYOU --signature - ``` A Base Account owner types the vault passphrase instead (`kint connect --owner 0xYourBaseAccount --smart-account`), and with neither at hand the recovery code opens the vault (`kint connect --owner 0xYOU --recovery-code-stdin`). Both have their own sections below: [The Base Account passphrase](/docs/new-machine#the-base-account-passphrase) and [The recovery code](/docs/new-machine#the-recovery-code). A fresh machine has no local key wraps, so the data key comes out of the chain. kint reads the head for this owner and space, finds that epoch's `Epoch` event at its block, fetches the push transaction's calldata, checks `keccak256(ciphertext)` against the event digest, and parses the header. If the head epoch's bytes are on the chain but do not parse as a kint header (an epoch a leaked session key wrote, say), kint walks back through `prevBlock`, at most 32 epochs, and uses the newest epoch whose header parses: those are the wraps a new machine can open. The header carries the data key wrapped once per key that opens the vault. kint derives this machine's key-encryption key from your signature or passphrase, compares its 16-byte tag with each wrap's tag, and unwraps only the one that matches. The wraps are saved under `~/.kint`, the data key is cached (encrypted at rest, 24 hours by default, `KINT_KEY_TTL`), and connect prints: ```text connected: owner 0x... space key tag data key from chain head header session key: 0x... ``` It never prints the signature, the key-encryption key or the data key. What makes it refuse: - No wrap matches: `this key (tag ...) does not open the vault anchored under 0x...: the wraps in the head epoch carry different tags. Wrong wallet, wrong passphrase, or wrong tenant.` The message ends by pointing at the recovery code. - A head epoch whose header parses but carries wraps none of your keys open gets that same refusal. Only a header that does not parse is stepped over; the way past the other kind is a snapshot anchored over it from a machine that still holds the data key ([Epochs on Base](/docs/epochs#an-epoch-nobody-can-open)). - The ciphertext does not hash to its event: `ciphertext digest does not match the Epoch event`. The RPC served bytes the event does not vouch for, and connect never walks past that. - The chain cannot be read: `cannot read the chain head: ...`, `cannot read the head epoch: ...` or `cannot fetch the calldata of epoch N: ...`. That is a transport failure, never a security verdict; try again or change the RPC. A walk back that finds no header that parses within 32 epochs also ends with `cannot read the head epoch`. If the vault was created with a passphrase salting the wallet key (`--passphrase-prompt`), give the same passphrase here or the tag will not match. `--passphrase-stdin` and `--signature -` cannot share stdin: use `--passphrase-prompt`, or `--signature-file`. > **Warning.** If connect prints `fresh vault: recovery code written to ...`, it found no epochs for this owner and tenant and started a new, empty vault. On a machine meant to restore, that means the wrong tenant or the wrong owner address, or `KINT_OFFLINE=1`, which skips the chain read. Stop, fix it, connect again with the chain reachable, then run `kint recovery-code` so the recovery file on this machine holds the real vault's code. `kint join` refuses in this case unless you pass `--new-vault`. ## Pull ```sh kint pull ``` Pull needs the cached data key (without it: "no data key on this machine: run `kint connect` ... first") and takes the head lock for the space, so two kint processes on one machine never pull or push the same space at once. It waits up to 30 seconds for the lock, then fails with `another kint process holds the head lock for this space`. `--owner` overrides the enrolled owner; `--force-scan` is accepted and passed through, but pull does not act on it in v0.3.0. The steps below run in the order the code runs them. ### Freshness: two endpoints on a cold start A cold start is a pull with no watermark for this space, which is every first pull on a fresh machine. kint reads the head (seq, digest, block) from the primary RPC and asks a second endpoint for the same head: - **Primary:** `KINT_RPC_URL`, else on macOS the Alchemy key in the Keychain item `dev.api.alchemy` (`KINT_NO_KEYCHAIN=1` turns that off), else `https://mainnet.base.org`. - **Second:** `KINT_RPC_URL_2`, else `https://base-rpc.publicnode.com` when the primary is `https://mainnet.base.org` (a trailing slash counts as the same URL), else `https://mainnet.base.org`. Every JSON-RPC call gives up after `KINT_RPC_TIMEOUT` seconds (default 10). A call web3 marks as safe to repeat is tried once more after 0.1 seconds on a connection error, an HTTP error or a timeout, so a dead endpoint is reported after two tries. The second must not be the same host, port and path as the first, or pull refuses before asking. If the two disagree on seq or digest, kint waits 3 seconds and asks the second once more; still different, it refuses with `two RPCs disagree on the head: ...; refusing to restore from a contested head`. If the second cannot be reached, it refuses with `could not get a second opinion on the head from ...`. `KINT_ALLOW_SINGLE_RPC=1` accepts one RPC when the second is the same endpoint or unreachable, and says so in the output; it never overrides a disagreement. Wherever kint reports an error (a tool result, a log line, a CLI message), every URL in it keeps its scheme, host and port and loses the rest (`https://host/`), and the path and query of each endpoint kint is configured with are scrubbed wherever they appear, so an API key in the URL stays out. kint-server logs an unexpected push, pull or verify failure as its error type and the redacted message, never a raw traceback; the CLI still prints a Python traceback, on your own terminal, for an error it does not catch. After the first applied epoch, the watermark (seq, digest, block) exists and never decreases. Every later pull refuses a head older than it, or the same seq with a different digest: `... stale or lying RPC, refusing`. ### The fork check This runs before anything is written. `kint connect` leaves an empty mirror, this machine's record of what the chain last saw. If the Sibyl store here already holds rows for this tenant (Sibyl was used on this machine before kint, say), those rows were never anchored and the chain has moved past the mirror, so pull refuses: `the chain moved to seq N and this store has X changed and Y deleted rows that were never anchored: a fork.` A store with rows and no mirror at all is refused too (`Refusing to merge blindly`). The same refusal protects a machine that wrote locally while another machine anchored: its push fails because the head moved, and its pull refuses instead of losing the local rows. `kint pull --discard-local` resolves it. It moves `memory.db`, `memory.db-wal` and `memory.db-shm` aside to `.kint-backup-` next to the store (moved, not deleted) and restores from the chain. The refusal says as much: push is impossible because the head moved, and pulling would lose the rows, so save what you need out of the store yourself before you discard. A rebase (a row-level merge over a fork) is not built ([Limits and threat model](/docs/limits)). ### The walk kint walks backwards from the head through the `prevBlock` field of each `Epoch` event, one exact-block `eth_getLogs` per epoch, so no RPC range cap is ever hit. It stops at the newest snapshot epoch (header flag `0x02`, written by `kint compact`, `memory_push(snapshot=true)` and every `kint rekey`), because a snapshot's rows are the whole state. Then it applies the epochs oldest first. When it starts at a snapshot, the epochs before it are never opened; the prevBlock links from the head vouch for the snapshot's place in the chain. ### What every epoch must pass In this order. The first failure makes the epoch a gap (see [Gaps](/docs/new-machine#gaps)). 1. **Continuity.** The event's `prev` equals the digest of the epoch applied just before it. 2. **Ciphertext.** From the local epoch cache only if it still hashes to the event digest; otherwise from the push transaction's calldata. That transaction must be addressed to EpochAnchor and call `push`, its decoded owner, space and prev must match the event, and `keccak256(ciphertext)` must equal the event digest. 3. **Header.** It parses as envelope version 1. 4. **Key.** The cached data key's id (the first 8 bytes of HMAC-SHA256 keyed with the data key over `kint-dek-id-v1`) equals the header's `dek_id`. 5. **AEAD.** The ciphertext is exactly the bucket plus a 16-byte tag, and AES-256-GCM opens it under the AAD `keccak256(abi.encode(chainId, owner, space, seq, prev, bucket, rows_root, dek_id))` with chain id 8453. A byte changed after anchoring already failed step 2; a ciphertext altered before it was anchored fails here, and so does an epoch replayed under another owner, space or position. 6. **Plaintext.** It unpads, decompresses and parses as version 1; its seq, space and prev agree with the chain, and its `rows_root` agrees with the header. 7. **Snapshot flag.** The header flag agrees with the plaintext's `snapshot` key. The flag is not covered by the AAD, so the two must agree before either is acted on. Then the rows are replayed, and two more checks run on the result: 8. **Leaf re-read.** Every replayed row is read back from the store and its leaf (keccak256 over the exact stored text) compared with the anchored one: `stored text differs from the anchored leaf after replay`, or `row missing after replay`. 9. **Running root.** The merkle root of this machine's picture after the epoch equals the header's `rows_root`. A failure at 8 or 9 is a warning, not a gap: the epoch is applied, pull prints a `pull: WARNING epoch N: ...` line for it, and the summary counts the warnings. At the end kint exports the whole store once more and compares its root with the mirror's and the anchored one; that is the `store root matches the anchored root` in the summary line. ### Replay through Sibyl's write methods Rows go back into the store through the SDK, never raw SQL: `set_entity`, `set_state`, `set_reference` and `write_event`. Deletions run first. Only entities have an SDK delete (`delete_entity`); a deletion in another tier is reported as `no SDK delete for this tier`. | on replay | what happens | |---|---| | row content: key, category, status, body, reference metadata | exact; it is what the leaf hashes | | the journal `ts` | survives: passed to `write_event` explicitly, and part of the leaf | | uuids, a journal event's id included | regenerate; a journal row's identity is its content, and two identical events stay two rows | | entity, state and reference `updated_at` | regenerate; not part of the leaf | A snapshot epoch replaces the local picture: rows it does not hold are deleted where the SDK can delete them, and only rows whose stored text differs are written, so an append-only journal event is never written twice. ### What it prints ```text pull: cold start, both RPCs agree: head seq , digest <16 hex>, block pull: applied epoch ( rows, deletions) from block restored epoch(s), rows; head seq at block ; store root matches the anchored root cold start: two RPCs agreed on the head: True ``` After that come one `SKIPPED` line per gap and one `CLOSED` line per epoch sealed under a retired key. Exit codes: 4 for a fork, 6 for a freshness refusal, 5 for any other refusal (a missing data key included). A gap is not an error: pull exits 0 and prints the `SKIPPED` line. The demo tenant `kint-demo` has two ordinary epochs, epoch 1 with 14 rows at [block 51081867](https://basescan.org/tx/0xbe9bb8596c825a8891c3eafe4381e0c6522d647641735c635ca58fcf4bbe4455) and epoch 2, the release rule rewritten, at [block 51081880](https://basescan.org/tx/0x369907fb1bccc3222365b2e07b4d6d831642831d481a5f7cb99357823e2a9729); neither is a snapshot, so a cold start of that tenant walks and applies both. ### What the machine now holds ```text ~/.kint/ ├── session.key this machine's session key ├── enrol-.json owner, tenant, account kind, key tags ├── wraps-.json the key wraps, taken from the head epoch's header ├── vault-.aes the data key, encrypted at rest, with a TTL ├── mirror-.json what this machine believes the chain last saw ├── watermark-.json the freshness floor: seq, digest, block ├── head-.lock one pusher or puller per space per machine └── epochs/ └── / ├── 00000001.bin the ciphertext, as read from calldata ├── 00000001.json the decrypted plaintext (history reads this) └── 00000001.meta.json seq, digest, prev, block, tx, bucket, rows_root, writer ``` `` is the first 16 hex characters of the space id. Every file is written with mode 0600. ## Snapshots and --full Stopping at the newest snapshot bounds restore cost by the size of the memory, not its history. The price is history on this machine: `kint history` and `memory_history` read the local epoch cache, so versions older than the snapshot are not there. The log says so: "pull: starting at snapshot epoch N; the epochs before it are not needed to restore (`kint pull --full` walks them for the older versions)". `kint pull --full` walks past snapshots to the first epoch. On a machine that already restored, it backfills: it walks from the head to the first epoch and fetches, checks and caches every epoch this machine has not decrypted yet, including the diff epochs an earlier pull jumped when it stopped at a newer snapshot, and it leaves the store and the mirror alone (`N older epoch(s) cached for history`). When every epoch is already cached or reported, it returns without walking. An epoch it cannot open is listed as `CLOSED` with its reason, and the walk carries on past it. The backfill runs only when the pull itself ended without a gap. A snapshot flag cannot cut a restore short. If the epoch the walk stopped at claims to be a snapshot and does not open, pull logs `pull: epoch N claims to be a snapshot but cannot be applied; walking the full history instead`, applies the epochs before it, stops there, and reports the bad epoch as a gap. ## Gaps When an epoch fails a check, the pull stops there. Nothing after it is applied, the mirror stays at the last applied epoch, and the epoch is recorded as skipped: ```text SKIPPED epoch block : ``` The next pull retries from that point: a transient RPC failure heals itself, a wrong key does not (connect again). Until the gap applies, push refuses (`this machine could not apply epoch(s) [N] on its last pull, so its picture of the memory is incomplete`) and verify refuses (`this machine's picture of the memory is not the one the chain vouches for`). A partial picture is never anchored over or acted on. One kind of failure does not stop a walk that goes past snapshots (`--full`). When this machine cannot open the epoch at all (its header does not parse, no key here opens it, or AES-GCM refuses it) and a later snapshot in the same walk opens, that snapshot carries the whole state, so the pull resumes there and ends complete: ```text SKIPPED epoch block : (snapshot epoch carries the whole state, the pull carried on) ``` A read failure (the RPC would not serve the calldata, served calldata that disagrees with the event, or the prev links break) and an epoch that opened and contradicts the chain always stop the pull, because nobody has seen what is in that epoch. An epoch that will never open, with no later snapshot, stays a gap until the owner anchors a snapshot over it; the push refusal names `kint compact --over-skipped` ([Epochs on Base](/docs/epochs#an-epoch-nobody-can-open)). ## Epochs sealed under a retired key `kint rekey` rotates the data key and anchors one snapshot epoch under the new one ([Keys and custody](/docs/keys)). A new machine gets the new key from the head header, and the walk starts at the rotation's snapshot (or a newer one). The epochs before it carry a different `dek_id`, and this machine holds no key for them. With `--full` on a cold start they are listed and left closed: ```text CLOSED epoch block : sealed under a data key this machine does not hold (rotated) ``` and the summary ends `N older epoch(s) sealed under a retired key stay closed on this machine`. They stay readable to whoever held the old key: a rotation protects what comes next, not what is already on a public ledger. The old recovery code stops opening the vault from the rotation's snapshot on. ## The recovery code The recovery code is the data key itself: base32 of the key plus a 2-byte SHA-256 checksum, in groups of four. The machine that created the vault wrote it to `~/.kint/RECOVERY-.txt`, and every `kint rekey` writes a new one. ```sh kint connect --owner 0xYOU --recovery-code-stdin ``` The checksum catches a typo (`recovery code checksum failed (typo?)`). Then the code is bound to this vault: its key id must equal the `dek_id` of the newest readable epoch (normally the head), or connect refuses with `this recovery code does not belong to the vault anchored under this owner`. The wraps come from the head header, so this machine can still push later, and connect reports `key tag none` and `data key from recovery code`. A recovery code cannot start a vault: with no epochs on the chain and nothing cached here, it refuses. A restored machine does not hold the recovery file. `kint recovery-code` writes a copy from the cached data key, and refuses when that key was rotated on another machine. Push only insists on the file on the machine that created the vault. ## The Base Account passphrase A Base Account (Coinbase Smart Wallet) has no deterministic derive signature, so its vault key comes from a passphrase: scrypt over the passphrase, salted with the owner address (N = 2^17, r = 8, p = 1), then HKDF. ```sh kint connect --owner 0xYourBaseAccount --smart-account # prompts: vault passphrase: kint session-key create kint authorize page kint pull ``` A wrong passphrase is refused by the key tag before any decrypt: the tag is compared first, and a wrap is never trial-decrypted. `--passphrase-stdin` reads the passphrase from stdin instead of the prompt. `kint authorize page` serves one page on 127.0.0.1 with a random port and a 32-byte token in the path. The page connects the account, checks it is the expected owner, and the account sends `setSessionKey` itself (on an account that has never sent a transaction, that one also deploys it). kint waits up to 15 minutes for the page's result, then polls `canWrite(owner, key)`. No passphrase, signature or key crosses the page. On Base mainnet, Base Account `0xd3390EDAC3d0EB41248C132792B0DcC5f0b0D4E3` authorized a machine this way, and that machine then anchored [an epoch under it at block 51082012](https://basescan.org/tx/0x9c64e2c21b294843888680bd34d6da8242cb5313bc894864552163e167049c12). `memory_connect` also accepts `passphrase` and `recovery_code` (with `owner` the first time). Prefer the terminal: anything typed into an agent chat lands in the transcript. ## kint-server pulls before it serves `kint-server` builds Sibyl's server with their eight tools unmodified plus kint's six, starts the startup pull on its own thread, and waits for it up to `KINT_BOOTSTRAP_SECONDS` (default 20). Then it starts the quiet-period watcher and serves on stdio. A pull still running at that point (a slow cold start, a slow RPC) finishes in the background. Until it does, Sibyl's tools answer from the store as far as the pull has got, `memory_pull` and `memory_push` wait for its head lock (up to 30 seconds), and `memory_verify` refuses while the chain head is past this machine's mirror. A refusal never keeps it from serving: | state at start | what the server does | |---|---| | no enrolment | serves; `memory_connect` returns the terminal steps | | data key not cached, or expired | serves without pulling | | a fork | logs the refusal and serves the local store | | a freshness refusal | logs it and serves | | any other pull failure | logs it and serves the local store | | a pull still running after `KINT_BOOTSTRAP_SECONDS` | logs `bootstrap: still running; serving now, the pull finishes in the background` and serves | Logs go to stderr and `~/.kint/kint.log`, never stdout, which the MCP transport owns. So a wiped machine can come back two ways: connect and pull in the terminal, then start the harness; or connect in the terminal and let the first `kint-server` start do the cold start. The server serves within about `KINT_BOOTSTRAP_SECONDS` of starting however slow the RPC is, so set a harness's startup timeout above that (Codex: `startup_timeout_sec`), and give `memory_pull` room with its tool timeout (`tool_timeout_sec`). Inside a session, `memory_pull` runs the same code with `discard_local` and `full`, and returns `FORK`, `NOT_FRESH` or `PULL_FAILED` where the CLI returns an exit code. [Harnesses](/docs/harnesses) covers `kint setup`. ## Check it ```text $ kint status owner 0xC635e6Eb223aE14143E23cEEa9440bC773dc87Ec data key cached last anchored on this machine: seq 2 block 51081880 rows chain head: seq 2 digest 7df5378a8c4fa866 block 51081880 (in step) contract 0xa22E03f7a4145Bf4909a83595C90a38E14d79600 ``` Those are three of `kint status`'s lines on the demo tenant: the mirror and the head agree. Then `kint verify "release rule"` (or `memory_verify` from the agent) searches through Sibyl's gated search, checks that the chain head is still the one this machine pulled, re-reads each hit's stored text and proves it against the anchored `rows_root`, and `kint doctor` checks every moving part, including whether the second RPC is the same as the first. What the restored agent does with a row it recalls is [Verify before acting](/docs/verify). ## See the demo memory The demo tenant from the launch film is a purpose-built set of fixtures on Base mainnet, so its vault passphrase is public: `an0Co1E8--gzFk8VNoGTX6lS`. Anyone can open it, two ways, both read-only: a write needs a session key funded and authorized by the owner wallet, and the passphrase is not that. **In the browser.** Open [the app](/app), press **Fill the demo lane** (owner `0xd3390EDAC3d0EB41248C132792B0DcC5f0b0D4E3`, tenant `kint-demo`) and type the passphrase. The page reads the epochs off Base, checks each ciphertext against the digest in its `Epoch` event, opens them in the tab and shows every row with its anchored versions. Nothing decrypted leaves the page ([kint-core](/docs/kint-core) is the code it runs). **In a terminal, the cold-start beat from the film.** On any machine: ```sh uv tool install git+https://github.com/s0nderlabs/kint@v0.3.0 kint join --owner 0xd3390EDAC3d0EB41248C132792B0DcC5f0b0D4E3 --base-account --tenant kint-demo --db /tmp/kint-demo.db --no-setup kint --tenant kint-demo --db /tmp/kint-demo.db status ``` `join` prompts `vault passphrase:`, pulls the epochs, checks each against its `Epoch` event, replays the rows through Sibyl's own write methods and reports `store root matches the anchored root`. It writes nothing to the chain and registers no harness; `--db` keeps the restore out of your own Sibyl store. The contract, the owner's epochs and their digests are on [BaseScan](https://basescan.org/address/0xa22E03f7a4145Bf4909a83595C90a38E14d79600). Read [How it works](/docs/how-it-works) next. Source: [`src/kint/pull.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/pull.py), [`src/kint/restore.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/restore.py), [`src/kint/connect.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/connect.py), [`src/kint/epoch.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/epoch.py), [`src/kint/server.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/server.py), [`src/kint/cli.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/cli.py), [`src/kint/chain.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/chain.py). --- ## How it works > Source: https://github.com/s0nderlabs/kint/blob/main/src/kint/server.py A row starts as an ordinary Sibyl write, gets anchored on Base inside an encrypted epoch, comes back on another machine through Sibyl's own write methods, and is checked before the agent acts on it. ## Four paths This page follows one row through those four paths (write, anchor, restore, read and decide), then names the pieces they share and every file kint keeps on disk. ```text agent (Claude Code, Codex, Hermes, OpenClaw) │ MCP over stdio ▼ kint-server ├── Sibyl's eight tools, unmodified ──┐ └── kint's six tools ─────────────────┤ one MemoryClient, one cap gate ▼ Sibyl's store: $SIBYL_MEMORY_DB or ~/.sibyl-memory/memory.db │ anchor: diff against the mirror, after the write returned ▼ EpochAnchor on Base: one transaction per epoch, ciphertext in calldata │ restore: walk, check, decrypt, replay through Sibyl's write methods ▼ the same store on the next machine │ read and decide: Sibyl's search, then each hit's leaf against the mirror ▼ proceed, or refuse and write the refusal back ``` ## Write: Sibyl's tools, unchanged `kint-server` calls `build_server()` from `sibyl_memory_mcp.server` and gets Sibyl's eight tools exactly as shipped: `memory_remember`, `memory_recall`, `memory_search`, `memory_list`, `memory_forget`, `memory_set_state`, `memory_get_state` and `memory_record_event`. Then it adds six of its own: `memory_status`, `memory_connect`, `memory_pull`, `memory_push`, `memory_verify` and `memory_history`. When the agent calls `memory_remember`, Sibyl's tool calls `set_entity` on its client, and the row lands in Sibyl's SQLite file in Sibyl's format. kint changes nothing in that write. On the one client it builds, kint wraps `set_entity`, `archive_entity`, `delete_entity`, `set_state` and `set_reference` so that, once a write has returned, it re-reads that row and notes its leaf: that is how kint-server tells a write through the tools from an edit behind them ([When a push is held](#when-a-push-is-held)). The store is `$SIBYL_MEMORY_DB`, or `~/.sibyl-memory/memory.db` when that is unset. The tenant is `KINT_TENANT`, falling back to the `tenant_id` and then the `account_id` in Sibyl's credentials, and last to Sibyl's default tenant. The only kint code that runs during a Sibyl write is the size function inside the [cap gate](#the-cap-gate): Sibyl's own measurement plus a sum of the file sizes under `KINT_HOME`. Nothing touches the chain, an RPC or a signer inside a Sibyl write transaction. ## Anchor: push, after the write returned Push reads the store after Sibyl has committed, and it never runs once per write. Inside kint-server every push (the watcher's, the exit hook's and `memory_push`) first passes the [drift hold](#when-a-push-is-held); `kint push` and `kint compact` in a terminal do not. One push goes like this: 1. It takes the [head lock](#the-head-lock) for the space. 2. It refuses when this machine is not connected; when the machine created the vault but its recovery code file is gone; when the data key is missing or expired ("nothing was dropped, the changes stay in the store until the next push"); when no key wraps are recorded for the space; or when the last pull left a gap. The one way past a gap is the owner's `--over-skipped` snapshot, from a terminal ([Epochs on Base](/docs/epochs#an-epoch-nobody-can-open)). 3. It exports every row this tenant holds in the four tiers the SDK writes (entities, state, reference, journal), reading the store with read-only SQL in rowid order. 4. It diffs those rows against the [mirror](#the-mirror-and-the-watermark). A row counts as changed when its leaf (a hash of its exact stored text) differs from the leaf the mirror holds, and as deleted when the mirror has it and the store does not. An empty diff stops here with `nothing to push: the store matches the last anchored epoch`, and no RPC is called. A snapshot (`kint compact`, `memory_push(snapshot=true)`, `kint rekey`) skips that stop and carries every row, even when nothing changed. 5. It reads the chain head. If that is not the head the mirror recorded, another machine has pushed, so it refuses (`CHAIN_MOVED`): run `kint pull` first, or `kint pull --discard-local` when unanchored local changes make that pull a fork. Under `--over-skipped` the snapshot chains on the chain head instead. 6. It checks on chain that this machine's session key may write for the owner. 7. It splits the change set so that each epoch's measured gzip size stays under 90 KB. A single row too large to fit one epoch on its own is named in a refusal before anything is sent, and push refuses until that row is shrunk or split in Sibyl. A snapshot is never split. 8. For each epoch it computes the merkle root of the full state after that epoch, then compresses, pads to a size bucket, and encrypts under the data key. It sends one transaction from the session key and waits for its confirmations (2 by default). It checks that the `Epoch` event's digest is the keccak of what was sent. Finally it caches the epoch, saves the mirror and raises the watermark. The epoch format, and what Base can see of an epoch, are covered in [Epochs on Base](/docs/epochs). > **Note.** Push anchors whatever the store holds when the diff runs, and a push from your terminal does exactly that. kint-server's own pushes are held instead when a row the chain vouches for changed behind Sibyl's tools while the server ran, or when verify already refused the value a row now holds ([When a push is held](#when-a-push-is-held)). An edit made while no server was running, and never refused, still goes out with the next session's first push. Checking a row against what the chain vouched for is the job of [read and decide](#read-and-decide-verify), which is why the agent runs that step before acting. ### When push runs | Trigger | What starts it | |---|---| | Quiet | The store changed and then stayed unchanged for `KINT_QUIET_SECONDS` | | Size | The estimated unanchored change (400 bytes per changed or deleted row) passed `KINT_SIZE_TRIGGER_BYTES`. This is checked at most once a minute while the store is dirty, however busy the agent is | | On demand | `memory_push` or `kint push`, or a snapshot: `memory_push(snapshot=true)` or `kint compact` | | On exit | kint-server exits normally or receives `SIGTERM` or `SIGHUP`. The signal handler only unwinds, so nothing touches the chain inside a Sibyl write; then an exit hook makes one best-effort push, when this machine is connected, the data key is cached and the push is not held | The quiet and size triggers come from the watcher, a background thread that kint-server starts after its startup pull. Every `KINT_POLL_SECONDS` the watcher reads the modification time of the store and of its `-wal` file, and any change marks the store dirty. The watcher pushes nothing while this machine is not connected or the data key is not cached. Some writes change nothing that needs anchoring (a pull's replay also moves the modification time), so when the once-a-minute diff finds nothing unanchored, the watcher clears the dirty mark. At the defaults, the size trigger fires at 82 changed or deleted rows. | Variable | Default | Meaning | |---|---|---| | `KINT_QUIET_SECONDS` | `300` | Seconds without a store change before the watcher pushes | | `KINT_POLL_SECONDS` | `15` | How often the watcher looks at the store. Values below `1` become `1` | | `KINT_SIZE_TRIGGER_BYTES` | `32768` | The estimated unanchored size at which the watcher pushes without waiting for quiet | | `KINT_NO_WATCHER` | unset | `1` starts kint-server without the watcher. The exit push still runs | If a value is not a number, kint logs a warning and uses the default. The other variables are listed in [Configuration](/docs/configuration). Two pushes never overlap. Inside one server, a second request gets `BUSY` ("a push is already running"). Between processes on one machine, the head lock makes them take turns. The exit push is best effort. A server killed before its exit push lands anchors nothing, and Claude Code gives a closing server about half a second before it kills it, less than a push with its confirmations takes, so under Claude Code the exit push rarely completes. The rows it missed stay in the store, unanchored, and the next kint-server session over that store picks them up: its watcher starts dirty, logs `watcher: N changed and M deleted row(s) were left unanchored by an earlier session; they are pushed at the next quiet period`, and pushes them once the quiet period passes. `kint push` anchors them at once. ### When a push is held kint-server follows every row its own tool path writes. Sibyl's eight tools and kint's six share one client, and the wrapped write methods move a row's baseline to the leaf that was just written. The baseline becomes the whole store again when the server starts and after every successful pull and push. Before a server-side push (the watcher, the exit hook, `memory_push`), kint looks at every row whose stored leaf differs from the leaf the mirror says was anchored, and holds the push when either is true: - a `kint_refusal` entity in the store names exactly the value the row holds now, so `memory_verify` already refused it; - the row changed, or an anchored row vanished, while this server ran, and no write through its tool path explains it (an edit straight into SQLite, say). A row that was never anchored is not drift. A store kint cannot read holds the push too, because an unchecked push is what the hold exists to stop. A held push anchors nothing and drops nothing: ```json {"ok": false, "error": "HELD", "message": "refusing to anchor: 1 row(s) changed behind Sibyl's tools while this server was running (entity rules/release-gate). Anchoring would make the chain vouch for a value kint cannot account for. Check the store, then `kint push` from your own terminal (or `kint pull --discard-local` to restore the anchored value).", "hint": "nothing was anchored and nothing was dropped; memory_verify still refuses the row"} ``` The log gets `push HELD (): ` once per distinct message, and the watcher waits for the next store change instead of retrying. `memory_status` works the hold out again on every call and returns it as `held`: `{"message", "reason", "at"}`, where `reason` names what tried to push (`watcher`, `exit`, `tool`, or `status` when the status call found it first), or null when nothing is held. The way out is yours to take. `kint push` from a terminal is never held: check the store, then anchor it as it is. `kint pull --discard-local` moves the store aside and restores the anchored values; the unanchored rows, a refusal entity included, stay in the `.kint-backup-` copy. The hold only knows this server's own tool path. A write into the same store from another process (a second session's kint-server, an SDK-direct agent) looks like an edit behind Sibyl's back, so this server holds until that process's push anchors the row, or until you run `kint push`. An edit made while no server was running is not seen at all: the next session's baseline starts from the edited store, and its first push anchors the edit unless verify refused it first. ## Restore: pull, through Sibyl's write methods A pull runs in three places. kint-server starts one when it starts, if this machine is connected and the data key is cached, and waits for it up to `KINT_BOOTSTRAP_SECONDS` (default 20) before it serves. A pull still running by then finishes in the background (`bootstrap: still running; serving now, the pull finishes in the background`), and every RPC call gives up after `KINT_RPC_TIMEOUT` seconds (default 10), so a hung endpoint cannot keep the tools from being listed. The agent can call `memory_pull`, and a human can run `kint pull`. If the startup pull hits a fork, a freshness refusal or any other failure, kint logs it and the server serves the local store as it is. Pull takes the [head lock](#the-head-lock) and refuses when this machine holds no data key. Then the checks run in this order, each one before the next: 1. **Freshness.** The watermark is a floor. A pull refuses a head older than the one this machine already saw, or one with the same seq and a different digest, as a stale or lying RPC. When there is no watermark (a cold start), a second RPC that is a different endpoint must agree on the head's seq and digest, or the pull refuses. `KINT_ALLOW_SINGLE_RPC=1` accepts a single RPC instead. 2. **Fork.** If the store already holds rows while this machine has no mirror, pull never merges them blindly. If the store has unanchored changes while the chain moved, that is a fork, and pull refuses rather than lose them. `kint pull --discard-local` (`memory_pull(discard_local=true)`) moves the store file and its `-wal` and `-shm` files aside to timestamped `.kint-backup-` copies and restores from the chain. 3. **Walk.** The walk starts at the head and goes back through the `prevBlock` field of each `Epoch` event, one exact-block log query per epoch, down to the epoch this machine already holds. Without `--full` it also stops at the newest snapshot epoch, whose rows are the whole state. `--full` (`full=true`) walks past snapshots, and afterwards caches, for history, every epoch from the head to the first that this machine has not decrypted yet. 4. **Each epoch.** Pull checks prev continuity, then takes the ciphertext from the epoch cache or the transaction's calldata (a `push` call whose owner, space and prev match the event) and trusts it only when its keccak equals the event digest. It checks the header and the data key, then the AES-GCM tag against an AAD that binds chain id, owner, space, seq, prev, bucket, rows root and data key id. The plaintext's own seq, space, prev and rows root must agree with the chain and the header, and its snapshot key must agree with the header's snapshot flag. 5. **Replay.** Rows go back through `MemoryClient.set_entity`, `set_state`, `set_reference` and `write_event`, the SDK's own write methods, so each one passes Sibyl's cap gate and is searchable again. Deleted entities go through `delete_entity`. Pull re-reads every replayed row and compares its leaf, and compares the running merkle root with the rows root in the epoch header. A row that does not come back byte-exact, or a root that does not match, is reported as a warning in the pull report. 6. **Record.** One epoch at a time, pull caches the epoch, saves the mirror and raises the watermark. An epoch that cannot be applied is a gap. When this machine cannot open it and a later snapshot in the walk opens, the pull resumes at that snapshot and ends complete. Otherwise the pull stops there and the mirror stays at the last epoch it applied. Push and verify refuse until the gap is applied, and the next pull retries from that point ([Gaps](/docs/new-machine#gaps)). A snapshot epoch replaces the local picture. Rows the store already holds byte for byte are not written again, because a journal event has no key to overwrite. Replay is content-exact: row uuids and the entity, state and reference timestamps regenerate, but none of those fields are in the leaf, and the journal timestamp survives. The pull ends by hashing the store itself and reporting whether the `store root matches the anchored root`. Restoring sends no transaction. The steps for a wiped machine are in [Restore on a new machine](/docs/new-machine). ## Read and decide: verify `memory_verify(query, limit=5)`, or `kint verify "query"` from a shell, is the step between recall and action: 1. It calls `multi_record_search` on the shared client, the search Sibyl's own `memory_search` runs by default, with every precision gate, and gets ranked hits with a typed verdict. A verdict other than `ok` (`no_match`, `empty_store`, `abstained_on`, `negation_abstain`, `gated`), or no hit at all, means a refusal, because there is nothing to key a decision on. 2. It refuses when this machine has never pulled or pushed, or when its mirror is incomplete (a gap, or a root that differs from the anchored one). 3. It refuses when the chain head, read just before verify ran, is not the head the mirror recorded: another machine anchored something this one has not pulled (`chain moved to seq N at block B, pull first: ...`). 4. For every hit it re-reads the exact stored text by the key the search returned and hashes it into a leaf. It compares that with the leaf in the mirror and checks a merkle inclusion proof against the anchored root. Each hit comes out as `verified`, `drifted`, `unanchored` or `missing`. 5. If any hit is `drifted` or `missing`, verify refuses. For a drifted row the reason names the block that anchored the last good value and the head block this machine last saw. It writes the refusal back through `set_entity` as a Sibyl entity in category `kint_refusal` with status `refused` (`kint verify --no-write` skips that write). The next fresh session finds it with Sibyl's own tools, and while the row keeps the refused value, the refusal holds kint-server's automatic pushes. 6. If every hit is unanchored, it refuses. Otherwise it proceeds on the top verified hit and names any higher-ranked hit that is not anchored yet. Verify sends no transaction. It reads the chain head once, outside every Sibyl write. When that read fails (or under `KINT_OFFLINE=1`) it says so and checks against what this machine last saw anchored, which push and pull checked against the chain when they ran. `memory_status` reports whether the mirror is still the chain head (`in_step`). The rules, the reasons and the temporal read are in [Verify before acting](/docs/verify). ## The pieces ### One client Sibyl's MCP server keeps a single `MemoryClient` in a module-level `_client_cache` and rebuilds it only when `credentials.json` changes, appears or disappears. kint builds its client first (`store.open_client`) and writes it into that cache under Sibyl's own `_client_lock`, along with the current credentials mtime. Sibyl's tools then find a cached client that looks fresh and use it. No line of Sibyl is patched. kint's tools use the same client, and a pull that has epochs to apply builds a new one, after any wipe, and seeds it again. The client reads the same credentials file as Sibyl's own and keeps the same `tier_cache.json` beside the store. The change kint makes on purpose is the cap gate's size function. If `credentials.json` changes while the server runs (Sibyl's own trigger, for `sibyl upgrade`), Sibyl rebuilds its cached client its own way. Until the next pull or restart, their tools then run on Sibyl's client instead of kint's. That client carries none of kint's wrapped write methods, so their writes look like edits behind the tools, and kint-server's automatic pushes are held until the next pull, a restart or a `kint push`. ### The cap gate Sibyl's free tier is capped at 5 MiB (5,242,880 bytes) per account. Sibyl measures usage with `aggregate_db_size`, which sums the stores at five known paths (listed at the end of this page). kint builds the client with Sibyl's public `CapGate` and sets `db_size_fn` to that same aggregate plus `footprint_bytes()`, the size of every file under `KINT_HOME`. The gate enforces the cap, it does not just report it. A write that would take the sum past the cap meets Sibyl's gate exactly as Sibyl's own overflow would, and that includes rows a pull replays. `kint status` prints the three numbers: Sibyl's stores, kint's state, and the enforced total against the cap. `memory_status` returns them as `cap`, with the fields `sibyl_bytes`, `kint_bytes`, `volunteered_total`, `cap_bytes` and `db_path`. Sibyl can consult the gate while it holds its `BEGIN IMMEDIATE` write lock. That is why kint's part of the gate is a local file walk and nothing more. ### The head lock `head_lock` takes an exclusive `flock` on `~/.kint/head-.lock`. It retries every 0.2 seconds for up to 30 seconds, then fails with `another kint process holds the head lock for this space`. Push, pull and `kint rekey` hold it, so two harness sessions over one store take turns instead of racing. The lock only covers one machine. Between machines the chain head decides: a push that finds the head moved refuses, and a pull over unanchored changes refuses as a fork. ### The mirror and the watermark The mirror (`mirror-.json`) is what this machine believes the chain last saw. It holds every row in wire form (text included), each row's leaf, and the head those rows came from: seq, digest, block, bucket, transaction, the anchored rows root, and any epochs a pull had to skip. Push diffs against the mirror, verify compares leaves against it, and pull advances it one applied epoch at a time. The mirror is complete when no epoch was skipped and the root of its leaves equals the anchored root. Verify refuses while it is not complete, and also when the chain head has moved past it; push refuses while an epoch is skipped, except for the owner's `--over-skipped` snapshot. The watermark (`watermark-.json`) holds the seq, digest and block of the newest epoch this machine has pushed or applied, and its seq never decreases. A pull checks it first as the freshness floor. A pull is a cold start precisely when there is no watermark. ### The epoch cache `epochs//` keeps three files per epoch, named by the eight-digit seq: - the ciphertext (`.bin`) - the decrypted plaintext (`.json`) - the metadata (`.meta.json`): seq, digest, prev, block, transaction, bucket, rows root and writer, plus the cost for an epoch this machine pushed Pull uses a cached ciphertext only when its keccak still equals the event digest, and otherwise fetches the calldata again. `memory_history` and verify's provenance read the plaintext files, so a machine's history covers only the epochs it has decrypted. After a cold start that stopped at a snapshot, the history begins at that snapshot, a later pull that stops at a newer snapshot skips the diff epochs before it, and `kint pull --full` backfills both. ## Where kint keeps its state Everything lives under `KINT_HOME`, which defaults to `~/.kint`: ```text ~/.kint/ mode 0700 ├── kint.log the server's log (stdout belongs to the MCP transport) ├── session.key this machine's session key, a V3 scrypt keystore ├── session.key.old the previous one, after kint session-key rotate ├── local.secret only when neither the macOS Keychain nor KINT_SESSION_PASSPHRASE supplies the machine secret ├── enrol-.json owner, tenant, and the key tags this machine can open ├── wraps-.json the data key's wraps ├── vault-.aes the cached data key, encrypted, with an expiry ├── RECOVERY-.txt the recovery code: written for a fresh vault, by kint recovery-code, and by kint rekey ├── mirror-.json what this machine last saw anchored ├── watermark-.json the freshness floor ├── head-.lock the head lock └── epochs/ └── / ├── 00000001.bin ciphertext ├── 00000001.json decrypted plaintext └── 00000001.meta.json seq, digest, prev, block, tx, bucket, rows_root, writer ``` `` is the first 16 hex characters of the space id, `keccak256("kint-space-v1" || tenant)`. kint writes its state files with mode 0600 and replaces them atomically (the log and the lock file are opened in place). The keys, the wraps and the recovery code are explained in [Keys and custody](/docs/keys). ### Why ~/.kint sits outside Sibyl's paths Sibyl's `aggregate_db_size` sizes the stores at these five paths, WAL included, and counts each store once by resolved path: 1. the store the client is writing to 2. `~/.sibyl-memory/memory.db` 3. `$HERMES_HOME/sibyl/memory.db` (`HERMES_HOME` defaults to `~/.hermes`) 4. `$HERMES_HOME/sibyl/profiles//memory.db`, for every profile 5. `$SIBYL_MEMORY_DB`, when it is set kint's state sits outside all of them so that the two halves of the count never overlap. Sibyl's number is exactly what Sibyl measures without kint, kint's number covers every file under `KINT_HOME`, and the gate enforces the sum. A `KINT_HOME` that contained one of those stores would count it twice. `kint doctor` fails its `kint home placement` check when `KINT_HOME` is inside `~/.sibyl-memory`. Kept apart, the bytes kint adds to a machine stay visible, counted, and held to the cap Sibyl already enforces. Read [Keys and custody](/docs/keys) next. Source: [`src/kint/server.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/server.py), [`src/kint/push.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/push.py), [`src/kint/pull.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/pull.py), [`src/kint/store.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/store.py), [`src/kint/paths.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/paths.py), [`src/kint/epoch.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/epoch.py), [`src/kint/restore.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/restore.py), [`src/kint/verify.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/verify.py). --- ## Keys and custody > Source: https://github.com/s0nderlabs/kint/blob/main/src/kint/crypto.py kint splits custody between an owner wallet, which opens the memory and decides who may write, and a session key per machine, which pays for epochs and decrypts nothing. ## Two keys, two jobs The two keys never do each other's job. This page covers both, the one message the wallet signs, the data key under it, the recovery code, and what a rotation can and cannot do. | | Owner wallet | Session key | |---|---|---| | What it is | Your own EOA (Ledger, MetaMask, Rabby, a foundry keystore) or a Base Account | A plain EOA that `kint session-key create` generates on this machine | | Where it lives | Wherever you keep it. kint never stores it | `~/.kint/session.key`, a V3 scrypt keystore, mode 0600 | | What it signs | The vault message (EOA owners, on connect and on rekey) and each session key's authorization | `push` and `setSessionKeyBySig` transactions to EpochAnchor, nothing else | | Can it read the memory | Yes: its vault signature derives the key-encryption key (a Base Account owner uses a passphrase instead) | No: it holds nothing that decrypts | | Pays gas | In the `direct`, `page` and `burn-nonce` lanes | Every epoch, and `setSessionKeyBySig` in the `payload` lane | The split lets the owner key stay cold. A hardware wallet signs twice per machine (the vault message and the authorization) and goes back in the drawer. The hot key sitting next to the agent can append epochs under the owner's name until it expires or is revoked, and that is all it can do. It cannot rewrite an anchored epoch (the contract keeps only the head digest, and every `push` must name it as `prev`), and it cannot read a byte. What it appends can be any bytes, including an epoch no key opens; [Epochs on Base](/docs/epochs#an-epoch-nobody-can-open) covers how kint gets past one. EpochAnchor has no owner rotation. If you lose the owner key, new writes end once its session keys expire. The recovery code or a passphrase wrap still opens the memory on a new machine without it. ## The session key ```sh kint session-key create # "created: 0x..." (an existing key is reported as "exists: 0x..." and left alone) kint session-key show # JSON: address, path, exists, and from the chain balance, authorized, expiry kint session-key rotate # moves session.key to session.key.old, then creates a new key ``` `show` asks the chain about the owner from `--owner`, or the owner this machine is connected to. `rotate` does not touch the chain. The new address needs its own authorization and a little ETH, and the old one stays authorized until its expiry unless you revoke it. `kint doctor` reports the session key as ok only when its balance is at least 0.00002 ETH and it is authorized. You never type the keystore passphrase. kint reads it from the first of these that exists: 1. `KINT_SESSION_PASSPHRASE`, for servers and anything without a Keychain. 2. On macOS, the Keychain item with service `dev.kint-session-key` and account `passphrase`, created with a random value on first use. `KINT_NO_KEYCHAIN=1` skips it. 3. `~/.kint/local.secret` (mode 0600), created with a random value on first use. If none exists when the key is loaded, kint stops with `no local passphrase: set KINT_SESSION_PASSPHRASE`. The same secret also encrypts the [vault key cache](/docs/keys#the-vault-key-cache). ## Authorize a machine EpochAnchor accepts a `push` when `canWrite(owner, writer)` is true: the writer is the owner, or `sessionKeyExpiry[owner][writer] > block.timestamp`. `kint authorize` sets that expiry for this machine's session key in one of four lanes. Every lane takes `--owner` (by default, the connected owner) and needs this machine's session key to exist. `direct`, `page` and `payload` compute `expiry = now + days * 86400` from `--days`, which defaults to 30; `submit` sends the expiry that `payload` printed. | lane | who signs | who sends and pays | contract call | |---|---|---|---| | `direct` | the owner key in `KINT_OWNER_KEY` | the owner | `setSessionKey(key, expiry)` | | `page` | a Base Account, in your browser | the account | `setSessionKey(key, expiry)` | | `payload`, then `submit` | the owner, offline, over EIP-712 | the session key | `setSessionKeyBySig(owner, key, expiry, deadline, signature)` | | `burn-nonce` | the owner key in `KINT_OWNER_KEY` | the owner | `setSessionKey(key, current expiry)` | **direct.** `KINT_OWNER_KEY=0x... kint authorize direct` reads the owner key from the environment, never argv. It refuses a key that is not the owner (`KINT_OWNER_KEY is 0x..., not the owner 0x...`) and prints the transaction, its cost and its block. **page.** This lane is for Base Account (Coinbase Smart Wallet) owners. kint serves one page on `127.0.0.1` at a random port, with a 32-byte token in the path, and prints the URL. The page connects the account, refuses if it is not the expected owner, and sends `setSessionKey(key, expiry)` from the account. For an account that has never sent a transaction, that same transaction also deploys it. kint then polls `canWrite(owner, key)` every 3 seconds, up to 120 times. The page server binds loopback only, checks `Origin` and `Sec-Fetch-Site` on the result it receives, exits after one result, and gives up after 900 seconds. No derive signature, passphrase or key ever crosses the page. A signature lane cannot do this job: a counterfactual account has no code, so `setSessionKeyBySig` cannot run its ERC-1271 check. It falls through to `ecrecover` and rejects the signature. After that first transaction the account has code, and ERC-1271 works. **payload and submit.** In this lane a hardware wallet signs offline, and the session key submits and pays. ```sh kint authorize payload > kint-auth.json cast wallet sign --data --from-file kint-auth.json --ledger | kint authorize submit --deadline --expiry --signature - ``` `payload` writes the `SessionKeyAuthorization` typed data to stdout, with `nonce = authNonce(owner)` read from the chain and `deadline = now + 300`. On stderr it prints the nonce, deadline and expiry, plus the exact `submit` line to run. `submit` reads the signature from stdin (`-` is also the default). The signature works once: the nonce increments on success. The contract rejects it once the deadline has passed (`AuthorizationExpired`), so submit within five minutes of `payload`. It also rejects an EOA signature with `s` in the upper half of the curve order (`BadSignature`). Its domain is `kint EpochAnchor`, version `1`, chain 8453, with EpochAnchor as `verifyingContract`. That is a different domain from the vault message, so an authorization signature can never double as a derive signature. **burn-nonce.** This lane re-sets the session key's current expiry from the owner. That consumes `authNonce`, so any signed but unsubmitted authorization dies. It prints `nonce consumed: authNonce(0x...) is now N`. Like `direct`, it refuses a key that is not the owner (`KINT_OWNER_KEY is 0x..., not the owner 0x...`). ### Expiry and revocation Expiry is a unix timestamp stored per owner and key, and 0 means revoked. A key expires on its own. After that, push refuses with `session key 0x... is not authorized for owner 0x...: authorize it (kint authorize) and fund it` (exit 5). Run any lane again to extend it. kint has no revoke command. The owner calls `revokeSessionKey(key)` on EpochAnchor. That sets the expiry to 0 in the same block and consumes `authNonce`, so a signed authorization the revoked machine still holds cannot re-arm it. An EOA owner with Foundry: ```sh cast send 0xa22E03f7a4145Bf4909a83595C90a38E14d79600 "revokeSessionKey(address)" 0xSESSION_KEY --ledger --rpc-url "$KINT_RPC_URL" ``` A smart-account owner sends the same call from the account. Revoking stops writes, not reads: that machine keeps its cached data key until the cache expires, and any recovery file on it is the data key itself. For a compromised machine, revoke its key, then run [`kint rekey`](/docs/keys#rotate-the-data-key) from a machine you trust. If a leaked key already appended an epoch that does not open, run `kint pull` on that machine first (it records the epoch it cannot apply), then `kint rekey --over-skipped`: the new snapshot chains on the chain head, past that epoch. If the bad epoch's header parses and rekey refuses its wraps, run `kint compact --over-skipped` first, then `kint rekey`. ## The vault message An EOA owner signs one frozen EIP-712 message, and that signature is the vault key. `kint canonical-payload --owner 0x...` prints it as `cast wallet sign --data --from-file` reads it: byte-stable, one line, no whitespace between tokens. Here it is indented, for the owner in the committed test vectors: ```json { "types": { "EIP712Domain": [ {"name": "name", "type": "string"}, {"name": "version", "type": "string"}, {"name": "chainId", "type": "uint256"} ], "KintVault": [ {"name": "owner", "type": "address"}, {"name": "purpose", "type": "string"} ] }, "primaryType": "KintVault", "domain": {"name": "kint", "version": "1", "chainId": 8453}, "message": { "owner": "0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A", "purpose": "kint-memory-v1: signing this reveals your memory encryption key. Only sign it in a kint terminal or page you opened yourself." } } ``` For that owner the digest is `39fec015d21465d81dd5611129982b7a310e070a126f41dfdd8c87cc04a31517`. The signature reaches kint through a pipe, never argv: ```sh kint canonical-payload --owner 0xYOU > kint-canonical.json cast wallet sign --data --from-file kint-canonical.json --ledger | kint connect --owner 0xYOU --signature - ``` `--signature-file PATH` also works, and kint deletes the file after reading it. `memory_connect` never accepts the signature. **Why `purpose` is a warning.** The domain has no `verifyingContract` and no `salt`, so nothing in it ties the message to one page or one deployment. A phishing page could ask for the same signature. The defence is what the signer reads, and `purpose` is the field every wallet renders. So that field says what signing does and where it is safe. The other half of the defence is that kint never accepts this signature as a login and never sends it anywhere. The wallet must sign deterministically (RFC 6979), so the same wallet produces the same 65 bytes on every machine. A Base Account cannot do that (passkey signatures are not deterministic), which is why Base Account owners use the passphrase path. > **Warning.** A phished derive signature is permanent. It reaches back over every epoch already on Base, it cannot be revoked, and it covers every tenant of that wallet, because the message names no space. Sign it only in a kint terminal or page you opened yourself. With a salt passphrase (`--passphrase-prompt`), the signature alone opens nothing. ## From signature to key These are the steps for the signature path (wrap kind `0x01`), in order: 1. The signature must be exactly 65 bytes, with `v` as 27/28 or 0/1, and `r` and `s` in range. 2. kint recovers the signer over the vault digest, and it must be the owner. Otherwise kint stops with `derive signature recovers to 0x..., expected 0x...; refusing to derive a key from it`. This check never sits behind a try/except: it is the only thing between a wrong signature and a silently different key. 3. Low-S normalise: if `s > n/2`, then `s = n - s`. A malleated twin of the same signature derives the same key. 4. `ikm = r || s`, 64 bytes. `v` never enters. 5. `info = "kint-kek-v1" || owner (20 bytes) || space (32 bytes)`, 63 bytes, where `space = keccak256("kint-space-v1" || tenant id)`. 6. `KEK = HKDF-SHA256(ikm, salt, info)`, 32 bytes. The salt is empty unless you add a salt passphrase. Because `info` carries the owner and the space, one signature gives each tenant its own KEK. **A salt passphrase as a second factor.** `kint connect --passphrase-prompt` (or `--passphrase-stdin` together with `--signature-file`) sets `salt = scrypt(passphrase, owner20, N = 2^17, r = 8, p = 1, 32 bytes)`. `--passphrase-stdin` and `--signature -` cannot share stdin, and kint says so. A header does not say whether a signature wrap is salted: flag `0x01` is defined, but no kint writer sets it. The CLI does not guess: pass the salt flag every time you connect or rekey with a salted signature. The browser contract (`docs/frontend-contract.md`) has a reader derive the unsalted key first and ask for the salt only when no wrap carries that key's tag. **The passphrase path** (wrap kind `0x02`, `kint connect --smart-account`) needs no wallet. `ikm = scrypt(passphrase, salt = owner20, N = 2^17, r = 8, p = 1, 32 bytes)`, then `KEK = HKDF-SHA256(ikm, salt = "", info)` with the same 63-byte `info`. The passphrase is encoded as UTF-8 with no Unicode normalisation, in Python and in `js/` alike. `--passphrase-stdin` strips leading and trailing whitespace and the prompt does not, so do not start or end a passphrase with a space: the two would derive different keys. kint refuses an empty passphrase. It prompts `vault passphrase: `, or reads stdin with `--passphrase-stdin`. `memory_connect(passphrase=...)` accepts it too, but anything typed into an agent chat lands in the transcript, so use the terminal. | test vector (owner above, throwaway key `0x11...11`, tenant `demo`, unsalted) | hex | |---|---| | space | `fd83319f25a64bb2952805bd9bf32719080d37c6a83b6a51db74e409eb42bff6` | | KEK, signature path | `c69d894607fac5813904d32c44c4603835b746a529ada52fae1834ac28cc418f` | | kek_tag | `9710b041b39054fa3ad68ee22c8f2e79` | ## One data key, many wraps A KEK never encrypts memory. Each space has one random 32-byte data key (DEK), wrapped under a list of KEKs: ```text wrap = kind(1) | kek_tag(16) | wrap_nonce(12) | wrapped_dek(48) wrapped_dek = AES-256-GCM(KEK, wrap_nonce, DEK, aad = "kint-wrap-v1" || kind || kek_tag) kek_tag = HMAC-SHA256(KEK, "kint-kek-check-v1")[:16] dek_id = HMAC-SHA256(DEK, "kint-dek-id-v1")[:8] ``` Every epoch header carries the whole list and the `dek_id`. A client finds its wrap by comparing `kek_tag`, and never tries each wrap in turn. AES-GCM is not key-committing, so the tag match is what proves the key is the right one. Unwrapping refuses a mismatch before AES runs (`kek_tag mismatch: this key does not open this wrap`). The kinds are a frozen enum, extended only at the end: `0x01` wallet signature, `0x02` passphrase, `0x03` reserved for WebAuthn PRF, `0x04` reserved for an EOA-owned smart account. `kint connect` gets the DEK from the first of these that works: 1. The wraps on this machine (`~/.kint/wraps-.json`), while they hold the current key. 2. The header of the newest readable epoch on Base: the head, unless its bytes do not parse as a kint header, in which case kint walks back through `prevBlock` (at most 32 epochs) to the newest one that does. kint fetches that epoch's ciphertext, checks its keccak256 against the `Epoch` event digest, and finds its wrap by tag. Whenever the chain can be read, the header decides, so local wraps left over from before a rotation give way. 3. A fresh vault, when the chain has no epochs for this owner and space and this machine has no wraps for it: a new DEK, one wrap and a new recovery code. With `KINT_OFFLINE=1`, kint does not ask the chain, so on a machine with no wraps it starts a fresh vault instead of opening the one on Base. If the chain cannot be read and a local wrap matches, connect uses the local key. The command prints the owner, `data key from local wraps | chain head header | fresh vault | recovery code`, and the first 8 hex characters of the kek_tag. It never prints the signature, the KEK or the DEK. Connecting with the wrong key fails on the tag: `this key (tag ...) does not open the vault anchored under 0x...: the wraps in the head epoch carry different tags. Wrong wallet, wrong passphrase, or wrong tenant.` An EOA owner can add a second key with `kint connect --add-passphrase`, which prompts `extra passphrase wrap (empty to skip): `. It adds a kind `0x02` wrap, derived exactly like the passphrase path, so it opens the same DEK without the wallet. Every epoch pushed after that carries it. ## The recovery code The recovery code is the data key itself: base32 of `DEK || sha256(DEK)[:2]`, padding stripped, in hyphenated groups of four. That makes 55 characters in 14 groups, the last group three characters long. Decoding ignores case, hyphens and spaces, and the 2-byte checksum catches a typo (`recovery code checksum failed (typo?)`). kint writes it to `~/.kint/RECOVERY-.txt`, mode 0600, at three moments: when connect creates a fresh vault, on `kint recovery-code`, and on `kint rekey`. ```text kint recovery code for owner 0x..., space Keep this somewhere that is not this machine. It opens the vault without the wallet. XXXX-XXXX-XXXX-... ``` Copy it off the machine. The machine that created the vault refuses to push while that file is missing or empty. `kint recovery-code` rewrites it from the cached data key. It refuses if the cached key is not the one the head epoch was sealed with, which happens when the key was rotated on another machine. To use it, run `kint connect --owner 0x... --recovery-code-stdin`. The checksum only proves the code was typed correctly, so before caching anything, kint binds the code to this vault: its `dek_id` must equal the head header's (`this recovery code does not belong to the vault anchored under this owner`). When the chain cannot be read, kint checks against the cached data key or the newest cached epoch header instead. With nothing to check against, it refuses: a recovery code cannot start a vault. Connecting with a code adds no wrap. The machine takes the wraps from the head header, so it can push. `memory_connect(recovery_code=...)` accepts it too, but anything typed into an agent chat lands in the transcript, so use the terminal. ## The vault key cache After a connect, the unwrapped DEK is cached, so the agent is not blocked on you again for a while. The cache is `~/.kint/vault-.aes`: AES-256-GCM under `HKDF-SHA256(local secret, info = "kint-vault-cache-v1")`, with the space id in hex as AAD. The local secret is the one that protects the session keystore. | `KINT_KEY_TTL` | how long the cached key lasts | |---|---| | unset, empty or unparseable | 24 hours | | a number | that many seconds | | a number with `s`, `m`, `h` or `d` | e.g. `30d` for an unattended machine | | `session` | memory only, in the process that connected; never written to disk | While kint-server runs, every successful pull and push (the startup pull, `memory_pull`, `memory_push`, the watcher's and the exit hook's) caches the key again with a fresh deadline, so a server in use does not lapse. The key still lapses on a machine that goes longer than the TTL without one of those: an idle server, or a machine used only from the CLI, whose commands never renew it. An expired key loses nothing. Push refuses with exit 3 (`KEY_EXPIRED` from `memory_push`) and says so: nothing was dropped, and the changes stay in the store until the next push. kint-server starts without pulling, its watcher waits, and `memory_verify` and `memory_history` keep working. Run `kint connect` again, or set `KINT_KEY_TTL=30d` on a machine that stays up. ## Rotate the data key `kint rekey` replaces the DEK and keeps the keys that open it. It is CLI only. No MCP tool rotates the key, because the secrets that open a vault are typed in a terminal, never in an agent chat. In order: 1. It refuses unless this machine is connected to the vault. 2. It takes the head lock for the space. 3. It reads the newest readable epoch's header from Base (skipped with `KINT_OFFLINE=1`): the head, or, past a head that does not parse as a kint header, the newest epoch within 32 that does, logging `connect: the head epoch N at block B is not a readable kint epoch (...)` and `connect: using epoch N at block B, the newest readable one`. Those wraps are the keys a new machine sees, so they decide what must be carried over. Local wraps count only when the chain has no epochs. 4. It derives a KEK from every key you supplied. Each must open a current wrap: rekey re-wraps keys that work today and cannot add a new one. 5. It unwraps the current DEK and checks its `dek_id` against that header's. 6. It lists the current wraps you did not supply, and refuses by name unless you pass `--drop-missing`. 7. It generates a new random DEK and wraps it under each supplied key, keeping each wrap's kind. 8. It anchors one snapshot epoch under the new DEK, with the same checks a push makes (recovery file, head in step, session key authorized). A machine behind the chain must run `kint pull` first, and the whole state must fit in one epoch. With `--over-skipped`, on a machine whose last pull stopped at an epoch it could not apply, the snapshot chains on the chain head instead ([Epochs on Base](/docs/epochs#an-epoch-nobody-can-open)). 9. Only then, still holding the lock, it rewrites the local wraps, the key cache, the recovery file and the enrolment. If the push fails, nothing on this machine has changed. ```sh cast wallet sign --data --from-file kint-canonical.json --ledger | kint rekey --signature - cast wallet sign --data --from-file kint-canonical.json --ledger | kint rekey --signature - --add-passphrase kint rekey --smart-account # a Base Account owner: prompts for the vault passphrase ``` | flag | meaning | |---|---| | `--owner` | defaults to the connected owner | | `--signature -`, `--signature-file PATH` | the wallet signature, from stdin or from a file that is deleted after reading | | `--passphrase-prompt` | the salt passphrase of a salted signature | | `--passphrase-stdin` | one passphrase from stdin: the salt when a signature is given, otherwise the vault passphrase | | `--smart-account` | carry the vault passphrase wrap (prompted). Combine with `--signature` to carry both | | `--add-passphrase` | also carry the extra passphrase wrap (prompted) | | `--drop-missing` | rotate without the wraps you did not supply | | `--over-skipped` | rotate even though the last pull stopped at an epoch this machine could not apply: the new snapshot chains on the chain head | | `--confirmations N` | confirmations to wait for, default 2 | It prints the old and new `dek_id`, the snapshot epoch and its transaction, how many keys were carried over and which were dropped, the path of the new recovery file, then `the old recovery code no longer opens this vault; copy the new one somewhere that is not this machine`. Exit codes: 2 for a refusal, 3 when there is no session key or no local secret to unlock it, 4 when the chain moved, 5 when the push failed. Afterwards, other machines holding a carried-over key run `kint connect` again, and the head header hands them the new DEK. A machine whose local wraps hold a dropped key is told `the data key of this vault was rotated and this key (tag ...) was not carried over: it opens the epochs sealed before the rotation and nothing after it`. On a machine with no local wraps, a dropped key gets the ordinary wrong-key refusal. connect refuses the old recovery code, because its `dek_id` no longer matches the head. A machine holding only the new key restores from the rotation's snapshot. It reports older epochs as closed (`sealed under a data key this machine does not hold (rotated)`). `kint pull --full` walks back past the rotation, but it reports each earlier epoch as closed and opens none of them. **What a rotation cannot undo.** Every epoch before the rotation stays on Base, sealed under the old DEK, which is what the old recovery code encodes and what every old wrap opens. Anyone who held one of those can read everything up to the rotation, permanently. A rotation protects what comes next. Because rekey re-wraps under the same KEKs, a leaked key you carry over opens the new DEK too. To shut a key out, leave it out and pass `--drop-missing`. A leaked wallet signature cannot be changed (same wallet, same message, same key), so leaving its wrap out means that wallet opens nothing sealed after the rotation. ## Files on this machine These are the key files. `KINT_HOME` also holds the mirror, the epoch cache, the watermark, the head lock and the log. ```text ~/.kint/ KINT_HOME, mode 0700 ├── session.key the session key, V3 scrypt keystore ├── session.key.old the previous key, after kint session-key rotate ├── local.secret keystore and cache secret, when there is no env var or Keychain ├── enrol-.json owner, tenant, account kind, the kek tags of the current wraps ├── wraps-.json the current wraps of the data key ├── vault-.aes the cached data key, encrypted, with its expiry └── RECOVERY-.txt the recovery code ``` `` is the first 16 hex characters of the space id. kint writes each of these files at mode 0600 through an atomic replace; `session.key.old` is the previous keystore, renamed. Read [Epochs on Base](/docs/epochs) next. Source: [`src/kint/crypto.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/crypto.py), [`src/kint/keys.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/keys.py), [`src/kint/connect.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/connect.py), [`src/kint/cli.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/cli.py), [`src/kint/chain.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/chain.py), [`src/kint/page.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/page.py), [`src/kint/push.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/push.py), [`src/kint/pull.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/pull.py), [`src/kint/server.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/server.py), [`contracts/src/EpochAnchor.sol`](https://github.com/s0nderlabs/kint/blob/main/contracts/src/EpochAnchor.sol), [`docs/frontend-contract.md`](https://github.com/s0nderlabs/kint/blob/main/docs/frontend-contract.md). --- ## Epochs on Base > Source: https://github.com/s0nderlabs/kint/blob/main/src/kint/crypto.py An epoch is the unit kint writes to Base: a set of rows, compressed, padded, encrypted and sent as the calldata of one `push` transaction. ## What an epoch is This page covers what goes inside one, the exact bytes of the envelope, how a reader finds epochs again, and what each one costs. Every epoch belongs to one owner and one space. The space is `keccak256("kint-space-v1" || tenant)`, so each Sibyl tenant has its own chain of epochs under the same wallet. Epochs are numbered from 1 (`seq`), and each one names the digest of the epoch before it (`prev`, 32 zero bytes for the first). For each `(owner, space)`, EpochAnchor keeps the head: the latest digest, its `seq` and the block it landed in. `push(owner, space, prev, ct)` reverts with `StaleHead(expected, got)` unless `prev` equals the current head digest, so the chain only ever grows at the end and two writers cannot both extend the same head. A push runs after Sibyl's write has returned, never inside it; the export reads the store read-only. It happens when you run `kint push`, when the agent calls `memory_push`, when kint-server's watcher fires (after `KINT_QUIET_SECONDS` of quiet, default 300, or on its size trigger), and best effort on exit. When nothing changed, push anchors nothing and says `nothing to push: the store matches the last anchored epoch`. ## Diff epochs and snapshot epochs | | diff epoch | snapshot epoch | |---|---|---| | `rows` | only the rows whose leaf changed since the last anchored epoch | every row in the store (the full state) | | `deleted` | rows removed since the last anchored epoch | the same list, kept for history only | | header flags | `0x00` | `FLAG_SNAPSHOT` (`0x02`) | | plaintext key | absent | `"snapshot": true`, the last key | | `rows_root` | root of the mirror plus this epoch's changes | root of exactly its own rows | | written by | `kint push`, `memory_push()`, the watcher, the exit hook | `kint compact`, `memory_push(snapshot=true)`, `kint rekey` | A snapshot is written even when the diff is empty; that is the point of `kint compact` and of a key rotation. A pull without `--full` stops walking at the newest snapshot, so restore cost is bounded by the size of the memory, not its history. `kint pull --full` (and `memory_pull(full=true)`) walks past snapshots, and afterwards caches, for history, every epoch from the head back to the first that this machine has not decrypted yet, including the diff epochs an earlier pull jumped when it stopped at a newer snapshot. The deletions a snapshot carries are already absent from its `rows`; history and `at_block` use them to record when a row disappeared. The other defined flag, `0x01` (a signature wrap salted with a passphrase), is set by no kint writer. A reader must not branch on it, and unknown bits are reserved: ignore them, never refuse on them. ### Why the flag and the key must agree The flags byte sits in the header, which a reader parses before it decrypts anything: that is how a walk knows where to stop. The flags are not part of the AAD (below), so the AES-GCM tag says nothing about them. A writer holding a session key but not the data key can still set that bit. The plaintext `"snapshot"` key lives inside the ciphertext, so it is covered by the tag. kint acts on a snapshot only when both say the same thing. On a pull, an epoch whose header flag and plaintext key disagree is a gap: the pull stops there with the reason `snapshot flag disagrees between header and plaintext`, and the mirror stays at the last epoch it applied. If a walk stopped at an epoch that claims to be a snapshot and that epoch does not open, kint logs `pull: epoch N claims to be a snapshot but cannot be applied; walking the full history instead`, applies the epochs before it, and reports it by name. The browser core runs the same check (`assertEpochConsistent`) and the same provisional stop (`coldStartEvents`, which names the refused epoch in `refusedSnapshotSeq`); see [Browser core](/docs/kint-core). The plaintext key is present only on a snapshot, so every ordinary epoch stays byte-identical to the v1 format. ## The plaintext Before anything is compressed, an epoch is one JSON document, serialised with no spaces (`separators=(",", ":")`, `ensure_ascii=False`, UTF-8) in this key order. Abridged, with placeholders where values vary; the row shown is the demo tenant's release rule (`rules/release-gate`), the row epoch 2 rewrote: ```json { "v": 1, "tenant": "kint-demo", "space": "<64 hex: keccak256(\"kint-space-v1\" || tenant)>", "seq": 2, "prev": "<64 hex: digest of epoch 1>", "rows": [ {"tier": "entity", "key": "release-gate", "category": "rules", "status": "", "body": "", "meta": null, "ts": "", "evaluated": null, "acted": null, "forward": null, "extra": null} ], "deleted": [["", "", ""]], "rows_root": "<64 hex: root of the full state after this epoch>", "n_rows": "", "created_at": "" } ``` - `rows` are wire rows: `tier`, `key`, `category`, `status`, `body`, `meta`, `ts`, `evaluated`, `acted`, `forward`, `extra`. The local `rowid` and journal uuid are dropped. - `body`, `meta` and the journal fields are the exact stored TEXT from Sibyl's tables, never re-serialised. Sibyl's `dumps()` keeps insertion order on purpose; a re-serialised hash would refuse every row on the next machine. - `deleted` entries are `[tier, category or null, key]`. - `n_rows` is the total row count after the epoch. `created_at` is the writer's own clock; kint states when a row existed as a block height upper bound instead. ## Leaves and rows_root Every row has a canonical id, `tier\0category\0key` (empty category outside entities), and a leaf. With `lp(x) = uint32(len) || utf8(x)` (big-endian) and `lp(null) = 0xFFFFFFFF`: ```text entity, state, reference: leaf = keccak256("kint-leaf-v1" || lp(tier) || lp(category) || lp(key) || lp(status) || lp(body) || lp(meta)) journal: leaf = keccak256("kint-leaf-v1" || lp("journal") || lp(null) || lp(key) || lp(null) || lp(ts) || lp(evaluated) || lp(acted) || lp(forward) || lp(extra)) key = hex(keccak256("kint-journal-key-v1" || lp(ts) || lp(evaluated) || lp(acted) || lp(forward) || lp(extra))) ``` Timestamps Sibyl regenerates on replay (`updated_at` on entities, state and reference) are not in the leaf. The journal `ts` is content and is. Journal rows get a new uuid on replay, so their key is their content; a second identical event gets `:1`, a third `:2`, assigned in rowid order. `rows_root` is the merkle root over the leaves of the full state after the epoch, sorted by canonical id. Pairs hash as `keccak256(left || right)`, an odd last node is carried up unchanged, and an empty state has the root `keccak256("kint-empty-v1")`. Because it covers the whole state, a single row can be proven against any one epoch's `rows_root` with a merkle path; see [Verify before acting](/docs/verify). ## Sealing the payload 1. **Compress.** `gzip` at level 9 with `mtime 0`, so the same plaintext compresses to the same bytes. 2. **Pick a bucket.** The smallest of 4096, 8192, 16384, 32768, 65536 or 98304 bytes that holds the compressed length plus 4. The choice ratchets per space: an epoch never uses a smaller bucket than the one before it. 3. **Pad.** `padded = uint32(len(gz)) || gz || zeros` up to the bucket. 4. **Encrypt.** AES-256-GCM under the space's data key, a fresh random 12-byte nonce, and the AAD below. The ciphertext is exactly `bucket + 16` bytes (the GCM tag). 5. **Frame.** `blob = header || ciphertext`. That blob is the `ct` argument of `push`. Only the bucket is public. The true compressed length is the first four bytes of the padded payload, inside the ciphertext. ## The header Big-endian, byte-exact. The fixed part is 59 bytes, and each wrap adds 77. | offset | bytes | field | contents | |---|---|---|---| | 0 | 1 | `version` | `0x01`; anything else is refused as `unsupported envelope version` | | 1 | 1 | `flags` | `0x02` snapshot; `0x01` defined, never set | | 2 | 12 | `gcm_nonce` | the AES-GCM nonce of the payload | | 14 | 32 | `rows_root` | merkle root of the full state after this epoch | | 46 | 8 | `dek_id` | `HMAC-SHA256(DEK, "kint-dek-id-v1")[:8]` | | 54 | 4 | `lenBucket` | the padded length (one of the six buckets) | | 58 | 1 | `n_wraps` | how many wraps follow | | 59 | 77 each | wraps | `kek_kind` (1), `kek_tag` (16), `wrap_nonce` (12), `wrapped_dek` (48) | Each wrap is the data key encrypted under one key-encryption key: `AES-256-GCM(KEK, wrap_nonce, DEK, aad = "kint-wrap-v1" || kek_kind || kek_tag)`. Kinds are `0x01` wallet signature, `0x02` passphrase, with `0x03` and `0x04` reserved. A reader finds its wrap by comparing `kek_tag`, never by trial decryption, because GCM does not commit to its key. Every epoch carries its own wraps, which is why a wiped machine gets the data key out of the head epoch's header. [Keys and custody](/docs/keys) covers how each KEK is derived. Opening checks the ciphertext length against `lenBucket + 16` and the `dek_id` against the key in hand (`dek_id mismatch: wrong data key for this epoch`) before it decrypts. ## The AAD ```text aad = keccak256(abi.encode( uint256 chainId, 8453 address owner, bytes32 space, uint64 seq, bytes32 prev, uint32 lenBucket, bytes32 rows_root, bytes8 dek_id)) ``` Chain id, owner and space pin an epoch to one vault on Base. `seq` and `prev` pin it to one position, so a valid ciphertext cannot be replayed at another. `lenBucket`, `rows_root` and `dek_id` bind the header fields a reader acts on to the payload. What the AAD leaves out: the version byte, the flags byte, the nonce itself, and the wraps, which carry their own AAD. ## Why calldata For each `(owner, space)` the contract stores a digest, a sequence number and a block number, and its `Epoch` event carries the digest. The ciphertext itself is never copied to storage or into an event: it stays in the calldata of the `push` transaction that carried it there. Base charges those bytes as calldata: L2 gas under the EIP-7623 floor, plus the L1 data fee. The contract's own work (the head write, the event, the keccak) is flat, 61,409 gas at 4 KB and 139,387 gas at 100 KB, and the calldata floor absorbs it at every bucket, so the calldata is the bill. A root alone on Base is a receipt for data you still have to keep somewhere else; here the calldata is the store. ## The Epoch event and the prevBlock walk ```solidity event Epoch(address indexed owner, bytes32 indexed space, address indexed writer, uint64 seq, bytes32 prev, bytes32 digest, uint64 prevBlock); ``` `writer` is the address that sent the push (the owner or a session key). `digest` is `keccak256(ct)`. `prevBlock` is the block the previous head landed in, 0 for the first epoch. That field is what lets a reader walk backwards with one exact-block query per epoch: 1. Read `head(owner, space)` for the newest `seq` and its block. 2. Call `eth_getLogs` with `fromBlock` and `toBlock` both set to that block, filtered by the contract, the `Epoch` topic, the owner and the space, and take the event with the wanted `seq`. If it is missing, the walk stops with `no Epoch event for seq N at block B; the RPC may be pruned or lying`. 3. Move to `prevBlock` and `seq - 1`. Repeat until `prevBlock` is 0, the walk reaches epochs this machine already applied, or, without `--full`, it reaches a snapshot. Walking back from epoch 2 of the demo tenant takes two queries: block 51081880 for epoch 2, whose `prevBlock` points to 51081867 for epoch 1, whose `prevBlock` is 0. No query in the walk spans a block range, so no RPC range cap is hit. For each event, kint first checks `prev` continuity. Then it takes the ciphertext from the local cache, or fetches the transaction, requires it to call `push` on EpochAnchor with the same owner, space and `prev`, and requires `keccak256(ct)` to equal the event's digest. Only then does it open the epoch. The writer checks the chain too. Before sending it compares the chain head with what this machine last saw and refuses if another machine pushed first (the owner's override [below](#an-epoch-nobody-can-open) is the one exception). After the transaction has its confirmations (`kint push --confirmations`, default 2), it reads the event back at the receipt's block and stops with `epoch N landed but the Epoch event digest does not match what was sent` if they differ. If the transaction lands but the wait for its receipt or its confirmations fails (a timeout, an RPC that drops), this machine never records the epoch. Its next push then reads its own epoch as another machine's push, and its next pull calls the rows it sent a fork. `kint pull --discard-local` gets out of it: the chain already carries that epoch, so the restore brings those rows back, and only rows changed after that push stay behind in the `.kint-backup-` copy. ## An epoch nobody can open `push` checks who writes, never what: any authorized writer can append any non-empty bytes as the next epoch. A session key holds no data key, so an epoch appended with a leaked one opens for nobody, and it sits in the chain like any other. kint deals with it in three places. - **Pull.** A walk that goes past snapshots (`--full`) can meet an epoch this machine cannot open: its header does not parse, no key here opens it, or AES-GCM refuses it. When a later snapshot in the same walk opens, the pull resumes there, because a snapshot's rows are the whole state. It reports the epoch as skipped with `superseded_by` and ends complete. An epoch the RPC would not serve, or one that opened and contradicts the chain, is never walked past: nobody has read what is in it. A pull without `--full` starts at the newest snapshot and never opens what lies before it. - **Connect and rekey.** When the head epoch does not parse as a kint header, they take the wraps from the newest epoch that does, walking back through `prevBlock` at most 32 epochs. `kint rekey` logs both steps. - **The owner's snapshot.** Anywhere else the pull stops at the epoch, and push and verify refuse on that machine. The push refusal ends by naming `kint compact --over-skipped`. Run where the data key is cached, after a pull that recorded the gap, that command anchors a snapshot of this machine's store on top of the chain head, whatever sits between; `kint rekey --over-skipped` does the same while rotating the key. Every later pull stops at that snapshot and never needs the bad epoch. The override is scoped on purpose: a snapshot, from a terminal, on a machine whose last pull reported an epoch it could not apply. No MCP tool takes it. It logs `push: OVERRIDE, anchoring a snapshot on top of chain head seq N (<12 hex>) although this machine stopped at seq M and could not apply epoch(s) [K]`. The snapshot carries this machine's store as it is, so run it where the memory is the one to keep, and revoke the leaked key first ([Keys and custody](/docs/keys#expiry-and-revocation)) or it can append again. Connect and rekey step over a header that does not parse and nothing else: a bad epoch with a well-formed header whose wraps none of your keys open still stops them until the owner's snapshot sits on top of it. ## Splitting a large diff One epoch carries at most 90 KB (92,160 bytes) of compressed rows, measured rather than estimated: kint gzips the candidate chunk's rows and deletions and adds 512 bytes of headroom. Rows are added in order; when the next row would cross the limit, the chunk closes and a new epoch starts. Deletions travel in the first epoch. Each chunk is its own transaction, `seq` and `prev` chain them in order, and each carries the `rows_root` of the state after it. A single row that does not fit on its own is refused by name before anything is sent (the category and its slash appear only for entities): ```text row / compresses to more than 90 KB on its own and cannot fit one epoch; shrink or split it in Sibyl (push refuses until then, nothing else is blocked) ``` A snapshot is always a single epoch. When the full state is over the limit, `kint compact` refuses with `the full state (N rows) compresses to S bytes, more than the 92160 bytes one epoch can carry, and snapshots are single-epoch`, and ordinary epochs keep working. `--dry-run` on `kint push` or `kint compact` reports what would be anchored and sends nothing. ## What it costs Base charges calldata under EIP-7623's floor, `max(standard, 21000 + 10 * (zeros + 4 * nonzeros))`. Ciphertext is almost all nonzero bytes, so that comes to 40 gas per byte, and the floor absorbs the contract's own execution at every bucket. Measured on a Base fork at block 51,081,018, the receipt's `gasUsed` equals the floor exactly: | bucket | calldata bytes | gasUsed | |---|---|---| | 4 KB | 4,356 | 191,040 | | 8 KB | 8,452 | 355,390 | | 16 KB | 16,644 | 681,840 | | 32 KB | 33,028 | 1,336,090 | | 64 KB | 65,796 | 2,642,640 | | 96 KB | 98,564 | 3,949,670 | On top of that comes the L1 data fee, reported as `l1Fee` in the receipt. All in, a 4 KB epoch costs about 0.000002 ETH on Base. Epoch 1 of the demo tenant carried 14 rows in the 4 KB bucket ([tx](https://basescan.org/tx/0xbe9bb8596c825a8891c3eafe4381e0c6522d647641735c635ca58fcf4bbe4455), block 51081867); epoch 2 followed at block 51081880 ([tx](https://basescan.org/tx/0x369907fb1bccc3222365b2e07b4d6d831642831d481a5f7cb99357823e2a9729)). kint records the real `gasUsed`, `l1Fee` and total from every receipt in the push report. Restoring and reading never send a transaction. ## Where the bytes live The ciphertext lives only in transaction calldata. Reading it back goes through `eth_getTransactionByHash`, which needs a node that keeps its transaction index, and the walk needs `eth_getLogs`. Base's data availability on L1 is the blob retention window, not forever. So kint keeps its own copy. Every epoch a push sends or a pull fetches goes into a local cache under `KINT_HOME` (default `~/.kint`): ```text ~/.kint/epochs// ├── 00000001.bin the ciphertext, byte for byte as sent ├── 00000001.json the decrypted plaintext ├── 00000001.meta.json seq, digest, prev, block, tx, bucket, rows_root, writer └── 00000002.bin ... ``` A pull reads the cache first and trusts a cached blob only when its keccak equals the digest in the `Epoch` event; anything else is fetched from calldata again. The decrypted `.json` files also feed `memory_history` and the read of a row at an earlier block. The directory sits outside every path Sibyl's cap accounting walks, and kint volunteers its size into that cap. > **Note.** The cache is a backup of the ciphertext, not a replacement for the chain. The chain still decides which epochs exist and which digest each one must have; the cache only saves a fetch on a machine that already holds it. Files are written with mode 0600, and `KINT_HOME` itself is mode 0700. Read [Verify before acting](/docs/verify) next. Source: [`src/kint/crypto.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/crypto.py), [`src/kint/canon.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/canon.py), [`src/kint/push.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/push.py), [`src/kint/epoch.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/epoch.py), [`src/kint/chain.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/chain.py), [`src/kint/pull.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/pull.py), [`contracts/src/EpochAnchor.sol`](https://github.com/s0nderlabs/kint/blob/main/contracts/src/EpochAnchor.sol), [`contracts/README.md`](https://github.com/s0nderlabs/kint/blob/main/contracts/README.md), [`docs/frontend-contract.md`](https://github.com/s0nderlabs/kint/blob/main/docs/frontend-contract.md). --- ## Verify before acting > Source: https://github.com/s0nderlabs/kint/blob/main/src/kint/verify.py `memory_verify` re-reads the exact stored text of a recalled row, hashes it and checks it against what Base anchored. The answer is `proceed` or `refuse`, and a changed row is refused with the blocks that prove it. ## The beat Something the agent remembers is not proof that it still holds what was anchored. So before an agent acts on a recalled row, verify asks Sibyl for it and checks what Sibyl found against the chain. `memory_history` then shows what the row held at every anchored epoch. The demo tenant `kint-demo`, head at epoch 2, block 51081880. The output below keeps the lines `kint verify` prints; `...` marks lines cut here (one line prints per checked hit), and the placeholders in angle brackets stand for values not recorded in the transcript. ```text $ kint verify "release rule" query: release rule sibyl verdict: ok hits: verified entity rules/release-gate: stored text matches the leaf anchored on Base ... DECISION: PROCEED: entity rules/release-gate verified against rows_root 390336ff71dbcb85 anchored at epoch 2, block 51081880 $ sqlite3 ~/.sibyl-memory/memory.db "update entities set body = replace(body, 'never', 'always') where name = 'release-gate'" $ kint verify "release rule" query: release rule sibyl verdict: ok hits: drifted entity rules/release-gate: the stored text of this row no longer matches what was anchored: the chain vouches for 4ac605528bbb00e7 (epoch 2, no later than block 51081880), the store now holds (unanchored, head epoch 2 at block 51081880) ... DECISION: REFUSE: entity rules/release-gate: the stored text of this row no longer matches what was anchored: the chain vouches for 4ac605528bbb00e7 (epoch 2, no later than block 51081880), the store now holds (unanchored, head epoch 2 at block 51081880) refusal written back as Sibyl entity kint_refusal/entity-rules-release-gate- $ kint history entity release-gate --category rules epoch 1 block <= 51081867 leaf f406bb0919d23360: {"rule":"never ship on a Friday; every release needs a green deadlift test and a second reviewer", ...} epoch 2 block <= 51081880 leaf 4ac605528bbb00e7: {"rule":"never ship on a Friday; every release needs a green deadlift test, a second reviewer, AND a passing kint verify", ...} ``` The edit went straight into Sibyl's SQLite file, behind Sibyl's back. Sibyl still finds the row and still ranks it. kint catches it because the stored text no longer hashes to the leaf epoch 2 anchored. `history` still shows both anchored versions, because it reads the decrypted epochs, not `memory.db`. If `kint-server` is running when an edit like that lands, its automatic push does not anchor it. The row changed behind Sibyl's tools while the server ran, so the watcher's push comes back `HELD`, anchoring nothing and dropping nothing, and `memory_status` shows why under `held`. Once verify has refused the value, the refusal entity holds it too, in this session and the next. [When a push is held](/docs/how-it-works#when-a-push-is-held) has the rules. ## Sibyl finds the row Verify starts with the search Sibyl's own `memory_search` runs by default: `multi_record_search(client, query, limit=limit)` from `sibyl_memory_client.multi_record`. Inside `kint-server` the client is the same `MemoryClient` that serves Sibyl's eight tools; `kint verify` builds its client the same way. It is Sibyl's two-stage retrieve-then-verify search. Candidates come from the client's own full-text search over four FTS5 indexes (`entities_fts`, `state_documents_fts`, `reference_documents_fts`, `journal_events_fts`), then Sibyl's precision gates drop any candidate the query does not support, or abstain on the whole query. So verify applies the same gates the agent's own search does. Every Sibyl search returns a `SearchResults`: a list of hits that carries a typed `verdict` from `sibyl_memory_client/verdicts.py`. The `VerdictCode` set is closed: | code | meaning (from Sibyl's `explain`) | |---|---| | `ok` | rows matched; the only code on a non-empty result | | `no_match` | the query ran with every gate armed and matched nothing, or carried no searchable terms | | `empty_store` | the store holds no searchable rows for this tenant | | `abstained_on` | one content word in the query appears nowhere in the store and blocks the whole query | | `negation_abstain` | the query is negated and full-text search cannot honour a negation | | `gated` | candidates were found, then dropped by a relevance gate | Through this search every code is reachable; Sibyl's `NEGATION_POLICY` is `abstain`, so a negated query comes back `negation_abstain`. kint keys the decision on the code: anything other than `ok`, or zero hits, means there is no ranked candidate to act on, and verify refuses. The plain `client.search` primitive has no abstention gate and would have answered some of these queries with rows. kint's test asks for `Friday spaceship` on a store that has a Friday rule and no spaceship: `client.search` answers `ok`, and verify refuses on `abstained_on`. kint returns its own copy of the verdict with `code`, `returned`, `recovery`, `tokens`, `gate` (only when set) and `explain`: ```json {"code": "ok", "returned": 3, "recovery": "none", "tokens": [], "explain": "3 row(s) matched."} ``` Take Sibyl out and nothing gets decided: the epoch is a decrypted blob with no query surface, and there is no verdict to key on. ## Re-read the exact stored text A search hit carries a `snippet`, a highlighted excerpt. kint never hashes the snippet. For every hit it re-reads the row by the key the search returned (`tier`, `key`, `category`) through a read-only SQLite connection (`mode=ro`), the same columns the exporter reads: | tier | lookup | columns hashed | |---|---|---| | entity | `category` and `name` | `category`, `name`, `status`, `body` | | state | `document_key` | `document_key`, `body` | | reference | `doc_key` | `doc_key`, `body`, `metadata` | | journal | the event `id` the search returned | `ts`, `evaluated`, `acted`, `forward`, `extra` | The text is hashed exactly as stored. Sibyl's `storage.dumps()` keeps insertion order on purpose, so a row parsed and re-serialised with sorted keys would hash differently and every row would refuse on a second machine. A journal event gets a new uuid when it is replayed on another machine, so its identity is its content: the key is keccak256 over `kint-journal-key-v1` and the five content columns. A second identical event gets `:1`, the third `:2`, in rowid order. ## The leaf The leaf is keccak256 over the prefix `kint-leaf-v1` followed by the row's fields, each written as a 4-byte big-endian length and its UTF-8 bytes (a null field is the length `0xFFFFFFFF` and no bytes): ```text entity, state, reference: kint-leaf-v1 | tier | category | key | status | body | meta journal: kint-leaf-v1 | "journal" | null | key | null | ts | evaluated | acted | forward | extra ``` `updated_at` is left out, because Sibyl regenerates it when a row is replayed. The journal `ts` is content (`write_event` takes it explicitly), so it is in. ## The proof The mirror is this machine's record of what the chain last saw: every row, its leaf, and the head (seq, digest, block). It lives at `~/.kint/mirror-.json` (under `KINT_HOME`). Pull and push fill it; connecting a machine that has none writes an empty one at seq 0. Its `anchored_root` is the `rows_root` anchored with the head epoch. The root orders leaves by canonical id (`tier`, `category`, `key` joined by a null byte), never by rowid, so every machine computes the same root over the same content. Pairs hash as keccak256 of the two children; an odd node is carried up unchanged; an empty set has the root keccak256 of `kint-empty-v1`. When the stored leaf equals the mirror's leaf, verify builds a merkle inclusion proof for the row, a list of `[sibling, sibling_is_left]` pairs, and checks that it reproduces the mirror's root. Past seq 0 the decision has already required that root to equal the anchored `rows_root` (step 3 below). The proof and its result travel back in the check as `proof` and `proof_ok`. > **Note.** Verify sends no transaction and makes one read: the chain head for this owner and space, taken before verify runs and outside every Sibyl write. When that head is not the one this machine last pulled or pushed, verify refuses before it checks a row. When the read fails, or `KINT_OFFLINE=1`, verify checks against the mirror and the epoch cache alone and says so: `kint verify` on stderr, `memory_verify` in the server's log. ## Four statuses per hit | status | when | reason | |---|---|---| | `verified` | the stored leaf equals the anchored leaf and the proof reproduces the root | `stored text matches the leaf anchored on Base` | | `drifted` | the stored leaf differs from the anchored leaf | `the stored text of this row no longer matches what was anchored: the chain vouches for (epoch , no later than block ), the store now holds (unanchored, head epoch at block )` | | `drifted` | the leaves match but the proof fails | `leaf matches but the inclusion proof against the anchored rows_root failed` | | `unanchored` | the row is in the store but not in the mirror | `this row was written after the last anchored epoch; nothing on the chain vouches for it yet (push first)` | | `missing` | the key the search returned no longer reads back | `the row the search returned is no longer in the store` | Two blocks appear in a drift. The anchoring block comes from the epoch cache: the last epoch that changed this row. A snapshot epoch re-anchors every row, but it does not move the provenance of a row it did not change, so a `kint compact` leaves the named block where the value was written. (On a machine that restored from a snapshot and never ran `kint pull --full`, the snapshot is the oldest epoch it holds, and its block is the one named.) The head block is the mirror's head: the newest epoch this machine applied. ## The decision Verify walks these checks in order and stops at the first that applies: 1. Sibyl's verdict is not `ok`, or there are no hits: refuse with `no ranked candidate to act on: Sibyl's verdict is `. 2. There is no mirror file at all (normally a machine never connected under this `KINT_HOME`): refuse with `this machine has never pulled or pushed: nothing on the chain has been checked`. A machine that connected but never pulled has an empty mirror at seq 0 instead; step 4 refuses when the chain has moved past it, and otherwise every hit comes back `unanchored` and step 6 refuses. 3. The mirror is past seq 0 but incomplete: refuse with `this machine's picture of the memory is not the one the chain vouches for: ; pull again`. The why is `epochs could not be applied on the last pull` when a pull stopped at a gap, or `the local mirror's root does not equal the rows_root anchored on the chain` when the replayed rows did not reproduce the anchored root. 4. The chain head was read and its seq or digest is not the mirror's: refuse with `chain moved to seq at block , pull first: this machine last saw seq (<12 hex>), so the row it would check may be superseded`. Another machine anchored something this one has not pulled, and nothing is checked against a stale picture. 5. Any hit is `drifted` or `missing`: refuse on the first one in rank order, with ` /: ` (state, reference and journal rows have no `/`), and write the refusal back. 6. No hit is `verified` and at least one is `unanchored`: refuse with `every candidate is unanchored: nothing on the chain vouches for it yet; push, then verify`. 7. Otherwise proceed, on the highest-ranked verified hit: ` / verified against rows_root <16 hex> anchored at epoch , block `, where the epoch and block are the mirror's head. An unanchored hit is never credited as verified, even when Sibyl ranks it first. If unanchored hits outrank the verified one, the reason says so: `; NOTE higher-ranked hit(s) are UNANCHORED and not vouched for by the chain (): act on the verified row, push before relying on the others`. The test for this writes `rules/release-gate-v2` saying "ship on a Friday whenever" on top of an anchored `rules/release-gate`; the decision is `proceed` on `release-gate`, and when `release-gate-v2` ranks first the reason names it as unanchored. ## The refusal is written back A refusal over a drifted or missing row is written into the store as a Sibyl entity, through Sibyl's own `set_entity`, so the next fresh session finds it with Sibyl's tools like any other entity: | field | value | |---|---| | category | `kint_refusal` | | name | `---`, cut to 200 characters | | status | `refused` | | body | `refused` (true), `query`, `tier`, `category`, `key`, `reason`, `local_leaf`, `anchored_leaf`, `anchored_seq`, `anchored_block`, `anchored_tx`, `head_seq`, `head_block`, `at` (UTC) | The result names it as `kint_refusal/`. It is an ordinary entity, and itself an unanchored change. While the row still holds exactly the refused value, the refusal also holds kint-server's automatic pushes ([When a push is held](/docs/how-it-works#when-a-push-is-held)), so it reaches Base with whatever settles the row: `kint push` from your terminal anchors both, and `kint pull --discard-local` restores the anchored value and leaves the refusal in the backup copy. The other refusals (no candidate, no mirror, incomplete mirror, chain moved, every candidate unanchored) write nothing. `memory_verify` always writes the refusal; `kint verify --no-write` skips it. ## kint verify ```sh kint [--tenant TENANT] [--db PATH] verify QUERY [--limit N] [--json] [--no-write] ``` | argument | default | effect | |---|---|---| | `QUERY` | required | the text Sibyl searches for | | `--limit` | `5` | how many hits Sibyl returns and kint checks | | `--json` | off | print the whole result as JSON instead of the lines below | | `--no-write` | off | do not write a refusal entity | | `--tenant` | `KINT_TENANT`, then `credentials.json`, then Sibyl's default | the Sibyl tenant (before the subcommand) | | `--db` | `$SIBYL_MEMORY_DB` or `~/.sibyl-memory/memory.db` | the Sibyl store (before the subcommand) | Before it verifies, it reads the chain head for the connected owner (not under `KINT_OFFLINE=1`, and not on a machine that is not connected). When that read fails it prints `kint: chain head unavailable (); verifying against the local mirror only` to stderr and carries on. Without `--json` it prints `query:`, then `sibyl verdict: hits: `, one line per checked hit (status, row, reason; none when verify refused before checking rows), then `DECISION: PROCEED: ...` or `DECISION: REFUSE: ...`, then the refusal entity if one was written. The exit code is `0` on proceed and `1` on refuse. With `--json` it exits `0` either way, so read `decision`. ## memory_verify The MCP tool runs the same function inside `kint-server`. | parameter | type | default | |---|---|---| | `query` | string | required | | `limit` | integer | `5` | It returns `ok: true` with the result, or `ok: false` with `error` (the exception type) and `message` if verify raised. | field | content | |---|---| | `query` | the query as given | | `verdict` | kint's copy of Sibyl's verdict, shown above | | `decision` | `proceed` or `refuse` | | `reason` | one line, as in the decision list | | `hits` | per hit, as Sibyl returned it: `tier`, `key`, `category`, `snippet`, `rank`, `ts` | | `checks` | per hit: `tier`, `key`, `category`, `status`, `local_leaf`, `anchored_leaf`, `anchored_seq`, `anchored_block`, `anchored_tx`, `head_seq`, `head_block`, `rows_root`, `proof`, `proof_ok`, `reason`; empty when verify refused before checking rows | | `refusal_entity` | `kint_refusal/` when a refusal was written, otherwise null | | `chain_head` | `{"seq", "digest", "block"}` as read for this call; null when it could not be read, under `KINT_OFFLINE=1`, or when verify refused before comparing it | ## What the agent believed at an earlier block Sibyl's store holds a row's current text. The epochs on Base hold every version that was anchored. `memory_history` and `kint history` list them, oldest first, each with the block of the epoch that carried it. That block is an upper bound. The version existed no later than block N: it was written on some machine before the push, and the chain says nothing about how long before. It is never a wall-clock "as of". With a block given, you get the newest entry anchored at or before that block (a deletion entry when the row was deleted by then), or null if the row had no anchored version by then. Versions come from the local epoch cache, `~/.kint/epochs//`, which keeps the decrypted plaintext of every epoch this machine pushed, or pulled and could open. History makes no RPC call. A cold start stops at the newest snapshot epoch, so a freshly restored machine sees versions from that snapshot on, and a later pull that stops at a newer snapshot skips the diff epochs before it. `kint pull --full` (or `memory_pull` with `full`) walks past snapshots and caches every epoch this machine has not decrypted, those skipped ones included. Epochs sealed under a data key this machine does not hold, after a `kint rekey`, are reported by the pull and stay closed, so their versions do not appear. A snapshot re-anchors every row. Its entry is marked `snapshot: true` and is dropped when the row did not change, so a compaction or a rotation never invents a version. A deletion shows as its own entry. ### kint history ```sh kint [--tenant TENANT] history {entity,state,reference} KEY [--category CATEGORY] [--block N] ``` `KEY` is the entity name, state key or reference key. `--category` is part of an entity's identity: without it an entity never matches and nothing prints. State and reference rows have no category. Each version prints as `epoch block <= leaf <16 hex>: `, a deletion as `epoch block <= : DELETED`. With `--block N` it prints the version live at that block as JSON, or `null`. ### memory_history | parameter | type | default | |---|---|---| | `tier` | string: `entity`, `state` or `reference` | required | | `key` | string | required | | `category` | string, required for entities | null | | `block` | integer | null | Without `block` it returns `{"ok": true, "versions": [...], "count": n}`. With `block` it returns `{"ok": true, "at_block": N, "version": ...}`, where `version` is null when nothing was anchored by then. Each version carries `seq`, `block`, `tx`, `leaf` (full hex), `body` (the stored text), `status`, `deleted: false`, and `snapshot: true` on a snapshot entry; a deletion carries `seq`, `block`, `tx` and `deleted: true`. Read [MCP tools](/docs/tools) next. Source: [`src/kint/verify.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/verify.py), [`src/kint/canon.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/canon.py), [`src/kint/export.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/export.py), [`src/kint/epoch.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/epoch.py), [`src/kint/server.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/server.py), [`src/kint/cli.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/cli.py), and Sibyl's `sibyl_memory_client/verdicts.py` in [Sibyl-Memory](https://github.com/Sibyl-Labs/Sibyl-Memory). --- ## MCP tools > Source: https://github.com/s0nderlabs/kint/blob/main/src/kint/server.py `kint-server` is one MCP server on stdio: Sibyl's own `build_server()` registers their eight `memory_*` tools exactly as shipped, and kint registers six more on the same instance. ## What the server does around the tools A harness sees fourteen tools. This page lists every one: its parameters and defaults, what it returns, and every error it can return. - **One client.** kint opens one `MemoryClient` on the store at `SIBYL_MEMORY_DB` (default `~/.sibyl-memory/memory.db`) for the tenant in `KINT_TENANT` (else Sibyl's `credentials.json`, else Sibyl's default), and seeds it into Sibyl's module cache. Their eight tools write through it, so `KINT_TENANT` picks their tenant too, and the cap they enforce counts kint's own footprint against Sibyl's 5 MiB free-tier cap (5,242,880 bytes). If Sibyl's `credentials.json` changes while the server runs, Sibyl's cache builds its own client again; restart `kint-server` after that. - **Pull on start.** When this machine is connected and the data key is cached, the server pulls from Base as it starts. It waits for that pull at most `KINT_BOOTSTRAP_SECONDS`, then serves while the pull finishes behind it; a `memory_pull` or `memory_push` in the meantime waits for the pull's head lock, up to 30 seconds. A fork, a freshness refusal or a failed pull is logged, and the server serves the local store anyway. [Harnesses](/docs/harnesses) has the timeouts. - **Push while serving.** A watcher polls the store's modification time and pushes unanchored changes after a quiet period, or sooner when the change set grows past a size estimate. It pushes only when the machine is connected and the data key is cached, and it starts with whatever rows an earlier session left unanchored. - **Hold instead of launder.** Every push the server makes (the watcher's, the exit push, `memory_push`) checks the store first and answers `HELD`, anchoring nothing, when a row the chain vouches for changed behind Sibyl's tools while the server ran, or when `memory_verify` already refused exactly the value it holds. `kint push` in a terminal is never held. [Limits](/docs/limits#writers) has the edges. - **Renew the data key.** Every successful pull and push moves the cached data key's deadline forward by `KINT_KEY_TTL`, so a server in use does not lapse. - **Push on exit.** On a normal exit, `SIGTERM` or `SIGHUP`, the server pushes whatever is unanchored, once and best effort. The signal handler only marks the exit; the push runs after the tool call in flight has finished, never inside a Sibyl write. - **Logs never touch stdout.** The stdio transport owns stdout. kint logs to stderr and to `kint.log` under `KINT_HOME` (default `~/.kint`). | variable | default | what it sets | |---|---|---| | `KINT_QUIET_SECONDS` | `300` | seconds without a store change before the watcher pushes | | `KINT_POLL_SECONDS` | `15` (floor 1) | how often the watcher checks the store | | `KINT_SIZE_TRIGGER_BYTES` | `32768` | push early when (changed + deleted rows) x 400 bytes exceeds this; checked at most once a minute while dirty | | `KINT_NO_WATCHER` | unset | `1` turns the watcher off | | `KINT_BOOTSTRAP_SECONDS` | `20` | seconds the server waits for its startup pull before it serves | The rest of the environment is in [Configuration](/docs/configuration). ## All fourteen | tool | from | what it does | |---|---|---| | `memory_remember` | Sibyl | store an entity by category and name | | `memory_recall` | Sibyl | read one entity by exact category and name | | `memory_search` | Sibyl | full-text search across entities, state, reference and journal, with a typed verdict | | `memory_list` | Sibyl | list entities, most recently updated first | | `memory_forget` | Sibyl | archive an entity | | `memory_set_state` | Sibyl | write a HOT-tier state document | | `memory_get_state` | Sibyl | read a state document | | `memory_record_event` | Sibyl | append a COLD-tier journal event | | `memory_status` | kint | owner, space, chain head, mirror, unanchored changes, session key, cap | | `memory_connect` | kint | connect this machine, or say exactly what the human must run | | `memory_pull` | kint | restore or refresh the store from Base | | `memory_push` | kint | anchor unanchored changes on Base now | | `memory_verify` | kint | search, check every hit against its anchored leaf, decide proceed or refuse | | `memory_history` | kint | every anchored version of a row, or the one live at a block | Harnesses prefix the names with the registration: Claude Code shows `mcp__kint__memory_verify`, OpenClaw `kint__memory_verify`. ## Sibyl's eight This is Sibyl's code, untouched. Everything below is read from `sibyl_memory_mcp/server.py` in the pinned `sibyl-memory-mcp` (>=0.2.1,<0.3). | tool | parameters | returns | |---|---|---| | `memory_remember` | `category`, `name`, `body` | `{ok, category, name}` | | `memory_recall` | `category`, `name` | `{ok, entity}` with `id, tenant_id, category, name, status, body, created_at, updated_at` | | `memory_search` | `query`, `limit=10`, `tiers=None` | `{ok, query, count, results, verdict}` | | `memory_list` | `category=None`, `limit=50` | `{ok, category, count, results}` | | `memory_forget` | `category`, `name`, `reason=None` | `{ok, archived: {category, name}}` | | `memory_set_state` | `key`, `body` | `{ok, key}` | | `memory_get_state` | `key` | `{ok, key, body, updated_at}` | | `memory_record_event` | `kind`, `body`, `category=None`, `name=None` | `{ok, event_id, kind}` | - `memory_remember` is idempotent on `(category, name)`: a second call updates the entry. A dict or list `body` is stored as is; a string, number, boolean or null is wrapped as `{"value": ...}`. - `memory_recall` bounds the body at 1,000,000 characters and sets `truncated` when it cuts. - `memory_search` clamps `limit` to 1–50 and returns nothing, with a `no_match` verdict, for a query shorter than 3 characters. `tiers` is a comma-separated filter over `entity`, `state`, `reference`, `journal`; passing it calls the raw search directly and skips the multi-record linker and its abstention gate. - `memory_list` clamps `limit` to 1–200. - `memory_forget` moves the entity to `archived_entities`, where recall, list and search no longer see it, and records `reason`. - `memory_set_state` keeps one row per key, overwritten on each set; a primitive body is wrapped as in `memory_remember`. - `memory_get_state` returns `{ok: false, code: "NOT_FOUND", key}` as an ordinary result when the key is absent, and adds `truncated` when the body was cut. - `memory_record_event` is append-only. `body` must be an object; `category` and `name` name the entity the event is about. **Verdicts.** Every `memory_search` result carries `verdict`: `code`, `tokens`, `gate`, `gate_drops`, `best_pre_gate_coverage`, `tokens_total`, `tokens_scored`, `dropped_function`, `candidates`, `returned`, `abstained`, `recovery`, `explain`. `code` is `ok` when rows came back, otherwise exactly one cause: `abstained_on`, `negation_abstain`, `gated`, `empty_store` or `no_match`. `recovery` is one of `none`, `drop_token_and_retry`, `rephrase_without_negation`, `broaden_query`, `write_first`. On `abstained_on`, Sibyl's own instruction is to drop the word in `verdict.tokens[0]`, search again, and stop after two retries. **Untrusted content.** `memory_recall`, `memory_search`, `memory_list` and `memory_get_state` add an `_untrusted_context` block (`nonce`, `begin`, `end`, `note`) telling the agent to treat stored values as data. They strip forged fence markers from every string and cap each search or list hit's `body` and `snippet` at 1,500 characters, inside a total budget of about 200,000. **Errors.** Sibyl's tools fail as MCP errors (`isError` true) whose text is JSON: `error` (the exception class), `message`, `code`, and for some codes `recovery` and `upgrade_url`. `code` is one of `CAP_EXCEEDED`, `TIER_GATED`, `TIER_VERIFICATION_FAILED`, `NOT_FOUND`, `VALIDATION_ERROR`, `ERROR`. An argument of the wrong type comes back as `{ok: false, code: "VALIDATION_ERROR", error: "ValidationError", message}` without echoing the value. That guard wraps the server's tool dispatch, so it covers kint's six too. ## kint's six Called with valid arguments, kint's tools do not raise. Every one returns a dict with `ok`; a failure is an ordinary result with `ok: false`, an `error` string, and usually a `message` or `hint` (`OWNER_REQUIRED` carries neither). An agent that only watches `isError` misses them: read `ok`. ### memory_status No parameters. It reads local state, plus the chain when an owner is known (the head, the session key's balance, `canWrite`, `sessionKeyExpiry`). It never sends a transaction. | field | value | |---|---| | `ok` | `true` | | `owner` | the owner address this machine is enrolled under, or `null` | | `tenant` | the tenant the server resolved | | `space` | the space id for that tenant, hex | | `db_path` | the Sibyl store the server wraps | | `connected` | `true` when an owner is known (the data key may still be absent) | | `data_key_cached` | whether the unwrapped data key is cached and unexpired | | `held` | `null`, or why kint-server is holding its pushes: `message`, `reason` (`watcher`, `exit`, `tool`, or `status` when no push was tried yet) and `at` (UTC); recomputed on every call | | `cap` | `sibyl_bytes`, `kint_bytes`, `volunteered_total`, `cap_bytes`, `db_path` | | `mirror` | what this machine last saw anchored: `seq`, `digest`, `block`, `rows`; `null` until it connects or pulls | | `unanchored` | `changed` and `deleted` row counts since that epoch, or `error` | | `chain_head` | `seq`, `digest`, `block`, `contract`, or `error`; present only when an owner is known | | `in_step` | `true` when the mirror's seq and digest equal the chain head's | | `session_key` | `address`, `path`, `exists`, `balance_wei`, `balance_eth`, `authorized`, `expiry`, and `error` if a chain read failed | `in_step` and `session_key` appear only when the chain head was read. ### memory_connect | parameter | default | meaning | |---|---|---| | `passphrase` | `None` | the vault passphrase (the Base Account path, like `kint connect --smart-account`) | | `recovery_code` | `None` | the recovery code; used first when both are given | | `owner` | `None` | the owner address; required on the first connect, otherwise the enrolled owner is used | Without `passphrase` or `recovery_code` it connects nothing. It returns `{ok: true, connected: true, owner}` when an owner is known and the data key is cached; otherwise it returns the three commands a human runs, verbatim: ```json { "ok": true, "connected": false, "owner": null, "human_steps": [ "EOA wallet (Ledger, MetaMask, Rabby, keystore): kint canonical-payload --owner 0x... > kint-canonical.json && cast wallet sign --data --from-file kint-canonical.json --ledger | kint connect --owner 0x... --signature -", "Base Account / smart wallet owner: kint connect --owner 0x... --smart-account --passphrase-stdin", "Lost both? kint connect --owner 0x... --recovery-code-stdin" ] } ``` With `passphrase` or `recovery_code` it connects directly and returns `ok`, `owner`, `space`, `kek_tag8` (the first 8 hex of the key tag, `none` for a recovery code), `fresh_vault`, and `dek_source`: `local wraps`, `chain head header`, `fresh vault` or `recovery code`. A passphrase for an owner with nothing on the chain yet, and no key wraps on this machine, creates a vault; the result then adds `recovery_code_file` and a `note` to copy that code somewhere safe. A recovery code cannot start a vault. Errors: `OWNER_REQUIRED` when no owner was passed and none is enrolled. Otherwise the exception class with `message`, for example `ConnectError` (wrong key, wrong tenant, a key the last rotation did not carry over, a recovery code for another vault), `ChainUnreadable` (the head could not be read and this machine holds no key wrap that the passphrase opens) or `KintCryptoError` (a recovery code that fails its checksum). The derive signature is never accepted here. It is a secret: it travels on stdin through `kint connect --signature -` (or in a file that `--signature-file` reads and then deletes), never on argv and never through a tool. > **Warning.** `memory_connect` does accept `passphrase` and `recovery_code`, and anything typed into an agent chat lands in the transcript. Prefer the terminal: `kint connect --owner 0x... --smart-account --passphrase-stdin` or `kint connect --owner 0x... --recovery-code-stdin`. ### memory_pull | parameter | default | meaning | |---|---|---| | `discard_local` | `false` | move the local store aside and restore from the chain; the way past a fork | | `force_scan` | `false` | accepted and passed to the pull; v0.3.0 does not act on it | | `full` | `false` | walk past snapshot epochs to the first epoch; on a store already up to date, walk from the head to the first epoch and cache every one not yet decrypted, for `memory_history` | It checks freshness first (the local watermark; on a cold start two different RPC endpoints must agree on the head, unless `KINT_ALLOW_SINGLE_RPC=1`), refuses a fork, walks epochs back from the head to the newest snapshot, verifies each against the chain, decrypts, and replays the rows through Sibyl's own write methods. An epoch this machine cannot open (not a kint epoch, or sealed under a key it lacks) is walked past when a later snapshot in the same walk opens; any other epoch it cannot apply stops the pull there. `discard_local` first moves `memory.db` and its `-wal` and `-shm` files to `.kint-backup-`. A pull that walks epochs opens a fresh client and hands it to Sibyl's eight tools. The walk itself is in [Restore on a new machine](/docs/new-machine) and [Epochs on Base](/docs/epochs). | field | value | |---|---| | `applied` | epochs applied in this pull | | `skipped` | epochs the pull could not apply, as `{seq, block, tx, reason}`. An entry with `superseded_by` names the later snapshot the pull resumed at, and the picture is complete; an entry without it is where the pull stopped, and nothing after it is applied | | `head_seq`, `head_digest`, `head_block` | the head this machine now holds | | `rows_total` | rows in the store after the pull (on an up-to-date store, the mirror's row count) | | `root_ok` | whether the store's merkle root equals the anchored `rows_root`; `null` when the store was already up to date | | `rpcs_agreed` | on a cold start, whether the second RPC agreed; `null` otherwise | | `unopenable` | epochs sealed under a data key this machine does not hold, as `{seq, block, tx, reason}` | | `backfilled` | epochs `full` cached for history that this machine had not decrypted before | | `message` | one line, including any warning count | Errors: `NOT_CONNECTED` (hint `call memory_connect`); `FORK` (unanchored local changes while the chain moved, or a local store this machine has no record of); `NOT_FRESH` (the RPC served a head older than one this machine saw, the two RPCs disagree, `KINT_RPC_URL_2` is the same endpoint as the primary, or the second RPC did not answer); `PULL_FAILED` (for example, no data key on this machine); or the exception class, such as `ChainError`, or `TimeoutError` when another kint process held the head lock for 30 seconds. ### memory_push | parameter | default | meaning | |---|---|---| | `snapshot` | `false` | anchor one epoch carrying the whole state instead of the diff, even when nothing changed (what `kint compact` does) | It first checks whether the push must be held (`HELD`, below), then diffs the store against the last anchored epoch, splits the change set so each epoch compresses to under 90 KB, pads it to a size bucket, encrypts it to the wallet, and sends one Base transaction per epoch from this machine's session key, waiting for two confirmations each. | field | value | |---|---| | `reason` | `tool` for this call (the watcher and the exit hook run the same push as `watcher` and `exit`) | | `pushed` | epochs anchored | | `changed_rows`, `deleted_rows` | the diff (for a snapshot, every row) | | `head_seq`, `head_digest` | the head after the push | | `epochs` | per epoch: `seq`, `tx`, `block`, `digest`, `bucket`, `rows`, `deleted`, `snapshot`, `cost_wei`, `gas_used`, `l1_fee` | | `message` | `nothing to push: the store matches the last anchored epoch`, or what was anchored | | `error` | when | what to do | |---|---|---| | `NOT_CONNECTED` | no owner enrolled on this machine | call `memory_connect` (hint `call memory_connect`) | | `BUSY` | a push is already running in this server | wait (hint `a push is already running`) | | `HELD` | a row the chain vouches for changed behind Sibyl's tools while this server ran, `memory_verify` already refused exactly the value a row holds, or the store could not be read to check; `message` names up to three rows per ground | stop and tell the human: nothing was anchored and nothing was dropped (hint `nothing was anchored and nothing was dropped; memory_verify still refuses the row`). They check the store, then run `kint push` or `kint pull --discard-local` in their own terminal | | `KEY_EXPIRED` | the cached data key expired or is missing | the human runs `kint connect`; nothing was dropped | | `CHAIN_MOVED` | another machine pushed under this owner, or this machine's own last push landed after its confirmation wait failed ([Limits](/docs/limits#writers)) | `memory_pull` (hint `call memory_pull first`); a plain push only reaches this check when it has changes, so that pull answers `FORK` and the human decides | | `PUSH_FAILED` | no key wraps, the last pull left a gap (for an epoch that will never open, the message names `kint compact --over-skipped`), the recovery code file is gone on the machine that created the vault, one row over 90 KB compressed, a snapshot too big for one epoch, the session key not authorized, or an epoch that could not be sealed | read `message`; it names the fix | | an exception class | for example `ChainError` (a revert, a confirmation timeout, an event digest that does not match), `KeyError_` (no session key on this machine), `TimeoutError` (another kint process held the head lock for 30 seconds) | read `message` | ### memory_verify | parameter | default | meaning | |---|---|---| | `query` | required | what to search for | | `limit` | `5` | how many hits to check | It runs the same search `memory_search` runs without `tiers`: Sibyl's `multi_record_search` through the one client, with its linker and every precision gate, at its own `limit` (5, where `memory_search` defaults to 10). It re-reads the exact stored text of every hit by the key the search returned, hashes it, and compares it with the leaf this machine last saw anchored, with a merkle inclusion proof against the anchored `rows_root`. It also reads the chain head, outside every Sibyl write, and refuses when the head has moved past this machine's mirror. That read is best effort: when the chain cannot be read, the server logs `chain head unavailable (...); verifying against the local mirror only` and checks against the mirror alone; `KINT_OFFLINE=1` skips the read. | field | value | |---|---| | `decision` | `proceed` or `refuse` | | `reason` | one sentence; a drift names the block that anchored the last good value and the current head block | | `query` | the query | | `verdict` | Sibyl's typed verdict: `code`, `returned`, `recovery`, `tokens`, `gate` when Sibyl sets one, `explain`. `code` is `ok`, or one of Sibyl's five causes: `abstained_on`, `negation_abstain`, `gated`, `empty_store`, `no_match` | | `hits` | per hit: `tier`, `key`, `category`, `snippet`, `rank`, `ts` | | `checks` | per hit: `tier`, `key`, `category`, `status` (`verified`, `drifted`, `unanchored`, `missing`), `local_leaf`, `anchored_leaf`, `anchored_seq`, `anchored_block`, `anchored_tx`, `head_seq`, `head_block`, `rows_root`, `proof`, `proof_ok`, `reason` | | `refusal_entity` | `kint_refusal/---` when a refusal was written, else `null` | | `chain_head` | the head read for this check, `{seq, digest, block}`; `null` when it could not be read, or when the decision was made before the head check | It refuses when the verdict is not `ok` or there are no hits, when this machine has no mirror (it never connected, pulled or pushed), when the last pull left a gap or the local root differs from the anchored one, when the chain head moved past the mirror (`chain moved to seq N at block B, pull first: ...`), when any hit drifted or is missing, or when every hit is unanchored. An edit made on this machine to a row that was already anchored reads as `drifted` until it is pushed. The first drifted or missing hit is written back as a Sibyl entity in category `kint_refusal` with status `refused`, so the next fresh session finds it through Sibyl's own tools. While a row still holds the value a refusal names, every kint-server push answers `HELD`, so push your own edits before you verify them. A `proceed` whose higher-ranked hits are unanchored says so in `reason`. Errors come back as the exception class with `message`. The whole decision is in [Verify before acting](/docs/verify). ### memory_history | parameter | default | meaning | |---|---|---| | `tier` | required | `entity`, `state` or `reference` | | `key` | required | the entity name, state key or reference key | | `category` | `None` | required for entities: the row id includes it, so an entity lookup without it finds nothing | | `block` | `None` | return the version live at this block instead of the list | Without `block` it returns `{ok, versions, count}`, oldest first. Each version is `seq`, `block`, `tx`, `leaf`, `body`, `status`, `deleted: false`, plus `snapshot: true` when a snapshot epoch carried it; a snapshot that re-anchors an unchanged row adds no version. A deletion is `{seq, block, tx, deleted: true}`. With `block` it returns `{ok, at_block, version}`: the last version anchored at or before that block, or `null`. Every `block` is an upper bound: the row existed no later than that block. History reads the local epoch cache only, so it covers the epochs this machine has decrypted. A restore that stopped at a snapshot starts there, and a later pull that jumped to a newer snapshot skips the diff epochs in between; `memory_pull` with `full` caches all of them, as far back as the newest key rotation. Errors come back as the exception class with `message`. ## Which tool, when 1. At the start of a session, call `memory_status`. If `connected` or `data_key_cached` is false, call `memory_connect` with no arguments and give the human its `human_steps`. If `in_step` is false, call `memory_pull`: `memory_verify` refuses while the head is past this machine's mirror. If `held` is not `null`, tell the human its `message`. 2. Recall with Sibyl's tools: `memory_search`, `memory_recall`, `memory_get_state`, `memory_list`. 3. Before acting on anything recalled, call `memory_verify`. Act only on `proceed`. On `refuse`, tell the human the `reason` and do not act. If you changed the row yourself, `memory_push` before you verify it. 4. Write with Sibyl's tools. You do not need to push after each write; the watcher anchors after the quiet period. Call `memory_push` when the change must be on Base now, for example before another machine picks the work up. 5. On `CHAIN_MOVED`, call `memory_pull`; with unanchored changes here it answers `FORK`. On `FORK`, stop and ask the human: `discard_local` throws the unanchored rows away. On `KEY_EXPIRED`, the human runs `kint connect`. On `HELD`, stop: the human checks the store and runs `kint push` or `kint pull --discard-local` in their own terminal. 6. For what the agent believed earlier, call `memory_history`, with `block` for a point in time. 7. When a cold start walks many epochs, `memory_push` with `snapshot` lets the next one stop at the snapshot. ## There is no rekey tool Rotating the data key is `kint rekey`, in a terminal, and nothing else. It needs every key that is to keep opening the vault (the wallet signature, the vault passphrase), and those are read on stdin or at a prompt in the human's own terminal, never through an agent chat. Creating and authorizing a session key, writing the recovery code file, registering harnesses and `--over-skipped` (the owner's way past an epoch that will never open, on `kint compact` and `kint rekey`) are CLI only too. [Keys and custody](/docs/keys) explains the rotation. Read [CLI](/docs/cli) next. Source: [`src/kint/server.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/server.py), [`src/kint/verify.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/verify.py), [`src/kint/push.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/push.py), [`src/kint/pull.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/pull.py), [`src/kint/connect.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/connect.py), [`src/kint/store.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/store.py), [`sibyl_memory_mcp/server.py`](https://github.com/Sibyl-Labs/Sibyl-Memory). --- ## CLI > Source: https://github.com/s0nderlabs/kint/blob/main/src/kint/cli.py `kint` is the thin CLI over the same functions `kint-server` exposes as tools, for what needs a shell. This page lists every subcommand, every flag and its default, and marks which ones need the owner key or a secret on stdin. It covers the connect pipe (a signature on stdin), key rotation, the session key, push and pull for SDK-direct harnesses such as Hermes, and `kint setup` to point a harness at `kint-server`. If your shell sets `PYTHONPATH`, run both binaries with it unset: `env -u PYTHONPATH kint status`. `kint doctor` flags it when it is set. ## Global options These two go before the subcommand: `kint --tenant kint-demo status`, not `kint status --tenant kint-demo`. | Option | Default | Used by | |---|---|---| | `--tenant TENANT` | `KINT_TENANT`, else `tenant_id` then `account_id` from Sibyl's `credentials.json`, else Sibyl's default tenant `00000000-0000-0000-0000-000000000001` | every command except `canonical-payload` and `setup` | | `--db DB` | `$SIBYL_MEMORY_DB`, else `~/.sibyl-memory/memory.db` | `push`, `compact`, `rekey`, `pull`, `verify`, `status`, `doctor`, `export` | The tenant picks the space, `keccak256("kint-space-v1" || tenant_id)`, so it decides which chain of epochs a command reads and writes. Sibyl's credentials file is `$SIBYL_CREDENTIALS`, else `~/.sibyl-memory/credentials.json`. Neither option reaches `kint-server`: a harness passes the tenant and the store path as environment variables (see [setup](#kint-setup)). ## What each command touches | Command | Reads Base | Sends a transaction | What it needs from you | |---|---|---|---| | `canonical-payload` | no | no | nothing | | `connect` | yes, the head epoch's header, or the newest readable one before it | no | the derive signature on stdin or in a file, the vault passphrase, or the recovery code on stdin | | `session-key create`, `rotate` | no | no | nothing | | `session-key show` | yes | no | nothing | | `authorize payload` | yes | no | nothing; you sign its output with the owner wallet | | `authorize submit` | yes | yes, from the session key | the owner's authorization signature on stdin | | `authorize direct` | yes | yes, from the owner | the owner's private key in `KINT_OWNER_KEY` | | `authorize page` | yes | yes, from the Base Account in the browser | nothing in the terminal | | `authorize burn-nonce` | yes | yes, from the owner | the owner's private key in `KINT_OWNER_KEY` | | `push`, `compact` | yes | yes, from the session key | a cached data key | | `rekey` | yes | yes, from the session key | every key that is to keep opening the vault | | `pull` | yes | no | a cached data key | | `join` | yes | no | the owner address and one secret, as for `connect`; the CLIs of the harnesses it registers | | `recovery-code` | yes, the head header | no | a cached data key | | `verify` | yes, the chain head (skipped with `KINT_OFFLINE=1`) | no | nothing; it may write a refusal into Sibyl | | `history`, `export` | no | no | nothing | | `status` | yes, when connected | no | nothing | | `doctor` | yes | no | nothing | | `setup` | no | no | the harness CLI on `PATH` (Codex needs none) | `KINT_OFFLINE=1` stops `connect`, `rekey` and `recovery-code` from reading the head header, and `verify` from reading the chain head. The owner key is only ever read from the environment, never from argv. > **Warning.** `--signature` takes `-` (stdin) or a file path, never the signature itself. Any other value is treated as a path: the file is read, then unlinked. The signature an EOA owner signs to connect is the vault key, so pipe it from `cast wallet sign` or keep it in a file you are happy to lose. ## Connect and keys ### kint canonical-payload Prints the frozen EIP-712 message an EOA owner signs to derive the vault key: domain `kint`, version `1`, chain id 8453, primary type `KintVault` with `owner` and `purpose`. The output is byte-stable and ends with a newline, which is what `cast wallet sign --data --from-file` reads. It ignores `--tenant`. | Flag | Default | Meaning | |---|---|---| | `--owner` | required | the owner wallet address | ```sh kint canonical-payload --owner 0xYOU > kint-canonical.json ``` ### kint connect Connects this machine to the vault. It finds the data key in this order: the local wraps (while they hold the key the head epoch was sealed with), then the head epoch's own header on Base, else it starts a fresh vault. When the head epoch's header does not parse, it reads the newest epoch within 32 before it whose header does (see [Limits](/docs/limits#writers)). On a fresh vault it writes the recovery code to `~/.kint/RECOVERY-.txt`, and `push` refuses until that file exists. | Flag | Default | Meaning | |---|---|---| | `--owner` | none; the command fails with `--owner 0x... is required` | the owner wallet address | | `--signature` | none | `-` reads the 65-byte hex signature from stdin | | `--signature-file PATH` | none | the signature from a file, read then unlinked | | `--smart-account` | off | the owner is a smart account (Base Account): the key comes from a vault passphrase | | `--passphrase-stdin` | off | read the passphrase from stdin | | `--passphrase-prompt` | off | EOA mode: salt the wallet key with a passphrase, prompted | | `--add-passphrase` | off | EOA mode: also add a passphrase wrap for the data key, prompted | | `--recovery-code-stdin` | off | read the recovery code from stdin | Three modes, checked in this order. With `--recovery-code-stdin`, the code is checked against the vault on the chain (or the cached epoch or key) before it is trusted; a recovery code cannot start a new vault. With `--smart-account`, the vault passphrase comes from stdin or from the prompt `vault passphrase: `. Otherwise it is EOA mode, which needs `--signature -` or `--signature-file`. `--passphrase-stdin` and `--signature -` cannot share stdin: use `--passphrase-prompt` or `--signature-file`. A passphrase that salts the wallet key has to be given again on every later connect and rekey. ```sh cast wallet sign --data --from-file kint-canonical.json --ledger | kint connect --owner 0xYOU --signature - kint connect --owner 0xYourBaseAccount --smart-account kint connect --owner 0xYOU --recovery-code-stdin < code.txt ``` It prints the owner, the first 16 hex of the space, the first 8 hex of the key tag, where the data key came from (`local wraps`, `chain head header`, `fresh vault` or `recovery code`) and the session key address. It never prints the signature or either key. ### kint session-key This machine's session key: a plain EOA that signs Base transactions to EpochAnchor and never decrypts. It lives at `~/.kint/session.key`, a V3 scrypt keystore with mode 0600. The keystore passphrase comes from `KINT_SESSION_PASSPHRASE`, else the macOS Keychain item `dev.kint-session-key` (created on first use, skipped with `KINT_NO_KEYCHAIN=1`), else `~/.kint/local.secret`. | Argument or flag | Default | Meaning | |---|---|---| | `create`, `show` or `rotate` | required | the action | | `--owner` | the connected owner | `show` only: the owner to check the authorization against | `create` prints `created: 0x...`, or `exists: 0x...` if a key is already there. `rotate` moves the old keystore to `session.key.old` and creates a new key, which then needs its own authorization and funding. `show` prints JSON: `address`, `path`, `exists`, and when Base answers `balance_wei`, `balance_eth`, `authorized` and `expiry`. ```sh kint session-key create kint session-key show ``` ### kint authorize Authorizes this machine's session key to push under the owner. Every action needs a session key on this machine and an owner (`--owner`, else the connected owner). | Argument or flag | Default | Meaning | |---|---|---| | `payload`, `submit`, `direct`, `page` or `burn-nonce` | required | the action | | `--owner` | the connected owner | the owner wallet address | | `--days` | `30` | the authorization's lifetime, from now (`payload`, `direct`, `page`) | | `--signature` | `-` (stdin) | `submit`: the owner's authorization signature | | `--expiry` | none | `submit`: the expiry `payload` printed | | `--deadline` | none | `submit`: the deadline `payload` printed | - `payload` prints the `SessionKeyAuthorization` typed data (domain `kint EpochAnchor`, version `1`, chain id 8453, the contract as verifying contract) with the owner's current `authNonce` and a deadline 300 seconds out. The JSON goes to stdout; the next command, with its `--deadline` and `--expiry`, goes to stderr. - `submit` sends `setSessionKeyBySig` from the session key, which pays the gas. The deadline has to be in the future when the transaction lands. - `direct` reads the owner's private key from `KINT_OWNER_KEY`, refuses if it is not the owner's, and sends `setSessionKey` from the owner. - `page` is for Base Account owners. It serves one page on `127.0.0.1` with a random port and a random token in the path, waits up to 900 seconds for the browser to report, then polls `canWrite(owner, key)` every 3 seconds, 120 times. No secret crosses the page. - `burn-nonce` sends `setSessionKey` from `KINT_OWNER_KEY`, keeping the key's current expiry. That increments `authNonce`, which cancels any authorization that was signed and never submitted. Like `direct`, it refuses when the key is not the owner's: `KINT_OWNER_KEY is 0x..., not the owner 0x...`. ```sh kint authorize payload > auth.json cast wallet sign --data --from-file auth.json --ledger | kint authorize submit --deadline D --expiry E --signature - KINT_OWNER_KEY=... kint authorize direct --days 7 kint authorize page ``` ### kint rekey Rotates the data key: a new key, new wraps under the same keys, one snapshot epoch sealed under the new key, and a new recovery code. Every key that is to keep opening the vault must be supplied in the same command, because a wrap can only be made by whoever holds its key. Nothing on this machine changes until the snapshot epoch is on Base. There is no rekey tool: this is CLI only. | Flag | Default | Meaning | |---|---|---| | `--owner` | the connected owner | the owner wallet address | | `--signature` | none | `-` reads the 65-byte hex signature from stdin | | `--signature-file PATH` | none | the signature from a file, read then unlinked | | `--smart-account` | off | carry over the vault passphrase wrap (prompted); combine with a signature to carry both | | `--passphrase-stdin` | off | read one passphrase from stdin: the salt when a signature is given, else the vault passphrase | | `--passphrase-prompt` | off | EOA mode: the passphrase that salts the wallet key | | `--add-passphrase` | off | also carry over the extra passphrase wrap, prompted | | `--drop-missing` | off | rotate even though a key that opens the vault today was not supplied | | `--over-skipped` | off | rotate even though the last pull stopped at an epoch this machine could not apply: the new snapshot chains on the chain head | | `--confirmations` | `2` | blocks to wait for after the snapshot transaction | It refuses unless this machine is connected, unless at least one supplied key opens the current vault (read from the head epoch's header, or the newest readable one within 32 before it), and, without `--drop-missing`, if it would drop a key. Like `push`, it refuses when the last pull stopped at an epoch this machine could not apply, unless `--over-skipped`. The refusal names each key it would drop by kind and tag. A dropped key keeps opening the epochs sealed before the rotation and opens nothing after it. The old recovery code is in the same position, so copy the new one off the machine. ```sh cast wallet sign --data --from-file kint-canonical.json --ledger | kint rekey --signature - cast wallet sign --data --from-file kint-canonical.json --ledger | kint rekey --signature - --smart-account ``` ### kint recovery-code Writes `~/.kint/RECOVERY-.txt` (mode 0600) from the cached data key. It is the fix when `push` refuses because the machine that created the vault lost its recovery file. It refuses with `no data key cached on this machine: kint connect first`, and it refuses when the cached key is not the one the head epoch was sealed with (a rotation happened elsewhere; connect again first). No flags. ## Anchor and restore ### kint push Diffs the store against what this machine last anchored, then anchors the changes on Base: one transaction per epoch from the session key. A change set too large for the 96 KB bucket is split across epochs; a single row that compresses to more than 90 KB on its own is refused by name. | Flag | Default | Meaning | |---|---|---| | `--owner` | the connected owner | the owner wallet address | | `--dry-run` | off | read the chain head and report what would be anchored; send nothing | | `--confirmations` | `2` | blocks to wait for after each transaction | It prints `nothing to push: the store matches the last anchored epoch` when that is true, otherwise one line per epoch with its seq, tx, block, bucket, rows, deletions and cost in ETH. It refuses when another machine pushed first (the message says to run `kint pull`, or `kint pull --discard-local` when this machine's unanchored changes may go), when the session key is not authorized, when the last pull left a gap (for an epoch that will never open it names `kint compact --over-skipped`), and on the machine that created the vault when its recovery file is gone. `kint push` is never held: it is how the human anchors a value kint-server held back (`HELD` in [MCP tools](/docs/tools)), so check the store before you run it. ```sh kint push --dry-run kint push ``` ### kint compact Anchors one snapshot epoch carrying the whole state, even when nothing changed. A cold start stops at the newest snapshot. It takes the same flags as `push` (`--owner`, `--dry-run`, `--confirmations` default `2`), plus one. Snapshots are single-epoch, so it refuses when the whole state compresses to more than 92,160 bytes; keep using `kint push` then. | Flag | Default | Meaning | |---|---|---| | `--over-skipped` | off | anchor the snapshot on the chain head even though the last pull stopped at an epoch this machine could not apply | `--over-skipped` is the owner's way past an epoch that will never open, such as one a leaked session key appended. It is honoured only on a machine whose last pull reported such an epoch. It prints `push: OVERRIDE, anchoring a snapshot on top of chain head seq N (...)` and chains the snapshot on the head, so every later pull stops at it, with this machine's store as the whole state. Revoke the key that wrote the epoch first. ```sh kint compact kint compact --over-skipped ``` ### kint pull Restores or refreshes the store from Base. It checks freshness first: the head may not be older than the watermark this machine already saw, and on a cold start two different RPC operators must agree on the head (`KINT_ALLOW_SINGLE_RPC=1` accepts one). It then walks the epochs back from the head, stopping at the newest snapshot, verifies each against the chain, and replays the rows through Sibyl's own write methods. It needs a cached data key, so connect first. | Flag | Default | Meaning | |---|---|---| | `--owner` | the connected owner | the owner wallet address | | `--discard-local` | off | move the local store aside and restore from the chain | | `--force-scan` | off | accepted and passed through, but the 0.3.0 pull never reads it | | `--full` | off | walk past snapshot epochs to the first epoch (the whole history, not just the current state); on a store already up to date, cache every epoch not yet decrypted, for `history` | It refuses over a fork: rows this machine never anchored while the chain moved, or a store holding rows for the tenant that this machine has no record of. The fork message says to save what you need out of the store, then run `--discard-local`, which moves `memory.db` and its `-wal` and `-shm` files to `.kint-backup-` before restoring (the whole file, other tenants' rows included). Epochs sealed under a retired data key are printed as `CLOSED`. An epoch it could not apply is printed as `SKIPPED`: when it could not open that epoch and a later snapshot in the same walk opens, the line ends `(snapshot epoch S carries the whole state, the pull carried on)` and the pull completes; otherwise the pull stopped there, and push and verify refuse until it applies. ```sh kint pull kint pull --full kint pull --discard-local ``` ### kint join The read half of setting up a machine in one command: this machine's session key, connect, restore and harness registration. It sends no transaction and spends nothing, and every step reads the state first, so running it again resumes rather than starting over. [Restore on a new machine](/docs/new-machine#one-command-kint-join) walks through what it prints. | Flag | Default | Meaning | |---|---|---| | `--owner` | required | the wallet that owns the memory | | `--base-account` | off | the owner is a Base Account: the vault passphrase is the key, prompted for | | `--passphrase-stdin` | off | read the passphrase from stdin instead of prompting | | `--passphrase-prompt` | off | EOA mode: prompt for the passphrase that salts the wallet key | | `--signature` | none | EOA owners: `-` reads the 65-byte hex derive signature from stdin | | `--signature-file` | none | EOA owners: a file holding the signature, read then unlinked | | `--recovery-code-stdin` | off | either owner: the recovery code on stdin | | `--new-vault` | off | allowed to start a vault when the chain holds no epochs for this owner and tenant | | `--discard-local` | off | move a local store aside and restore from the chain | | `--full` | off | walk past snapshot epochs for the older versions | | `--setup` | `all` | which harnesses to register: `all`, `claude`, `codex`, `hermes` or `openclaw` | | `--no-setup` | off | register no harness | | `--env` | none | `KEY=VALUE` for the server process, repeatable; `KINT_TENANT` is always set to the tenant it joined | | `--tenant`, `--db` | the global values | the same as the global options, accepted after the command | Exactly one of `--base-account`, `--signature -`, `--signature-file PATH` and `--recovery-code-stdin` is required, and `--passphrase-stdin` cannot share stdin with `--signature -` (use `--passphrase-prompt` or `--signature-file`). With no epochs under that owner and tenant it stops at the vault step unless `--new-vault` is passed. A failure prints `join stopped at : `; a failed join creates no store, and a session key it created before the failure is kept for the retry. Its last line says `writes: on`, or `writes: off until the session key is funded and authorized by the owner; reads work now` followed by the next step. ```sh kint join --owner 0xYourBaseAccount --base-account cast wallet sign --data --from-file kint-canonical.json --ledger | kint join --owner 0xYOU --signature - kint join --owner 0xYOU --recovery-code-stdin --setup claude ``` ## Read and decide ### kint verify Runs the same search Sibyl's `memory_search` runs (`multi_record_search`, every precision gate included), re-reads the exact stored text of every hit, hashes it and proves it against the `rows_root` this machine last saw anchored. It also reads the chain head and refuses with `chain moved to seq N at block B, pull first` when another machine anchored past this machine's mirror. When the head cannot be read it prints `kint: chain head unavailable (...); verifying against the local mirror only` to stderr and checks against the mirror; `KINT_OFFLINE=1` skips the read. It never sends a transaction. On a refusal it writes a Sibyl entity in category `kint_refusal`, unless `--no-write`; a running kint-server then holds its pushes while the row keeps the refused value. | Argument or flag | Default | Meaning | |---|---|---| | `query` | required | the search query | | `--limit` | `5` | how many hits to check | | `--json` | off | print the full result as JSON | | `--no-write` | off | do not write a refusal entity | Each hit gets one status: `verified`, `drifted`, `unanchored` or `missing`. The last line is `DECISION: PROCEED: ...` or `DECISION: REFUSE: ...`. The exit status is 0 on proceed and 1 on refuse, except with `--json`, which always exits 0; read the `decision` field instead. ```text $ kint verify "release rule" query: release rule sibyl verdict: ok hits: 3 verified entity rules/release-gate: stored text matches the leaf anchored on Base DECISION: PROCEED: entity rules/release-gate verified against rows_root <16 hex> anchored at epoch 2, block 51081880 ``` ### kint history Every anchored version of an entity, state or reference row, oldest first. Each version carries a block-height upper bound: it existed no later than that block. It reads the local epoch cache, so it lists the versions this machine has decrypted; `kint pull --full` fills in the older ones. A snapshot that re-anchored an unchanged row does not add a version. | Argument or flag | Default | Meaning | |---|---|---| | `entity`, `state` or `reference` | required | the tier (the journal tier is not accepted) | | `key` | required | the entity name, state key or reference key | | `--category` | none | the entity's category; entities are keyed by it, so give it for them | | `--block` | none | print, as JSON, the version live at that block (`null` if none) | ```text $ kint history entity release-gate --category rules epoch 1 block <= 51081867 leaf f406bb0919d23360: {"rule":"never ship on a Friday; ...", ...} epoch 2 block <= 51081880 leaf 4ac605528bbb00e7: {"rule":"never ship on a Friday; ...", ...} ``` A deleted row prints `epoch N block <= B: DELETED`. ## Inspect ### kint status The tenant, the space and the store path; the owner and whether the data key is cached; what this machine last anchored (seq, block, rows); how many rows changed or were deleted since. When the machine is connected, it also prints the chain head with `in step` or `NOT in step: pull or push`, the contract address, and the session key's balance and authorization. It ends with the cap accounting: Sibyl's bytes, kint's bytes (mirror, epoch cache, keys) and the total against the 5 MiB free-tier cap Sibyl enforces. No flags. ### kint doctor Checks every moving part and prints one `[ok ]`, `[WARN]` or `[FAIL]` line per check: the Sibyl packages, the kint home (mode 0700 expected), the Sibyl store, whether the kint home sits inside `~/.sibyl-memory`, the enrolment, the cached data key, the recovery file, the primary RPC (chain id 8453), the secondary RPC (a warning when it is the same endpoint), the contract's code, the session key (ok when authorized with more than 0.00002 ETH), the head against the mirror, unanchored changes, and the Python version. RPC URLs are printed as scheme and host only. It exits 1 when any check fails. | Flag | Default | Meaning | |---|---|---| | `--owner` | the connected owner | the owner to check the session key and head against | ### kint export Prints the four tiers the Sibyl SDK writes (entity, state, reference, journal) for the tenant, one JSON object per line, in rowid order, read-only. It is a debug tool and the output is plaintext memory. No flags. ## Harnesses ### kint setup Points a harness at `kint-server` instead of `sibyl-memory-mcp`. It looks for `kint-server` next to the `kint` binary, then on `PATH`, and fails with `kint-server not found next to kint or on PATH`. A harness whose CLI is missing is skipped. | Argument or flag | Default | Meaning | |---|---|---| | `all`, `claude`, `codex`, `hermes` or `openclaw` | required | `all` runs the four in that order | | `--env KEY=VALUE` | none | set in the server process; repeatable, for example `KINT_TENANT=kint-demo` | | Target | What it does | |---|---| | `claude` | removes any user-scope `kint`, then runs `claude mcp add --scope user kint -e PYTHONPATH=x [-e KEY=VALUE ...] -- /usr/bin/env -u PYTHONPATH /abs/path/kint-server`, and reminds you to remove Sibyl's own `sibyl-memory` registration | | `codex` | appends `[mcp_servers.kint]` (the absolute `kint-server` path, `args = []`, env `PYTHONPATH = ""` plus your `--env` pairs) to `~/.codex/config.toml`, after copying it to `config.toml.bak-`; if a `kint` block exists it prints `codex: already configured` and changes nothing | | `hermes` | runs `hermes mcp add kint --command /usr/bin/env --args PYTHONPATH= [KEY=VALUE ...] /abs/path/kint-server` | | `openclaw` | runs `openclaw mcp set kint` with `{"command":"/usr/bin/env","args":["PYTHONPATH=", ..., "/abs/path/kint-server"]}` | Global `--tenant` and `--db` do not reach the server. Pin them with `--env KINT_TENANT=...` and `--env SIBYL_MEMORY_DB=...`. Registration is user scope only for Claude Code. For project scope, run it by hand: `claude mcp add --scope project kint -e PYTHONPATH=x -e KINT_TENANT=... -- /usr/bin/env -u PYTHONPATH /abs/path/kint-server` writes `.mcp.json`, and Claude Code asks you to approve it until the folder is trusted. [Harnesses](/docs/harnesses) covers each one. ```sh kint setup all --env KINT_TENANT=kint-demo ``` ## kint-server The binary your harness starts. It takes no flags or arguments and speaks MCP on stdio: Sibyl's `build_server()` with their eight tools unmodified, plus kint's six. It reads the tenant from `KINT_TENANT` (else Sibyl's credentials, else Sibyl's default) and the store from `SIBYL_MEMORY_DB` (else `~/.sibyl-memory/memory.db`). - On start, when the machine is connected and the data key is cached, it pulls. It waits for that pull up to `KINT_BOOTSTRAP_SECONDS` (default 20), then serves while the pull finishes. A fork or a freshness refusal is logged and it serves the local store. - While serving, a watcher checks the store every `KINT_POLL_SECONDS` (default 15, at least 1). It pushes after `KINT_QUIET_SECONDS` of quiet (default 300), or sooner when the change set passes `KINT_SIZE_TRIGGER_BYTES` (default 32768, estimated as 400 bytes per changed row and checked once a minute). It starts with the rows an earlier session left unanchored. `KINT_NO_WATCHER=1` turns the watcher off. - Every push it makes is held (`HELD`, nothing anchored) when a row the chain vouches for changed behind Sibyl's tools while it ran, or when `memory_verify` refused exactly the value a row holds. `kint push` is not. - Every successful pull and push renews the cached data key's deadline. - On exit, `SIGTERM` or `SIGHUP` it pushes whatever is unanchored, once and best effort, after the tool call in flight has finished. - It logs to stderr and `~/.kint/kint.log`, never stdout, because the MCP transport owns stdout. ## Exit status | Status | Meaning | |---|---| | 0 | done; `verify` decided proceed | | 1 | `verify` refused; `doctor` found a failing check; or an error kint does not catch (an RPC failure, the head lock held by another kint process for 30 seconds), shown as a Python traceback | | 2 | a line starting `kint:` (a missing flag, a refusal from `connect`, `authorize`, `rekey` or `recovery-code`), or a usage error from argparse | | 3 | `push`, `compact`, `rekey`: the data key on this machine expired or is missing, or the session key cannot be loaded (`pull` too, for the session key) | | 4 | `push`, `compact`, `rekey`: the chain moved, another machine pushed; `pull`: a fork | | 5 | `push`, `compact`, `rekey`, `pull`: any other push or pull refusal (a pull with no cached data key lands here) | | 6 | `pull`: freshness refusal, a stale RPC or two RPCs that disagree on a cold start | `join` exits 2 when it stops before the restore, and with the `pull` codes (3, 4, 5 or 6) when the restore refuses. Read [Configuration](/docs/configuration) next. Source: [`src/kint/cli.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/cli.py), [`src/kint/connect.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/connect.py), [`src/kint/keys.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/keys.py), [`src/kint/push.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/push.py), [`src/kint/pull.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/pull.py), [`src/kint/page.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/page.py), [`src/kint/server.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/server.py), [`src/kint/join.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/join.py), [`src/kint/setup.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/setup.py). --- ## Configuration > Source: https://github.com/s0nderlabs/kint/blob/main/src/kint/paths.py kint reads no config file. What it can be told arrives as an environment variable or one of two global flags, and what it keeps lives in one directory. ## How values are read The nineteen variables below are every `os.environ` read in `src/kint`. A few rules hold across all of them: - Switches take the exact value `1`. Any other value, `true` and `yes` included, leaves the switch off. - An empty value counts as unset for every variable except `KINT_HOME` and `SIBYL_MEMORY_DB`. Those two take the empty string as a path, which resolves to the current directory, so unset them rather than blank them. - A CLI flag beats the environment: `--tenant` beats `KINT_TENANT`, `--db` beats `SIBYL_MEMORY_DB`. Both go before the subcommand: `kint --tenant kint-demo status`. - `kint-server` takes no flags. It resolves the tenant and the store path once, at startup, so a change to its environment takes a restart of the server. - The terminal and the server must agree. If a harness sets `KINT_TENANT`, `SIBYL_MEMORY_DB` or `KINT_HOME` for `kint-server`, set the same values in the shell where you run `kint`, or the two look at different spaces. ## Every variable | Variable | Default | Effect | Read in | |---|---|---|---| | `KINT_HOME` | `~/.kint` | The directory for every file kint keeps. Created with mode 0700 and reset to 0700 on each use. | `paths.py` | | `KINT_TENANT` | unset | The Sibyl tenant id. It picks the space, so it decides which chain of epochs kint reads and writes. See [Tenant resolution](#tenant-resolution). | `store.py` | | `SIBYL_MEMORY_DB` | `~/.sibyl-memory/memory.db` | The Sibyl store kint wraps and `kint-server` serves. Sibyl reads the same variable. | `paths.py` | | `KINT_RPC_URL` | unset | The primary Base RPC, for reads and sends. Unset: the Alchemy key in the macOS Keychain, else `https://mainnet.base.org`. | `chain.py` | | `KINT_RPC_URL_2` | derived | The independent second opinion on a cold start. Unset: `https://base-rpc.publicnode.com` when the primary is `https://mainnet.base.org` (with or without a trailing slash), else `https://mainnet.base.org`. | `chain.py` | | `KINT_ALLOW_SINGLE_RPC` | off | `1` lets a cold-start pull go ahead on one RPC when the second resolves to the same endpoint or cannot be reached. | `pull.py` | | `KINT_CONTRACT` | `0xa22E03f7a4145Bf4909a83595C90a38E14d79600` | The EpochAnchor address. Checksummed before use. | `chain.py` | | `KINT_RPC_TIMEOUT` | `10` | Seconds each JSON-RPC call waits before it gives up. A call that fails on a connection error or a timeout is retried twice with a short backoff; a value that does not parse means 10. | `chain.py` | | `KINT_NO_KEYCHAIN` | off | `1` skips the macOS Keychain for both the local passphrase and the Alchemy key. | `chain.py`, `keys.py` | | `KINT_SESSION_PASSPHRASE` | unset | The machine-local secret that encrypts `session.key` and derives the key for the data key cache. Set, it wins over the Keychain and `local.secret`. | `keys.py` | | `KINT_KEY_TTL` | `24h` | How long the unwrapped data key stays cached. Plain seconds, or a number with `s`, `m`, `h` or `d`; `session` keeps it in process memory only. | `keys.py` | | `KINT_OFFLINE` | off | `1` stops `connect`, `rekey` and `recovery-code` from reading the head epoch's header off the chain, and `kint verify` and `memory_verify` from reading the chain head. | `connect.py`, `cli.py`, `server.py` | | `KINT_OWNER_KEY` | unset | The owner's private key. Read only by `kint authorize direct` and `kint authorize burn-nonce`, never from argv. | `cli.py` | | `KINT_QUIET_SECONDS` | `300` | Watcher: push once the store has been quiet this long. | `server.py` | | `KINT_POLL_SECONDS` | `15` | Watcher: how often it looks at the store. Floor 1. | `server.py` | | `KINT_SIZE_TRIGGER_BYTES` | `32768` | Watcher: push early when the estimated diff passes this size. | `server.py` | | `KINT_NO_WATCHER` | off | `1` starts `kint-server` without the watcher thread. | `server.py` | | `KINT_BOOTSTRAP_SECONDS` | `20` | How long `kint-server` waits for its startup pull before it serves; the pull then finishes behind the server. | `server.py` | | `PYTHONPATH` | inherited | Read only by `kint doctor`, which flags it. See [PYTHONPATH](#pythonpath). | `connect.py` | `KINT_KEY_TTL` falls back to 24 hours when it cannot parse the value. The expiry is written into the cache when you connect, and `kint-server` writes it again after every successful pull and push, with a fresh deadline from the `KINT_KEY_TTL` in its own environment, so set the same value in the harness registration as in the terminal where you connect. With `session` nothing touches the disk: the key lives only in the process that connected, so a `kint connect` run in a terminal with `KINT_KEY_TTL=session` leaves nothing for `kint-server` to use. A number for the watcher or for `KINT_BOOTSTRAP_SECONDS` that does not parse is replaced by its default, with a log line such as `KINT_QUIET_SECONDS='5m' is not a number; using 300.0`. Two more are read by Sibyl's own code, which kint calls to load credentials and size the cap: | Variable | Default | Effect | |---|---|---| | `SIBYL_CREDENTIALS` | `~/.sibyl-memory/credentials.json` | Sibyl's credentials file. kint takes `tenant_id`, `account_id` and `tier` (default `free`) from it through Sibyl's loader, which treats a symlink or a file that does not parse as absent. | | `HERMES_HOME` | `~/.hermes` | Sibyl's cap walk also sizes `$HERMES_HOME/sibyl/memory.db` and every `profiles/

/memory.db` under it. | ## Tenant resolution The `kint` CLI takes the first of these that is set: 1. `--tenant`, the global flag. 2. `KINT_TENANT`. 3. `tenant_id` in Sibyl's `credentials.json`. 4. `account_id` in the same file. 5. Sibyl's default tenant, `00000000-0000-0000-0000-000000000001`. `kint-server` has no flag, so it starts at step 2. The tenant goes to Sibyl's client as well, so their eight tools read and write that tenant's rows, and it names the space: `keccak256("kint-space-v1" || tenant_id)`. The first 16 hex characters of the space name the files in `KINT_HOME`, and `kint status` prints them (`space bd2a3b5b8f3fb4c8` for `kint-demo`). The vault key is derived per space, so the same wallet under another tenant opens a different vault. When the chain already holds epochs for the space a connect computed, the wrong tenant fails with `Wrong wallet, wrong passphrase, or wrong tenant.` When it holds none, the connect starts a fresh, empty vault for that space and writes a new recovery code, so check the tenant before you connect. ## The store The store is `SIBYL_MEMORY_DB`, else `~/.sibyl-memory/memory.db`. `--db` overrides it for `push`, `compact`, `rekey`, `pull`, `verify`, `status`, `doctor` and `export`. kint creates the parent directory with mode 0700 when it is missing and builds its client the way Sibyl's server does, plus the volunteered cap. Around the file: - Sibyl keeps `tier_cache.json` in the same directory. - `kint pull --discard-local` moves `memory.db`, `memory.db-wal` and `memory.db-shm` aside in that directory, each renamed with a `.kint-backup-` suffix, then restores from the chain. - The watcher notices writes through the modification times of `memory.db` and `memory.db-wal`. For Hermes' SDK door, point kint at the file Hermes' Sibyl provider uses: `SIBYL_MEMORY_DB=$HERMES_HOME/sibyl/memory.db`. See [Harnesses](/docs/harnesses). `KINT_HOME` sits outside every path Sibyl's cap walk sizes, and kint volunteers its footprint back: the cap gate it hands Sibyl counts Sibyl's aggregate plus every file under `KINT_HOME`, against the same 5 MiB free-tier cap (5,242,880 bytes). `kint doctor` fails the check `kint home placement` when `KINT_HOME` is inside `~/.sibyl-memory`, a path Sibyl's cap walk already sizes. ## The KINT_HOME tree `` is the first 16 hex characters of the space id. One machine can hold several spaces side by side; the session key is shared by all of them. ```text ~/.kint/ ├── session.key this machine's session key, a V3 scrypt keystore ├── session.key.old the previous one, after kint session-key rotate ├── local.secret the local passphrase, only when neither the Keychain nor KINT_SESSION_PASSPHRASE supplies it ├── kint.log kint-server's log ├── enrol-.json owner, tenant, space, account kind, key tags, contract ├── wraps-.json the wraps of the data key ├── vault-.aes the unwrapped data key, encrypted under a key derived from the local passphrase, with its expiry ├── RECOVERY-.txt the recovery code, on the machine that created the vault ├── mirror-.json the rows and leaves as last anchored or pulled ├── watermark-.json the highest head this machine has seen; it never decreases ├── head-.lock one pusher or puller per space per machine, 30 second wait └── epochs/ └── / ├── 00000001.bin the ciphertext, as it sat in the push calldata ├── 00000001.json the decrypted epoch, when this machine could open it └── 00000001.meta.json the epoch's metadata ``` Every file except the log is written with mode 0600, through a temporary file and a rename. `kint.log` is created with your umask. `push` refuses on the machine that created the vault while its `RECOVERY-.txt` is missing or empty; `kint recovery-code` writes it again. The mirror and the `.json` epoch files hold plaintext rows, as Sibyl's own `memory.db` does, so the directory mode is what protects them. See [Keys and custody](/docs/keys) for what each key can and cannot do. ## Keychain, and machines without one On macOS kint uses two generic-password items through the `security` tool: | Service | Account | Written by kint | Holds | |---|---|---|---| | `dev.kint-session-key` | `passphrase` | yes, on first use, with 32 random bytes as URL-safe text | the local passphrase that seals `session.key` and `vault-.aes` | | `dev.api.alchemy` | `api-key` | never | an Alchemy API key; when present the primary RPC becomes `https://base-mainnet.g.alchemy.com/v2/` | The local passphrase comes from the first source that answers: 1. `KINT_SESSION_PASSPHRASE`. 2. The Keychain item `dev.kint-session-key`, on macOS unless `KINT_NO_KEYCHAIN=1`. Created if absent; if the write fails, kint falls through. 3. `$KINT_HOME/local.secret`, read if present, created with mode 0600 if not. When kint only needs to load the session key and finds none of the three, it stops with `no local passphrase: set KINT_SESSION_PASSPHRASE`. On Linux, or anywhere that is not macOS, there is no Keychain: set `KINT_SESSION_PASSPHRASE`, or kint writes `local.secret` beside `session.key` and the directory mode is the only thing between them. The primary RPC is `KINT_RPC_URL`, else the public Base endpoint. **Keep the source stable.** `session.key` and the data key cache are sealed under whatever the local passphrase was when they were written. Switch sources later, for example set `KINT_SESSION_PASSPHRASE` on a Mac that used the Keychain, and the session key no longer decrypts and the cached data key reads as absent. ## Base: chain, RPCs and contract | Setting | Value | |---|---| | Chain | Base mainnet, chain id 8453 | | Contract | [`0xa22E03f7a4145Bf4909a83595C90a38E14d79600`](https://basescan.org/address/0xa22E03f7a4145Bf4909a83595C90a38E14d79600), deployed at block 51,081,696 in tx [`0xf2e03cd1c4e7ca005ee602a6c61ed50861d2238acee217b2c19f8670ba2cc9f6`](https://basescan.org/tx/0xf2e03cd1c4e7ca005ee602a6c61ed50861d2238acee217b2c19f8670ba2cc9f6) | | Primary RPC | `KINT_RPC_URL`, else the Keychain Alchemy key, else `https://mainnet.base.org` | | Second RPC | `KINT_RPC_URL_2`, else `https://base-rpc.publicnode.com` when the primary is `https://mainnet.base.org`, else `https://mainnet.base.org` | | Request timeout | `KINT_RPC_TIMEOUT`, 10 seconds per RPC call by default; a call that fails on a connection error or a timeout is retried twice | | Receipt wait | 180 seconds; `push`, `compact` and `rekey` wait for 2 confirmations unless `--confirmations` says otherwise, and `kint-server` uses 2 | The second RPC matters on a cold start only: a machine with no watermark asks both endpoints for the head and refuses if they disagree. Two URLs count as the same endpoint when host, port and path match (a trailing slash is ignored). When they do, the pull stops with `cold start needs a second, independent RPC and KINT_RPC_URL_2 resolves to the same endpoint as the primary`, unless `KINT_ALLOW_SINGLE_RPC=1`. Once a watermark exists, an RPC that serves an older head is refused as a `stale or lying RPC`. See [Freshness](/docs/new-machine#freshness-two-endpoints-on-a-cold-start). kint redacts RPC URLs. Each URL keeps its scheme, host and port and loses its path, query and userinfo (`/`), and the path and query of the endpoints kint is configured with are scrubbed wherever else they appear. Every error on its way to a tool result, a kint log line, or a `kint status`, `kint verify` or `kint doctor` line passes through it. kint-server logs an unexpected push, pull or verify failure as its error type and the redacted message, never a raw traceback; the CLI still prints a Python traceback, on your own terminal, for an error it does not catch. ## The watcher The watcher is a thread inside `kint-server`; the CLI has none. It pushes unanchored changes while an agent works, so nothing waits for the session to end. - Every `KINT_POLL_SECONDS` (15, floor 1) it compares the modification times of `memory.db` and `memory.db-wal`. A change marks the store dirty and restarts the quiet clock. - It does nothing until this machine is connected and the data key is cached. Each successful push renews the cached key's deadline; if it expires anyway, pushes stop until you connect again, and the changes stay in the store. - At start it counts the rows an earlier session left unanchored (a harness that killed the last server before its exit push landed) and, when there are any, starts dirty, so the quiet period pushes them. - While dirty, it counts the changed and deleted rows on its first check and then at most once a minute. None: the dirty mark clears. More than `KINT_SIZE_TRIGGER_BYTES` at an estimated 400 bytes a row: it pushes at once. At the default 32768 that is 82 rows or more. - Otherwise it pushes when the store has been quiet for `KINT_QUIET_SECONDS` (300). - Every push it makes checks the store for drift first. A `HELD` push anchors nothing, and the watcher waits for the next store change instead of retrying; `memory_status` shows the reason under `held`. - `KINT_NO_WATCHER=1` turns it off. `memory_push` and the exit push still work: on exit, SIGTERM or SIGHUP, `kint-server` pushes whatever is unanchored, once and best effort, after the tool call in flight has finished. Every push is one Base transaction per epoch from the session key, so a shorter quiet period means more epochs. ## Logs `kint-server` logs to `$KINT_HOME/kint.log` and to stderr, never to stdout: the MCP stdio transport owns stdout. The format is `%(asctime)s %(levelname)s %(message)s` at INFO. The file is appended to and never rotated, and it counts against the cap like every other file under `KINT_HOME`. If kint cannot open it, stderr is all there is. On startup the server runs its pull on a thread of its own and waits for it up to `KINT_BOOTSTRAP_SECONDS`, then logs the line that says what it is serving. The bootstrap line before it says why there was no pull, or gives the pull's summary; a pull that outlives the budget logs `bootstrap: still running` instead, and its summary follows later: ```text bootstrap: not connected (no enrolment); serving anyway, memory_connect explains bootstrap: data key not cached; serving without pull, memory_connect explains bootstrap: still running; serving now, the pull finishes in the background kint-server: serving Sibyl's tools plus kint's on stdio (tenant kint-demo, db /Users/you/.sibyl-memory/memory.db) ``` The watcher adds `watcher: store changed`, `watcher: pushing (quiet trigger)` or `(size trigger)`, and the push result, and at start `watcher: N changed and M deleted row(s) were left unanchored by an earlier session; they are pushed at the next quiet period`. A held push logs `push HELD (): refusing to anchor: ...` once per distinct reason. A `memory_verify` that cannot read the chain head logs `chain head unavailable (...); verifying against the local mirror only`. The `kint` CLI does not use this log: its commands print to the terminal, with errors on stderr as lines that start with `kint:`. ## Handing the environment to kint-server A harness starts `kint-server` itself, so the variables go into its registration. `kint setup --env KEY=VALUE` (repeatable, split at the first `=`) writes them for you; see [kint setup](/docs/cli#kint-setup). Per target: - `claude`: removes any user-scope `kint`, then runs `claude mcp add --scope user kint -e PYTHONPATH=x -e KEY=VALUE -- /usr/bin/env -u PYTHONPATH /abs/path/kint-server`. - `codex`: appends a `[mcp_servers.kint]` table to `~/.codex/config.toml` after copying the file to `config.toml.bak-`. If the table is already there it prints `codex: already configured` and changes nothing, so edit the file by hand from then on. - `hermes` and `openclaw`: register `/usr/bin/env PYTHONPATH= KEY=VALUE /abs/path/kint-server`, so `env` sets the variables. `/abs/path/kint-server` is the binary next to `kint`, else the one on `PATH`. A Codex block with the tenant pinned, a shorter quiet period, and room for the startup pull and for push and pull calls, which wait for Base confirmations (`kint setup` does not write the two timeouts): ```toml [mcp_servers.kint] command = "/abs/path/kint-server" args = [] tool_timeout_sec = 180 startup_timeout_sec = 90 [mcp_servers.kint.env] PYTHONPATH = "" KINT_TENANT = "kint-demo" KINT_QUIET_SECONDS = "120" ``` A harness that reads the common `mcpServers` JSON shape takes the same settings. `env -u PYTHONPATH` drops whatever `PYTHONPATH` the harness passes and keeps the rest: ```json { "mcpServers": { "kint": { "command": "/usr/bin/env", "args": ["-u", "PYTHONPATH", "/abs/path/kint-server"], "env": { "KINT_TENANT": "kint-demo", "SIBYL_MEMORY_DB": "/Users/you/.sibyl-memory/memory.db", "KINT_KEY_TTL": "30d" } } } } ``` Write absolute paths in both. A harness does not run a shell, so nothing expands `~` or `$HOME` for you. A `KINT_SESSION_PASSPHRASE` in a registration sits in a plaintext file, which protects it no better than `local.secret` does. > **Warning.** Never put `KINT_OWNER_KEY` in a harness registration or a shell profile. `kint-server` never reads it; only `kint authorize direct` and `kint authorize burn-nonce` do, so export it in the one terminal that runs them and close that terminal after. ## PYTHONPATH A `PYTHONPATH` set in your shell can put other packages ahead of kint's dependencies. Run both binaries with it unset (`env -u PYTHONPATH kint status`). `kint setup` keeps it out of every harness it registers (unset for Claude Code, empty for the others), and `kint doctor` prints `(PYTHONPATH is set: run with env -u PYTHONPATH)` beside the Python version while it is set. ## Check it `kint doctor` reads the same settings and reports each one as `ok`, `WARN` or `FAIL`. The rows that come from this page: | Row | What it checks | |---|---| | `kint home` | the path and its mode; anything but 0700 warns | | `kint home placement` | fails when `KINT_HOME` is inside `~/.sibyl-memory` | | `sibyl store` | the store path and whether the file exists (absent on a fresh machine warns) | | `rpc primary` | the redacted URL, the chain id (anything but 8453 fails) and the block | | `rpc secondary` | the redacted second URL; the same endpoint as the primary warns `(SAME as primary: no second opinion on a cold start)` | | `contract` | whether the EpochAnchor address has code | | `python` | the Python version, and the `PYTHONPATH` note | Read [EpochAnchor contract](/docs/contract) next. Source: [`src/kint/paths.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/paths.py), [`src/kint/chain.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/chain.py), [`src/kint/keys.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/keys.py), [`src/kint/server.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/server.py), [`src/kint/store.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/store.py), [`src/kint/log.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/log.py), [`src/kint/connect.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/connect.py), [`src/kint/pull.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/pull.py), [`src/kint/cli.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/cli.py). --- ## EpochAnchor contract > Source: https://github.com/s0nderlabs/kint/blob/main/contracts/src/EpochAnchor.sol EpochAnchor is the one contract under kint. It keeps the head of each owner's chain of epochs per space; the ciphertext rides in the calldata of `push` and never touches contract storage. ## The deployment | field | value | |---|---| | address | [`0xa22E03f7a4145Bf4909a83595C90a38E14d79600`](https://basescan.org/address/0xa22E03f7a4145Bf4909a83595C90a38E14d79600) | | chain | Base mainnet, chain id `8453` | | deploy tx | [`0xf2e03cd1c4e7ca005ee602a6c61ed50861d2238acee217b2c19f8670ba2cc9f6`](https://basescan.org/tx/0xf2e03cd1c4e7ca005ee602a6c61ed50861d2238acee217b2c19f8670ba2cc9f6) | | deploy block | 51081696 (Sep 9 2026) | | deployer | `0xC635e6Eb223aE14143E23cEEa9440bC773dc87Ec` | | deploy receipt | gasUsed 885,843; effective gas price 7,833,683 wei; L1 fee 1,526,707,829 wei | | compiler | solc 0.8.28, evm cancun, optimizer 200 runs, `bytecode_hash none` | | source | verified on Basescan | The contract has no owner, no admin, no pause, no proxy, no upgrade path, no constructor arguments, no `receive`, no `fallback`, and it never handles ETH. Its only dependency is forge-std, and only for tests; the ECDSA recovery is inline. A change to the contract means a redeploy, which is a new address and a new chain of epochs that starts empty. kint reads the address from `KINT_CONTRACT` and falls back to the address above (`DEFAULT_CONTRACT` in `src/kint/chain.py`). The browser core exports the same default as `DEFAULT_CONTRACT` in `js/src/chain.ts`, and its read functions take the contract address as a parameter. The ABI ships in three identical copies: `contracts/abi/EpochAnchor.json`, the `abi` field of `contracts/deployments/base-mainnet.json`, and `src/kint/EpochAnchor.abi.json` inside the Python package. ## State Four public mappings, each with a generated getter: | mapping | key | value | |---|---|---| | `head` | `(address owner, bytes32 space)` | `Head { bytes32 digest; uint64 seq; uint64 blockNumber; }` | | `sessionKeyExpiry` | `(address owner, address key)` | `uint64 expiry`, a unix timestamp; `0` means revoked or never set | | `startSeq` | `(address owner, bytes32 space)` | `uint64 seq`, a reader hint; it does not affect `push` | | `authNonce` | `address owner` | `uint256 nonce`, the replay counter for `setSessionKeyBySig` | ### The head record `head[owner][space]` is the newest epoch for that owner and space. `digest` is `keccak256` of its ciphertext, `seq` is its 1-based sequence number, and `blockNumber` is the block it landed in. A space nobody has written to returns all zeros, so the first `push` passes `prev = bytes32(0)` and gets `seq` 1. A space is a `bytes32` that kint derives from the tenant id: `keccak256("kint-space-v1" || tenant_id)` over the UTF-8 bytes, with no separator (`space_id` in `src/kint/crypto.py`). Two tenants under the same owner have independent heads, and so do two owners using the same tenant id. ## Functions | function | who may call | what it does | |---|---|---| | `push(address owner, bytes32 space, bytes32 prev, bytes ct) returns (bytes32 digest, uint64 seq)` | the owner, or a session key whose expiry is still ahead | Appends one epoch. Reverts unless `canWrite(owner, msg.sender)`, then unless `ct` is non-empty, then unless `prev` equals the current head digest. Stores `keccak256(ct)`, `seq + 1` and `block.number`; emits `Epoch`. | | `setSessionKey(address key, uint64 expiry)` | anyone, for their own address | Sets `sessionKeyExpiry[msg.sender][key] = expiry` (`0` revokes). Increments `authNonce[msg.sender]`. Emits `SessionKeySet`. | | `revokeSessionKey(address key)` | anyone, for their own address | Sets the key's expiry to `0` in this same block. Increments `authNonce[msg.sender]`. Emits `SessionKeySet` with expiry `0`. | | `setSessionKeyBySig(address owner, address key, uint64 expiry, uint256 deadline, bytes signature)` | anyone who holds a valid owner signature; normally the session key itself | Checks `deadline`, then the owner's EIP-712 signature over the current nonce. On success sets the expiry, sets `authNonce[owner]` to nonce + 1, emits `SessionKeySet`. | | `setStartSeq(bytes32 space, uint64 seq)` | anyone, for their own address | Records where a reader should start walking `msg.sender`'s chain in `space`. Monotone non-decreasing; setting the same value again is allowed. Emits `StartSeqSet`. | | `canWrite(address owner, address writer) view returns (bool)` | anyone | True when `writer == owner`, or when `sessionKeyExpiry[owner][writer] > block.timestamp`. | | `DOMAIN_SEPARATOR() view returns (bytes32)` | anyone | The EIP-712 domain separator, computed at call time from `block.chainid` and `address(this)`. | | `authorizationDigest(address owner, address key, uint64 expiry, uint256 nonce, uint256 deadline) view returns (bytes32)` | anyone | The exact digest an owner signs to authorize a session key, so a client never rebuilds the domain by hand. | | `head`, `sessionKeyExpiry`, `startSeq`, `authNonce` | anyone | The getters for the four mappings above. | The order of checks in `push` matters when you read a revert: authorization first, then the empty payload, then the head comparison. The compare against `prev` is a cheap compare-and-swap. Two machines racing on the same head cannot both land, and the loser gets `StaleHead` with the digest it should have built on. kint rarely gets that far. Before it sends, `kint push` compares the chain head with the head this machine last saw, anchored or pulled; when they differ it stops, says another machine pushed, and tells you to run `kint pull` first. The one exception is the owner's way past an epoch that never opens: after a pull that stopped at such an epoch, `kint compact --over-skipped` (or `kint rekey --over-skipped`) builds its snapshot on the chain head's digest instead of this machine's, which is exactly the `prev` that `push` checks ([Epochs on Base](/docs/epochs#an-epoch-nobody-can-open)). It then checks `canWrite` for its session key and stops with `session key ... is not authorized for owner ...: authorize it (kint authorize) and fund it`. Every send is gas-estimated first, so a push that would still revert fails as `transaction would revert: ...` without spending gas. No kint command sends `setStartSeq` in v0.3.0. The browser core exposes a reader for it (`readStartSeq` in `js/src/chain.ts`, documented there as the lowest seq the owner still vouches for). ## Events | event | indexed | data | |---|---|---| | `Epoch` | `address owner`, `bytes32 space`, `address writer` | `uint64 seq`, `bytes32 prev`, `bytes32 digest`, `uint64 prevBlock` | | `SessionKeySet` | `address owner`, `address key` | `uint64 expiry` | | `StartSeqSet` | `address owner`, `bytes32 space` | `uint64 seq` | `writer` in `Epoch` is `msg.sender`: the owner itself or the session key that pushed. `prevBlock` is the block of the previous head (`0` for the first epoch in a space). It lets a reader walk from the head back to genesis with one exact-block `eth_getLogs` per epoch, filtered on the `Epoch` topic, the owner and the space, so no RPC log-range cap is ever hit (`walk_epochs` in `src/kint/chain.py`, `walkEpochs` in `js/src/chain.ts`). The ciphertext is in no event. A reader fetches it from the `push` transaction's calldata with `eth_getTransactionByHash`, checks that the transaction is addressed to EpochAnchor and calls `push`, and checks that `keccak256` of the decoded `ct` equals the event's `digest`. ## Errors | error | raised by | when | |---|---|---| | `NotAuthorized()` | `push` | `canWrite(owner, msg.sender)` is false: a stranger, or a session key that is revoked, expired or never set. | | `EmptyCiphertext()` | `push` | `ct` is zero bytes long. | | `StaleHead(bytes32 expected, bytes32 got)` | `push` | `prev` is not the current head digest. `expected` is the digest on chain, `got` is the `prev` you sent. | | `AuthorizationExpired()` | `setSessionKeyBySig` | `block.timestamp > deadline`. Checked before anything else. | | `BadSignature()` | `setSessionKeyBySig` | The signature does not verify for the current nonce. [The signature setSessionKeyBySig checks](/docs/contract#the-signature-setsessionkeybysig-checks) lists every case. | | `SeqNotMonotone()` | `setStartSeq` | `seq` is lower than the value already stored. | ### Selectors and topics Computed from the signatures with `cast sig` and `cast sig-event`. Use them to name a raw revert or to filter logs by hand. | signature | selector or topic0 | |---|---| | `push(address,bytes32,bytes32,bytes)` | `0xa2483825` | | `setSessionKey(address,uint64)` | `0x580da310` | | `revokeSessionKey(address)` | `0x84f4fc6a` | | `setSessionKeyBySig(address,address,uint64,uint256,bytes)` | `0x5247dcff` | | `setStartSeq(bytes32,uint64)` | `0x15e2058b` | | `head(address,bytes32)` | `0x24e8393d` | | `canWrite(address,address)` | `0xedf2c387` | | `sessionKeyExpiry(address,address)` | `0x6844536f` | | `startSeq(address,bytes32)` | `0x1408deb9` | | `authNonce(address)` | `0x8b524b7a` | | `DOMAIN_SEPARATOR()` | `0x3644e515` | | `authorizationDigest(address,address,uint64,uint256,uint256)` | `0x09c9881f` | | `NotAuthorized()` | `0xea8e4eb5` | | `StaleHead(bytes32,bytes32)` | `0x7fe83496` | | `BadSignature()` | `0x5cd5d233` | | `AuthorizationExpired()` | `0x0f05f5bf` | | `SeqNotMonotone()` | `0x4863002e` | | `EmptyCiphertext()` | `0xdbe0cd78` | | `Epoch(address,bytes32,address,uint64,bytes32,bytes32,uint64)` | `0x86da9d02e15b0a4ed66c7fca89e82bdb54f4f39993c59a4c9a5256f0eedf86bc` | | `SessionKeySet(address,address,uint64)` | `0x0775f7c38a29f43cd7fbc9f49b6e6ce64501452fd077c83ed52fa3e847da5625` | | `StartSeqSet(address,bytes32,uint64)` | `0x429767075b5fba88ebcab612058dbfc8613825fa68a59fa886f002c46636fa3e` | ## Session keys A session key is a plain EOA, one per machine, that appends under the owner's name. The owner authorizes it once with an expiry. Until that timestamp is reached, the key may call `push` for any space of that owner; after it, the key gets `NotAuthorized`. Because `canWrite` compares with a strict `>`, a key stops working in the second its expiry is reached. `revokeSessionKey` ends it at once, in the same block. A session key can decrypt nothing; [Keys and custody](/docs/keys) covers how custody splits between the two keys. `kint authorize payload`, `page` and `direct` set the expiry to now plus `--days` (default `30`) days, and `submit` sends the expiry `payload` printed. The five actions reach the contract like this: - `kint authorize payload` prints the EIP-712 typed data for the owner to sign, with the current `authNonce(owner)` and a deadline 300 seconds out. `kint authorize submit --deadline --expiry --signature -` then sends `setSessionKeyBySig` from the session key, which pays the gas. `--signature -` reads the signature from stdin; a path is read and then deleted. - `kint authorize page` serves a one-shot loopback page for a Base Account owner, and the account sends `setSessionKey(key, expiry)` itself. kint then polls `canWrite(owner, key)` until the key shows up. - `kint authorize direct` sends `setSessionKey` from an owner key read from `KINT_OWNER_KEY` (env only, never argv), and refuses when that key is not the owner. - `kint authorize burn-nonce` re-sends `setSessionKey` with the key's current expiry, from the account in `KINT_OWNER_KEY`, which only consumes the nonce. Like `direct`, it refuses when that account is not the owner. `kint session-key show` prints the key's address, keystore path, balance, and, when an owner is known, `authorized` (from `canWrite`) and `expiry` (from `sessionKeyExpiry`). `kint session-key rotate` is local only: it moves the old keystore aside to `session.key.old` and creates a new key. The old key stays authorized on chain until its expiry. kint ships no command that sends `revokeSessionKey`, and the authorize page sends only `setSessionKey`, so to cut an old key off early the owner sends `revokeSessionKey(key)` (or `setSessionKey(key, 0)`) from their own wallet. ### The signature setSessionKeyBySig checks The domain is `name "kint EpochAnchor"`, `version "1"`, `chainId`, `verifyingContract`. The signed type is: ```text SessionKeyAuthorization(address owner,address key,uint64 expiry,uint256 nonce,uint256 deadline) ``` The contract reads `nonce` from `authNonce[owner]`, so the caller never passes it. This is a different EIP-712 domain from the vault message a wallet signs to derive the data key, so an authorization signature can never double as a derive signature. Because the separator is rebuilt from `block.chainid` on every call, a signature made for chain 8453 does not verify on a chain with another id, even one forked from Base. Checking splits on whether the owner address has code: - **Deployed contract account** (`owner.code.length > 0`). The contract calls ERC-1271 `isValidSignature(digest, signature)` on the owner and requires the magic value `0x1626ba7e`. Any other `bytes4`, or a revert inside the account, is `BadSignature`. A reply that does not decode as a `bytes4` (too short, say) reverts without an error name instead, because Solidity's `try`/`catch` does not catch a failure to decode the return data. - **No code** (an EOA, or a smart account that is not deployed yet). The signature must be exactly 65 bytes laid out as `r || s || v`. A `v` of 0 or 1 is normalised to 27 or 28, and any other `v` is rejected. An `s` above half the secp256k1 order is rejected, so `s` has one encoding. `ecrecover` must return the owner and never the zero address. Every failure is `BadSignature`. A signature that already went through is rejected on replay, because the nonce it signed over has moved on. That is also `BadSignature`, not a separate error. > **Warning.** A counterfactual smart account cannot use `setSessionKeyBySig`. A Base Account that has never sent a transaction has no code, so the contract takes the ecrecover path and rejects its signature, and ERC-6492 wrapped signatures are deliberately not supported. The account must send `setSessionKey(key, expiry)` itself: that one transaction deploys the account and authorizes the key. After that the account has code, and `setSessionKeyBySig` works for every later key. `kint authorize page` does exactly this. The live Base Account `0xd3390EDAC3d0EB41248C132792B0DcC5f0b0D4E3` went through that path on Sep 9 2026. Its own `setSessionKey` transaction, sent from kint's authorize page, deployed it, and its machine then anchored an epoch ([tx](https://basescan.org/tx/0x9c64e2c21b294843888680bd34d6da8242cb5313bc894864552163e167049c12), block 51082012). The fork suite checks both sides: an address with no code falls through to ecrecover and fails, and the now-deployed account takes the ERC-1271 path. ### Why owner actions consume the nonce `setSessionKey` and `revokeSessionKey` both increment `authNonce[msg.sender]`, even though neither checks a signature. The point is revocation. Suppose the owner signs a long authorization for a machine, the machine holds it back without submitting it, and the owner later revokes that machine's key. Without the increment, the machine could submit the withheld signature and re-arm itself. With it, any signed-but-unsubmitted authorization dies the moment the owner acts on chain. The unit tests `test_RevokeInvalidatesUnspentAuthorization` and `test_SetSessionKeyInvalidatesUnspentAuthorization` pin this down. `kint authorize burn-nonce` exists for when you want that cancellation without changing any key. ## Reading the head with cast Every read is a free `eth_call`, and nothing below needs a key. The space is the keccak of the literal string `kint-space-v1` followed by the tenant id, which `cast keccak` computes from the string's bytes. For any owner and tenant: ```sh OWNER=0xYourOwnerAddress TENANT=your-tenant-id cast call 0xa22E03f7a4145Bf4909a83595C90a38E14d79600 \ "head(address,bytes32)(bytes32,uint64,uint64)" \ "$OWNER" "$(cast keccak "kint-space-v1$TENANT")" \ --rpc-url https://mainnet.base.org ``` For the demo tenant `kint-demo`, `cast keccak "kint-space-v1kint-demo"` returns `0xbd2a3b5b8f3fb4c81658d863b90018f1af511c28b33356fc81c2de406be98cb0`. Its first 16 hex characters are the `space bd2a3b5b8f3fb4c8` that `kint status` prints. With the demo owner: ```sh cast call 0xa22E03f7a4145Bf4909a83595C90a38E14d79600 \ "head(address,bytes32)(bytes32,uint64,uint64)" \ 0xC635e6Eb223aE14143E23cEEa9440bC773dc87Ec \ 0xbd2a3b5b8f3fb4c81658d863b90018f1af511c28b33356fc81c2de406be98cb0 \ --rpc-url https://mainnet.base.org ``` ```text 0x7df5378a8c4fa8664365cf8956033cac2f4e3cbf7a4767a377d759b262f01a13 2 51081880 ``` The three values come back in order: the head digest, its seq, and the block it landed in (some cast versions append a scientific-notation hint to the block, such as `[5.108e7]`). This is epoch 2 of the demo memory, whose ciphertext is the calldata of [`0x369907fb…`](https://basescan.org/tx/0x369907fb1bccc3222365b2e07b4d6d831642831d481a5f7cb99357823e2a9729). If the demo anchors more epochs, the seq and block rise. Read the block as an upper bound: the head existed no later than that block. `kint status` prints the same head on its `chain head:` line (the seq, the first 16 hex characters of the digest, the block, whether this machine is in step, and the contract address). The other reads take the same shape: ```sh KEY=0xSessionKeyAddress SPACE="$(cast keccak "kint-space-v1$TENANT")" cast call 0xa22E03f7a4145Bf4909a83595C90a38E14d79600 "canWrite(address,address)(bool)" "$OWNER" "$KEY" --rpc-url https://mainnet.base.org cast call 0xa22E03f7a4145Bf4909a83595C90a38E14d79600 "sessionKeyExpiry(address,address)(uint64)" "$OWNER" "$KEY" --rpc-url https://mainnet.base.org cast call 0xa22E03f7a4145Bf4909a83595C90a38E14d79600 "authNonce(address)(uint256)" "$OWNER" --rpc-url https://mainnet.base.org cast call 0xa22E03f7a4145Bf4909a83595C90a38E14d79600 "startSeq(address,bytes32)(uint64)" "$OWNER" "$SPACE" --rpc-url https://mainnet.base.org ``` ## Gas The contract's own execution is flat and small. `contracts/README.md` records 61,409 gas for a 4 KB epoch and 139,387 for a 100 KB one (storage write, event, keccak); `test_GasForFourKilobyteAndHundredKilobytePushes` logs that execution-only delta. That is not the bill. Since EIP-7623 (live on Base since the Isthmus upgrade), a transaction is charged `max(standard, 21000 + 10 * (zeros + 4 * nonzeros))`. An AES-GCM ciphertext is almost all nonzero bytes, so that works out to 40 gas per byte, and the floor absorbs execution at every bucket. Measured on a Base fork at block 51,081,018, the receipt's `gasUsed` equals the floor exactly: | bucket | calldata bytes | gasUsed | |---|---|---| | 4 KB | 4,356 | 191,040 | | 8 KB | 8,452 | 355,390 | | 16 KB | 16,644 | 681,840 | | 32 KB | 33,028 | 1,336,090 | | 64 KB | 65,796 | 2,642,640 | | 96 KB | 98,564 | 3,949,670 | Base charges calldata a second time, as the L1 data fee in the receipt's `l1Fee`. A 4 KB epoch costs about 0.000002 ETH on Base all in; the exact figure moves with the base fee (`contracts/README.md` works it out at about 0.000001 ETH at a 0.005 gwei base fee). kint records the real `gasUsed`, `effectiveGasPrice` and `l1Fee` from every receipt. Deploying costs 879,343 gas on a Base fork, and the mainnet deploy receipt shows 885,843. Restoring and reading never send a transaction. [Epochs on Base](/docs/epochs) covers what goes into the calldata. ## Deploying your own copy You do not need to: every owner uses the one deployment, and its heads are keyed by owner. If you want your own, build first so the artifact exists, then run the Python helper from the repository root: ```sh (cd contracts && forge build) export KINT_RPC_URL=... # a Base mainnet endpoint export KINT_DEPLOYER_KEY=0x... # env only, never a flag, never printed env -u PYTHONPATH .venv/bin/python contracts/deploy.py ``` It stops when either variable is unset, when the build artifact is missing, when the RPC cannot be reached or reports a chain other than 8453 (`wrong chain: RPC reports N, expected 8453`), or when the deployer has no ETH. It estimates gas, adds 20 percent, sends an EIP-1559 transaction, prints the hash, address, `gasUsed`, `effectiveGasPrice` and the Base `l1Fee`, and overwrites `contracts/deployments/base-mainnet.json` with the address, transaction hash, block number, chain id, deployer and ABI. The forge equivalent, run from `contracts/`, is `forge script script/Deploy.s.sol:Deploy --rpc-url "$KINT_RPC_URL" --broadcast` plus a signer flag such as `--ledger`. Point kint at the new address with `KINT_CONTRACT` ([Configuration](/docs/configuration)); epochs anchored at the old address stay there. ## Tests The forge suite is 29 tests. `contracts/test/EpochAnchor.t.sol` has 25 unit tests covering push chaining, stale heads, empty payloads, independent spaces, expiry, revocation, EOA and ERC-1271 signatures, replay, high `s`, wrong length, past deadlines, a reverting account, `startSeq` monotonicity, the chain-id-bound domain separator, and the two nonce-consumption cases. `contracts/test/EpochAnchor.fork.t.sol` has 4 tests against a Base mainnet fork of `https://mainnet.base.org`, and it skips itself cleanly when the network is unreachable. Run them from `contracts/`: ```sh export FOUNDRY_DISABLE_NIGHTLY_WARNING=1 forge build forge test -vv # unit suite plus the Base fork suite forge test -vv --no-match-path 'test/*.fork.t.sol' # offline ``` ## Limits - `push` checks who writes, not what. An authorized session key can append an epoch that no key opens; how kint gets past one, including the owner's `--over-skipped` snapshot, is in [Epochs on Base](/docs/epochs#an-epoch-nobody-can-open). Revoke a leaked key first. - There is no owner rotation. If the owner key is lost, new writes for every space of that owner stop once its session keys expire. Reads never need the owner key. - The ciphertext lives only in transaction calldata, and reading it back depends on the node keeping a transaction index. kint keeps its own ciphertext cache per epoch as the backup copy. The L1 data availability window is the blob retention window, not forever. [Limits and threat model](/docs/limits) has the rest. Read [Browser core](/docs/kint-core) next. Source: [`contracts/src/EpochAnchor.sol`](https://github.com/s0nderlabs/kint/blob/main/contracts/src/EpochAnchor.sol), [`contracts/README.md`](https://github.com/s0nderlabs/kint/blob/main/contracts/README.md), [`contracts/abi/EpochAnchor.json`](https://github.com/s0nderlabs/kint/blob/main/contracts/abi/EpochAnchor.json), [`contracts/deployments/base-mainnet.json`](https://github.com/s0nderlabs/kint/blob/main/contracts/deployments/base-mainnet.json), [`contracts/deploy.py`](https://github.com/s0nderlabs/kint/blob/main/contracts/deploy.py), [`contracts/test/EpochAnchor.t.sol`](https://github.com/s0nderlabs/kint/blob/main/contracts/test/EpochAnchor.t.sol), [`contracts/test/EpochAnchor.fork.t.sol`](https://github.com/s0nderlabs/kint/blob/main/contracts/test/EpochAnchor.fork.t.sol), [`src/kint/chain.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/chain.py), [`src/kint/cli.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/cli.py), [`js/src/chain.ts`](https://github.com/s0nderlabs/kint/blob/main/js/src/chain.ts). --- ## Browser core > Source: https://github.com/s0nderlabs/kint/blob/main/js/src/index.ts `js/` is `@s0nderlabs/kint-core`, a framework-free TypeScript port of kint's Python crypto. It exists so a page can read a wallet's epochs off Base and open them in the browser, with nothing decrypted ever leaving it. ## What it is for The package does the reader's half of kint and nothing else. It derives the KEK from a wallet signature or a passphrase, finds a wrap by its tag, unwraps the data key, walks EpochAnchor from head to genesis, decodes each epoch's ciphertext out of the `push` calldata, opens it, cross-checks the plaintext against the header, and folds the rows into a state. There is deliberately no seal in it. The machine that holds the Sibyl store is the only thing that writes an epoch. The package uses exactly three RPC methods, all reads (`readContract`, `getLogs`, `getTransaction`), and every byte they fetch is public ciphertext. Every format here is a port of `src/kint`, and each one is pinned by vectors the Python itself generates, so the two languages cannot drift apart quietly. See [The vectors](/docs/kint-core#the-vectors). ## Status | Field | Value | |---|---| | Package | `@s0nderlabs/kint-core` | | Version | 0.3.0, the same as the Python | | License | MIT | | Published | No. `"private": true` in `js/package.json`, so it is not on npm | | Module format | ESM only (`"type": "module"`) | | Entry point | `./src/index.ts`, which both `main` and `exports` name | There is no build step and no bundle: the entry point is the TypeScript source, so an app compiles it with its own bundler. Because it is not published, a page consumes it from the repository (a workspace, a link or a vendored copy). Dependencies are pinned exactly: `@noble/hashes` 2.4.0 (keccak, SHA-256, HMAC, HKDF, scrypt), `fflate` 0.8.3 (gunzip), `viem` 2.56.3 (address checksums, EIP-712 hashing, ABI coding, signature recovery, the `PublicClient` seam). Dev dependencies are `typescript` 5.9.3 and `@types/bun` 1.4.2. AES-256-GCM is not a dependency: it comes from WebCrypto, so the page needs a secure context. Without one, every decrypt fails and the error carries `WebCrypto subtle is unavailable; kint needs a secure context`. ## Install and test From the repository root: ```sh cd js bun install bun test # 110 tests bun run typecheck # tsc --noEmit ``` The 110 tests split as 8 for `bytes`, 21 for `derive` (typed data, the KEK paths, the `dek_id` and the recovery code), 23 for `canon`, 32 for `envelope` and 26 for `chain`. `bun run open-epoch` is the cross-language check: it opens one Python-sealed blob with the TypeScript path and prints the plaintext. ```sh KINT_DEK=<64 hex chars> bun run scripts/open-epoch.ts \ --owner 0x... --tenant demo [--seq 1] [--prev <64 hex chars>] [--raw] ``` `--space <64 hex chars>` replaces `--tenant` when the caller already holds the 32-byte space id. `--seq` defaults to 1 and `--prev` to 32 zero bytes. `--raw` writes the plaintext bytes to stdout with no trailing newline. `--help` prints the usage. The data key comes from `KINT_DEK` and never from argv, because a process list is public on a shared machine. The script goes through `readEpoch`, so a blob whose header disagrees with its plaintext exits 1 naming the field instead of printing the forged flag as fact. ## The API by module Everything is re-exported from `src/index.ts`, so an app imports from the package root. The package's own refusals throw `KintCryptoError`, which has the same shape as Python's `KintCryptoError`. ### typedData The one frozen EIP-712 payload the owner signs. That signature is a secret, not a credential: it IS the key, so the page must never send it anywhere and never accept it as a login. See [Keys and custody](/docs/keys). - `typedData(owner: string): KintVaultTypedData`. The payload with the owner checksummed. - `canonicalPayloadJson(owner: string): string`. Byte-stable JSON with a trailing newline, byte-equal to Python's `canonical_payload_json`; this is what `cast wallet sign --data --from-file` reads. - `vaultDigestHex(owner: string): Hex` and `vaultDigest(owner: string): Uint8Array`. `keccak256(0x19 0x01 || domainSeparator || hashStruct(KintVault))`. ### kek - `spaceId(tenantId: string): Uint8Array`. `keccak256("kint-space-v1" || tenant)`, 32 bytes. - `owner20(owner: string): Uint8Array`. The 20 raw bytes of a checksummed address. - `kekInfo(owner: string, space: Uint8Array): Uint8Array`. `"kint-kek-v1" || owner20 || space`, always 63 bytes. - `parseSignature(sig: Uint8Array): ParsedSignature`. Returns `{ r, s, v }` with `v` normalised to 0 or 1. Refuses anything but 65 bytes, a `v` outside 27/28/0/1, or an `r` or `s` outside the curve order. - `normaliseLowS(sig: Uint8Array): { sig: Uint8Array; r: bigint; s: bigint }`. A malleated twin derives the same KEK. - `recoverSigner(digest: Uint8Array, sig: Uint8Array): Promise`. The checksummed address that produced the signature. - `passphraseStretch(passphrase: string, owner: string): Uint8Array`. `scrypt` with N = 2^17 (131072), r = 8, p = 1, dkLen = 32, salt = `owner20`, maxmem 536870912 bytes. UTF-8 as typed, with no Unicode normalisation, exactly like Python's `str.encode`. An empty passphrase throws `empty passphrase`, as the Python does. - `kekTag(kek: Uint8Array): Uint8Array`. `HMAC-SHA256(kek, "kint-kek-check-v1")[:16]`, how a client finds its own wrap without trial-decrypting. - `kekFromSignature(sig, owner, space, passphrase?, opts?): Promise`. Kind `0x01`. `opts` takes `digest` and `signer`; `signer` defaults to `owner` and is the EOA whose 65 bytes are the ikm when the owner is an EOA-owned smart account. The recover check is hard, never behind a `try`: a signature that recovers to anyone else throws `derive signature recovers to

, expected
; refusing to derive a key from it` rather than deriving a silently different key. With a passphrase the HKDF salt is `passphraseStretch`; without one it is empty. - `kekFromPassphrase(passphrase, owner, space): Kek`. Kind `0x02`, the wallet-free path a Base Account owner uses. Both return `{ kek, tag }`. ### envelope - `dekId(dek: Uint8Array): Uint8Array`. `HMAC-SHA256(dek, "kint-dek-id-v1")[:8]`. - `parseWrap(b: Uint8Array): Wrap` and `wrapToBytes(w: Wrap): Uint8Array`. A wrap is `kind(1) | kek_tag(16) | wrap_nonce(12) | wrapped_dek(48)`, 77 bytes. - `findWrap(wraps: readonly Wrap[], kekTagBytes: Uint8Array): Wrap | null`. Compares tags; it never trial-decrypts. - `unwrapDek(wrap, kek, kekTagBytes = kekTag(kek)): Promise`. The tag must match before AES is touched, because AES-GCM is not key-committing. The default is the tag of the key you passed, which is the only value that makes the check mean anything; passing `wrap.tag` compares the wrap to itself and disables it. A mismatch throws `kek_tag mismatch: this key does not open this wrap`, and a wrap that fails to authenticate throws `wrap did not authenticate`. - `decodeRecoveryCode(code: string): Uint8Array`. Base32 of `DEK || sha256(DEK)[:2]`, accepting the grouped, lowercase and space-separated forms a user actually types. A typo fails on the checksum: `recovery code checksum failed (typo?)`. - `bucketFor(length: number): number` and `nextBucket(length: number, previousBucket: number): number`. The buckets are 4096, 8192, 16384, 32768, 65536 and 98304 bytes, and the ratchet is monotone per space. - `unpad(padded: Uint8Array): Uint8Array`. `uint32(len) || gz || zeros` back to `gz`. - `parseHeader(blob: Uint8Array): { header: Header; offset: number }`, `peekHeader(blob): Header`, `isSnapshotHeader(header): boolean`. The fixed header is 59 bytes: `version(1) | flags(1) | nonce(12) | rows_root(32) | dek_id(8) | bucket(4) | n_wraps(1)`, then the wraps. - `epochAad(input: EpochAadInput): Uint8Array`. `keccak256(abi.encode(chainId, owner, space, seq, prev, bucket, rows_root, dek_id))`. - `openEpoch(blob, input: OpenEpochInput): Promise`. `input` is `{ dek, owner, space, seq, prev }`. It checks the ciphertext length against `bucket + 16`, checks `dekId(dek)` against the header (`dek_id mismatch: wrong data key for this epoch`), builds the AAD and decrypts, then gunzips. A wrong key, seq, prev, owner or space all land on `epoch did not authenticate: wrong key, wrong seq/prev/owner/space, or tampered ciphertext`. - `ciphertextDigest(ct): Uint8Array` and `ciphertextDigestHex(ct): Hex`. The digest the `Epoch` event carries. ### epoch - `parsePlaintext(data: Uint8Array | string): EpochPlaintext`. Refuses any version but 1. The document is `{ v, tenant, space, seq, prev, rows, deleted, rows_root, n_rows, created_at, snapshot }`, and `snapshot` is `true` only when the plaintext carries the key. - `assertEpochConsistent(header, doc, expected: EpochExpectation): void`. `expected` is `{ seq, prev, spaceHex }`, each of which takes raw bytes or hex with or without `0x`, in either case. See below. - `readEpoch(blob, input: OpenEpochInput): Promise`. `openEpoch` plus `parsePlaintext` plus `assertEpochConsistent`, returning `{ header, doc, plaintext }`. - `applyEpoch(state: RowState, header: Header, doc: EpochPlaintext): AppliedEpoch`. Folds one epoch into a row map and returns `{ state, root, rootHex, snapshot, matchesRowsRoot, dropped }`. The state passed in is never mutated; use the one that comes back. `dropped` names the row ids the prior state held and this epoch does not, which is what a viewer shows as gone rather than unchanged. The two epoch kinds apply differently, and getting it wrong is how a viewer shows a row its owner deleted. An ordinary epoch is a diff: set `doc.rows`, then delete `doc.deleted`. A snapshot epoch (`FLAG_SNAPSHOT`, `0x02`) replaces the state: the map is cleared and set to exactly `doc.rows`, because a snapshot's rows are the whole state and its `rows_root` is the root of exactly those rows. Its `deleted` list is informational and is not needed to reach the anchored root, which is what lets a cold start stop at a snapshot. Merge one in as though it were a diff and every row the snapshot dropped survives, so `matchesRowsRoot` comes back false. More in [Epochs on Base](/docs/epochs). ### canon - `lp(x: string | null | undefined): Uint8Array`. `uint32(len(utf8)) || utf8`, and `0xFFFFFFFF` for a null column. - `journalContentKey(ts, evaluated, acted, forward, extra): string`. Journal rows get a new uuid on replay, so their identity is their content. A second identical event in one store carries the key with `:1` appended, a third `:2`, in rowid order; the exporter assigns that ordinal and it travels in the epoch's rows. - `rowId(row: Row): string`. `tier \0 category \0 key`. An unknown tier throws; the tiers are `entity`, `state`, `reference` and `journal`. - `deletedId(triple: readonly (string | null | undefined)[]): string`. The same id from the `[tier, category, key]` triple an epoch carries. - `leaf(row: Row): Uint8Array`. `keccak256("kint-leaf-v1" || the length-prefixed columns)`. Nothing in this file ever re-encodes a body: the leaf hashes the exact stored text. - `leavesOf(rows: readonly Row[]): Map` and `sortedIds(leaves: Leaves): string[]`. `Leaves` is a `Map` or a plain object. - `merkleRoot(leaves: Leaves): Uint8Array`. Pairs hashed as `keccak256(left || right)`, an odd last node carried up unchanged, and the empty state is `keccak256("kint-empty-v1")`. The return is always a fresh array, so a careless write cannot corrupt the shared empty root. - `merkleProof(leaves: Leaves, targetId: string): ProofStep[]` and `verifyProof(leafHash, proof, root): boolean`. A step is `{ sibling, siblingIsLeft }`. ### chain Constants: `DEFAULT_CONTRACT` (EpochAnchor on Base mainnet, `0xa22E03f7a4145Bf4909a83595C90a38E14d79600`), `BASE_CHAIN_ID` (8453), `PUBLIC_RPC` (Base's public read endpoint), `EPOCH_EVENT` and `EPOCH_ANCHOR_ABI`, the reader's slice of the ABI (`head`, `sessionKeyExpiry`, `startSeq`, `push` and the `Epoch` event). The full ABI is `contracts/abi/EpochAnchor.json`; see [EpochAnchor contract](/docs/contract). - `fromViem(client: PublicClient): EpochReadClient`. Wraps a viem client into the three-call seam the readers use. A fake object satisfies the same interface, which is how the walk is tested without a network. - `readHead(client, contract, owner, space): Promise`. `{ digest, seq, blockNumber }`. - `readSessionKeyExpiry(client, contract, owner, key): Promise`. A unix second, 0 when the key was never authorized. - `readStartSeq(client, contract, owner, space): Promise`. The lowest seq the owner still vouches for. - `epochsAtBlock(client, contract, owner, space, blockNumber): Promise`. One exact-block `eth_getLogs` for that owner and space. - `walkEpochs(client, contract, owner, space, options?): Promise`. Head to genesis, newest first, hopping through each event's `prevBlock`, so no RPC log-range cap is ever hit. `WalkOptions` is `stopSeq` (an exclusive floor, default 0), `head` (skip the `head()` call when the caller has it), `maxEpochs` (default 100000) and `stopWhen`, evaluated after an event is appended and allowed to be async. A missing hop throws `no Epoch event for seq N at block B; the RPC may be pruned or lying`. - `decodePushCalldata(data: Hex): PushCall`. `{ owner, space, prev, ct }`. - `epochCiphertext(client, txHash, contract): Promise`. Fetches the transaction and refuses one not addressed to EpochAnchor: `tx is not addressed to EpochAnchor`. The contract argument is required, because without it a reader would decode `push()` calldata out of any transaction at all, and anyone can write that calldata. - `assertCiphertextMatchesDigest(ct, digest): void`. The check a viewer must not skip: only `keccak256(ct)` matching the anchored digest makes those bytes the epoch the chain vouched for. - `isSnapshotEvent(client, ev, contract): Promise`. Fetches the calldata and peeks the header flag. An epoch whose header it cannot even read is not a place to stop, so it returns false rather than throwing. - `coldStartEvents(client, contract, owner, space, options): Promise`. The walk a cold start should use. Options are `stopSeq`, `head` and the required `tryOpen`. It returns `{ events, refusedSnapshotSeq, stoppedAtSnapshot }`, and `events` is oldest first, ready for `applyEpoch`. ### bytes `utf8Bytes`, `utf8String` (a fatal UTF-8 decoder: invalid bytes throw), `concatBytes`, `bytesEqual` (every byte compared, no early exit, when the lengths match), `toHex`, `fromHex`, `to0x`, `normaliseAddress`, `u32be`, `readU32be`, `bytesToBigInt`, `bigIntTo32`, `compareCodePoints`, `sortCodePoints`, and the shared `encoder` and `decoder`. `fromHex` validates the whole string before it parses a byte, because `parseInt` reads `"1z"` as 1 and a digest silently short of what the sender wrote is the bug this package exists to catch. `normaliseAddress` accepts the `0X` prefix that viem's `getAddress` rejects and re-checksums the body, exactly as Python's `to_checksum_address` does, so a caller that wants to reject a bad checksum has to compare its own input against what comes back. ### constants The frozen values, ported byte for byte from `src/kint/crypto.py` and `src/kint/canon.py`. | Constant | Value | |---|---| | `CHAIN_ID` | 8453 | | `PURPOSE` | the one warning sentence the wallet renders | | `DOMAIN`, `TYPES`, `PRIMARY_TYPE` | domain `kint` version `1`, type `KintVault(owner, purpose)` | | `SPACE_PREFIX` | `kint-space-v1` | | `KEK_INFO_PREFIX` | `kint-kek-v1` | | `KEK_CHECK` | `kint-kek-check-v1` | | `DEK_ID_INFO` | `kint-dek-id-v1` | | `WRAP_AAD_PREFIX` | `kint-wrap-v1` | | `LEAF_PREFIX` | `kint-leaf-v1` | | `JOURNAL_KEY_PREFIX` | `kint-journal-key-v1` | | `SCRYPT_N`, `SCRYPT_R`, `SCRYPT_P`, `SCRYPT_DKLEN`, `SCRYPT_MAXMEM` | 131072, 8, 1, 32, 536870912 | | `SECP256K1_N`, `SECP256K1_HALF_N` | the curve order and half of it | | `KEK_KIND_SIGNATURE`, `KEK_KIND_PASSPHRASE` | `0x01`, `0x02` | | `KEK_KIND_PRF`, `KEK_KIND_SMART_ACCOUNT` | `0x03`, `0x04`, both reserved | | `FLAG_SIGNATURE_WRAP_PASSPHRASE_SALTED`, `FLAG_SNAPSHOT` | `0x01`, `0x02` | | `ENVELOPE_VERSION`, `EPOCH_VERSION` | 1, 1 | | `BUCKETS` | 4096, 8192, 16384, 32768, 65536, 98304 | | `WRAP_LEN`, `HEADER_FIXED_LEN`, `GCM_TAG_LEN` | 77, 59, 16 | | `TIERS` | `entity`, `state`, `reference`, `journal` | | `EMPTY_ROOT` | `keccak256("kint-empty-v1")` | | `LP_NULL` | `0xFFFFFFFF` | ## Reading the head epoch Wrap a viem client once, read the head, take the data key out of the newest header, then walk. ```ts import { DEFAULT_CONTRACT, applyEpoch, assertCiphertextMatchesDigest, coldStartEvents, epochCiphertext, epochsAtBlock, findWrap, fromViem, kekFromPassphrase, peekHeader, readEpoch, readHead, spaceId, unwrapDek, } from '@s0nderlabs/kint-core'; import type { EpochEvent, Row } from '@s0nderlabs/kint-core'; const client = fromViem(publicClient); const space = spaceId('kint-demo'); const head = await readHead(client, DEFAULT_CONTRACT, owner, space); const at = await epochsAtBlock(client, DEFAULT_CONTRACT, owner, space, head.blockNumber); const headEvent = at.find((e) => e.seq === head.seq)!; const { ct } = await epochCiphertext(client, headEvent.txHash, DEFAULT_CONTRACT); assertCiphertextMatchesDigest(ct, headEvent.digest); const { kek, tag } = kekFromPassphrase(passphrase, owner, space); // or kekFromSignature const wrap = findWrap(peekHeader(ct).wraps, tag); if (!wrap) throw new Error('no wrap in the head epoch carries this key'); const dek = await unwrapDek(wrap, kek); ``` `peekHeader` throws when the head epoch is not a kint epoch at all. [An epoch that does not open](/docs/kint-core#an-epoch-that-does-not-open) covers what kint's Python does then. With the data key in hand, `coldStartEvents` gets the epoch list. It stops the walk at the newest snapshot, but only after your `tryOpen` has actually opened that epoch, and it walks the whole history instead when the snapshot refuses. ```ts async function open(ev: EpochEvent) { const { ct } = await epochCiphertext(client, ev.txHash, DEFAULT_CONTRACT); assertCiphertextMatchesDigest(ct, ev.digest); return readEpoch(ct, { dek, owner, space, seq: ev.seq, prev: ev.prev }); } const { events, refusedSnapshotSeq } = await coldStartEvents( client, DEFAULT_CONTRACT, owner, space, { tryOpen: (ev) => open(ev).then(() => true) }, // false or a throw both mean "do not stop here" ); let state = new Map(); for (const ev of events) { // already oldest first if (ev.seq === refusedSnapshotSeq) continue; // show this: an epoch the viewer would not open const { header, doc } = await open(ev); const applied = applyEpoch(state, header, doc); if (!applied.matchesRowsRoot) throw new Error(`epoch ${ev.seq} does not reproduce its rows_root`); state = applied.state; } ``` `refusedSnapshotSeq` is not a detail to swallow. It means an epoch on this chain carries a `FLAG_SNAPSHOT` its plaintext does not back up, and the viewer read the history the long way round instead. Put it on screen next to the rows, naming the seq. A row's block height is an upper bound, "existed no later than block N", so the viewer must never render it as a wall-clock "as of". A key rotation (`kint rekey`) changes the `dek_id`: epochs before it are sealed under the old data key and refuse with `dek_id mismatch`. The rotation anchors a snapshot under the new key, so a viewer holding only the current key stops there. `walkEpochs` with `stopWhen: (ev) => isSnapshotEvent(client, ev, contract)` is the raw form of that stop, and it trusts a header byte nothing signs. Reach for it directly only when you want the full history regardless. ## An epoch that does not open Any authorized session key can append bytes as the next epoch, including bytes that are not a kint epoch or that no key opens. kint's Python handles two such cases that this package leaves to the page, and the loop above throws on both. **A head that is not a kint epoch.** `kint connect` and `kint rekey` take the key wraps from the newest epoch whose header parses: when the head's header does not, they walk back through `prevBlock`, looking at most 31 epochs before the head. A fetch that fails, or calldata whose digest disagrees with the event, is never walked past; that is the chain not being read, not an epoch that is not a kint epoch (`_chain_head_header` in `src/kint/connect.py`). A page does the same walk with `walkEpochs`, `epochCiphertext`, `assertCiphertextMatchesDigest` and `peekHeader`. **An epoch in the middle.** `kint pull` steps past an epoch only when it cannot open it and a later snapshot in the same walk opens with the same key. It resumes at that snapshot with the snapshot event's own `prev`, and reports the skipped seq together with the snapshot that superseded it. Every other failure stops the pull where it is (`_open_one` and the apply loop in `src/kint/pull.py`). In this package's terms: | What failed | kint's kind | A later snapshot may supersede it | |---|---|---| | `peekHeader` or `parseHeader` throws, no wrap or `dek_id mismatch`, `epoch did not authenticate` | unreadable | yes | | `epochCiphertext` throws, the calldata disagrees with the event, `assertCiphertextMatchesDigest` throws, or `prev` does not chain | chain | no | | `parsePlaintext` or `assertEpochConsistent` throws | content | no | A viewer that follows this rule names the skipped seq on screen, as it does `refusedSnapshotSeq`. ## The consistency check is mandatory > **Warning.** The header flags are not covered by the AAD. `openEpoch` alone will happily open an epoch whose `FLAG_SNAPSHOT` bit someone flipped in transit: the ciphertext still authenticates, and nothing but the plaintext `"snapshot"` key contradicts it. A reader that skips that comparison can be told to throw its state away. `readEpoch` is the call a page should actually make. It runs `assertEpochConsistent`, a port of the block kint's own pull runs after `open_epoch`, comparing four plaintext fields and one flag against what the reader already knew: | Field | Compared against | Error | |---|---|---| | `doc.seq` | the seq the chain gave | `epoch seq disagrees` | | `doc.space` | the space id the reader asked for | `epoch space disagrees` | | `doc.prev` | the previous epoch's digest | `epoch prev disagrees` | | `doc.rows_root` | `header.rowsRoot` | `epoch rows_root disagrees` | | `doc.snapshot` | `isSnapshotHeader(header)` | `epoch snapshot flag disagrees` | Each message names the field and both values. `applyEpoch` re-checks the flag on its own and refuses an epoch whose header and plaintext disagree rather than folding it in. Use `readEpoch`, or call `assertEpochConsistent` yourself. The same rule holds on the Python side; see [Verify before acting](/docs/verify). ## The code-point sort Leaves are ordered by their canonical id, and that ordering is Python's: by Unicode code point. JavaScript's default comparison walks UTF-16 code units instead, which puts an astral character (a surrogate pair starting at `0xD800`) before anything in U+E000..U+FFFF where Python puts it after. Row ids carry user text, so the difference is real and it changes the merkle root. `compareCodePoints` is the comparator that fixes it, and `merkleRoot`, `sortedIds` and `merkleProof` all sort with it. The vector row set contains a key starting U+FB01 and one starting U+1F525 under the same category, so it only sorts correctly with the comparator: one test asserts the order matches Python's `sorted()`, and another asserts that the default `sort()` does not. ## The vectors `scripts/gen_vectors.py` makes the Python say what the bytes are, and writes `js/test/vectors.json`: ```sh env -u PYTHONPATH .venv/bin/python scripts/gen_vectors.py ``` It signs the frozen payload with a throwaway key (`0x11` repeated 32 times, never used for anything real), derives all three KEK paths (signature, signature salted with a passphrase, passphrase), wraps a fixed data key under each, builds eight rows across all four tiers with a null category, a null status, a null meta, accents and the code-point trap, and seals two epochs: epoch 1 as an ordinary diff, epoch 2 as a genuine snapshot with one row dropped and one changed. The file records the typed data, the canonical payload JSON, the vault digest, the space id, the KEK info, every KEK and tag, the scrypt parameters, the `dek_id`, a recovery code, a foreign wrap `findWrap` must skip, the empty root, every row id and leaf, the sorted ids, the root, two merkle proofs, both sealed blobs with their headers and AADs, and one `push` calldata. Do not hand-edit that file. Regenerate it when the Python format moves, and the TypeScript tests fail until the port moves with it. ## Limits - `seq`, `blockNumber` and `prevBlock` come back as JavaScript numbers, not bigints. They are `uint64` on chain, so the values are exact only below 2^53; the bigints are still on the raw viem log if you ever need them. - `epochCiphertext` and `isSnapshotEvent` both require the contract address, for the reason given above. - There is no helper for the walk past an unreadable head or past an epoch that does not open; the page implements the rule above itself. - The session-key authorization typed data (`SessionKeyAuthorization`, for `setSessionKeyBySig`) is not ported. A page that asks a wallet for one builds it from [`docs/frontend-contract.md`](https://github.com/s0nderlabs/kint/blob/main/docs/frontend-contract.md). - Nothing here seals an epoch or writes to the chain, and reading never sends a transaction. Read [Harnesses](/docs/harnesses) next. Source: [`js/src/index.ts`](https://github.com/s0nderlabs/kint/blob/main/js/src/index.ts), [`js/src/kek.ts`](https://github.com/s0nderlabs/kint/blob/main/js/src/kek.ts), [`js/src/envelope.ts`](https://github.com/s0nderlabs/kint/blob/main/js/src/envelope.ts), [`js/src/epoch.ts`](https://github.com/s0nderlabs/kint/blob/main/js/src/epoch.ts), [`js/src/canon.ts`](https://github.com/s0nderlabs/kint/blob/main/js/src/canon.ts), [`js/src/chain.ts`](https://github.com/s0nderlabs/kint/blob/main/js/src/chain.ts), [`js/src/bytes.ts`](https://github.com/s0nderlabs/kint/blob/main/js/src/bytes.ts), [`js/src/constants.ts`](https://github.com/s0nderlabs/kint/blob/main/js/src/constants.ts), [`js/README.md`](https://github.com/s0nderlabs/kint/blob/main/js/README.md), [`js/package.json`](https://github.com/s0nderlabs/kint/blob/main/js/package.json), [`js/scripts/open-epoch.ts`](https://github.com/s0nderlabs/kint/blob/main/js/scripts/open-epoch.ts), [`scripts/gen_vectors.py`](https://github.com/s0nderlabs/kint/blob/main/scripts/gen_vectors.py), [`src/kint/connect.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/connect.py), [`src/kint/pull.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/pull.py), [`docs/frontend-contract.md`](https://github.com/s0nderlabs/kint/blob/main/docs/frontend-contract.md). --- ## Harnesses > Source: https://github.com/s0nderlabs/kint/blob/main/src/kint/cli.py kint never connects to a harness: it wraps Sibyl's store, and harnesses already talk to Sibyl. This page covers the two doors in, what `kint setup` writes for each harness, and what was verified. ## Two doors | door | harnesses | how memory is read | restore and verify | |---|---|---|---| | MCP (stdio) | Claude Code, Codex, Hermes (`hermes mcp add`), OpenClaw, any other MCP client | the harness calls Sibyl's eight `memory_*` tools, served by `kint-server` next to kint's six | `kint-server` pulls on start; `memory_verify` before acting; `memory_push` on quiet, on demand, on exit | | SDK-direct | Hermes's Sibyl memory provider, LangGraph, any Python process | the process opens Sibyl's `memory.db` itself | `kint pull` before launch, `kint push` after, `kint verify "query"` from a shell | The MCP door is the one that refuses before the agent acts: `memory_verify` is a tool call inside the session. An SDK-direct process gets the integrity check at pull time instead, where every epoch is verified against the chain before a row is replayed. Both doors read and write the same kind of file, so a machine can use either, one store at a time. `kint-server` takes no flags. It speaks MCP on stdio and logs to stderr and `~/.kint/kint.log`, never stdout. When the machine is connected and the data key is cached, it pulls on start and waits for that pull up to `KINT_BOOTSTRAP_SECONDS` (20 by default) before it lists its tools; a longer pull finishes behind the server (see [kint-server pulls before it serves](/docs/new-machine#kint-server-pulls-before-it-serves)). ## kint setup ```sh kint setup all --env KINT_TENANT=kint-demo ``` The target is `all`, `claude`, `codex`, `hermes` or `openclaw`; `all` runs the four in that order. `--env KEY=VALUE` is repeatable and every value must contain `=`. Setup looks for `kint-server` next to the `kint` binary, then on `PATH`, and writes its resolved absolute path into each registration. Without it, setup stops with `kint: kint-server not found next to kint or on PATH`. A target whose CLI is missing prints `: CLI not found, skipped` (Codex needs no CLI: setup writes its file), and a target that fails prints `: failed: ...` while the rest carry on: ```text claude: registered kint (user scope) claude: `claude mcp remove -s user sibyl-memory` if Sibyl's own server is also registered (one store, one server) codex: wrote [mcp_servers.kint] to /.codex/config.toml hermes: registered kint hermes: SDK-direct alternative: `kint pull` before launch, `kint push` after, store at $SIBYL_MEMORY_DB openclaw: saved MCP server kint (openclaw mcp set) ``` Every registration keeps your shell's `PYTHONPATH` out of the server, because a polluting one shadows the tool's environment. Claude Code gets `-e PYTHONPATH=x` (a dummy that replaces the inherited value) and launches through `/usr/bin/env -u PYTHONPATH`. Codex sets `PYTHONPATH = ""`. Hermes and OpenClaw launch `/usr/bin/env PYTHONPATH= ... kint-server`. > **Warning.** `--env` values are written into the harness's own configuration in plain text: `~/.codex/config.toml`, Claude Code's MCP registration, Hermes's and OpenClaw's server definitions. Put the tenant and the store path there, never the derive signature, a vault passphrase or a recovery code. `kint join` runs the same registration as its last step: every harness it finds by default, `--setup ` for one, `--no-setup` for none, with `KINT_TENANT` always pinned to the tenant it joined, plus any `--env KEY=VALUE` ([One command: kint join](/docs/new-machine#one-command-kint-join)). ## Pin the tenant `kint-server` resolves its own tenant: `KINT_TENANT`, else `tenant_id` then `account_id` from Sibyl's `credentials.json`, else Sibyl's default tenant. The global `--tenant` and `--db` flags stop at the CLI, so `kint --tenant kint-demo setup claude` does not pin anything. Pin both through `--env`: ```sh kint setup all --env KINT_TENANT=kint-demo --env SIBYL_MEMORY_DB=/abs/path/memory.db ``` The space is derived from the tenant, and the vault is keyed by owner and tenant together. A harness that starts `kint-server` under another tenant reads a different chain of epochs, and on a tenant this machine never connected, the server serves without pulling. Sibyl's eight tools write through kint's client, so `KINT_TENANT` picks their tenant too. ## Claude Code `kint setup claude` first removes any user-scope `kint` registration, silently, then runs: ```sh claude mcp add --scope user kint -e PYTHONPATH=x -e KINT_TENANT=kint-demo -- /usr/bin/env -u PYTHONPATH /abs/path/kint-server ``` Running it again replaces the registration, which is how you change an `--env` value. The tools show up as `mcp__kint__memory_status`, `mcp__kint__memory_search` and so on. Remove Sibyl's own registration if it is there: `claude mcp remove -s user sibyl-memory`. One store, one server, or the two servers race on the same file. The same goes for every other harness: wherever `kint-server` is registered, Sibyl's own server should not be. A second writer on the store while `kint-server` runs (Sibyl's server, another harness's `kint-server`, an SDK-direct process) also makes its updates to anchored rows read as changes behind Sibyl's tools, and the server holds its pushes (`HELD`) until those rows are anchored. Setup registers at user scope only. For project scope, register by hand. Keep the `-e` flags after the name, as setup does: ```sh claude mcp add --scope project kint -e PYTHONPATH=x -e KINT_TENANT=kint-demo -- /usr/bin/env -u PYTHONPATH /abs/path/kint-server ``` That writes `.mcp.json` in the project, and Claude Code asks you to approve it until the folder is trusted. Verified Sep 9 2026 with a non-interactive run: ```sh claude -p 'call memory_status, then memory_search "Friday release", then memory_verify "Friday release"' \ --allowedTools 'mcp__kint__memory_status,mcp__kint__memory_search,mcp__kint__memory_verify' ``` It returned the owner, head seq 2 at block 51081880, the exact rule text, and decision `proceed` with all three merkle proofs passing. ## Codex `kint setup codex` copies `~/.codex/config.toml` to `config.toml.bak-` and appends: ```toml [mcp_servers.kint] command = "/abs/path/kint-server" args = [] [mcp_servers.kint.env] PYTHONPATH = "" KINT_TENANT = "kint-demo" ``` If the file already has a `[mcp_servers.kint]` table, setup prints `codex: already configured` and changes nothing, so edit the file by hand to change an env value later. Give the server room. It waits up to `KINT_BOOTSTRAP_SECONDS` (20 by default) for its startup pull before it lists its tools, and `memory_push` and `memory_pull` wait for Base confirmations. Add the two timeouts to the same table, above the `env` table (a second `[mcp_servers.kint]` header is a TOML error): ```toml [mcp_servers.kint] command = "/abs/path/kint-server" args = [] tool_timeout_sec = 180 startup_timeout_sec = 90 ``` For one run, `-c mcp_servers.kint.tool_timeout_sec=180` sets the tool timeout on the command line. Verified Sep 9 2026 (Codex 0.130): in the interactive TUI the tools work as in Claude Code. Non-interactive `codex exec` runs with `approval: never` inside its sandbox and cancels MCP calls that need an escalation, reported as "user cancelled MCP tool call". A 180-second timeout under the default sandbox still cancelled, so the cause is the sandbox policy, not a timeout. `codex exec --sandbox danger-full-access` recalled the exact rule, head seq 2 at block 51081880, decision `proceed`. ## Hermes ### The MCP door `kint setup hermes` runs: ```sh hermes mcp add kint --command /usr/bin/env --args PYTHONPATH= KINT_TENANT=kint-demo /abs/path/kint-server ``` Setup passes `PYTHONPATH=` to `env` rather than `-u PYTHONPATH`, which Hermes's argument parser does not accept inside `--args`. Hermes connects to the server during `add` (discovery first) and asks `Enable all 14 tools? [Y/n/select]`; answer `Y`. Setup gives that command 60 seconds and captures its output, so the question may not show. If setup prints `hermes: failed:` with a timeout, run the line above yourself and answer `Y`. Verified Sep 9 2026 (Hermes 0.8.0): `hermes chat -Q -q '...'` reported the owner, head seq 2 at block 51081880, the exact rule text and decision `proceed`. ### The SDK door Hermes's Sibyl memory provider opens its store directly: `$HERMES_HOME/sibyl/memory.db`, with `HERMES_HOME` defaulting to `~/.hermes`, and a profile's store at `$HERMES_HOME/sibyl/profiles//memory.db`. Point kint at the same file and wrap the session: ```sh export SIBYL_MEMORY_DB="${HERMES_HOME:-$HOME/.hermes}/sibyl/memory.db" kint pull hermes chat kint push ``` `kint --db PATH` does the same for a single command. Use the tenant you connected with (`KINT_TENANT` or `kint --tenant`). `kint verify "query"` exits 0 on `proceed` and 1 on `refuse`, so a wrapper script can gate an action on it. Pick one door per Hermes store: the MCP door's server reads `SIBYL_MEMORY_DB` too, and defaults to `~/.sibyl-memory/memory.db`, not the provider's file. A provider writing the same file while `kint-server` runs makes the server hold its pushes. ## OpenClaw `kint setup openclaw` runs: ```sh openclaw mcp set kint '{"command":"/usr/bin/env","args":["PYTHONPATH=","KINT_TENANT=kint-demo","/abs/path/kint-server"]}' ``` OpenClaw backs up `~/.openclaw/openclaw.json`, then saves the definition into it, and `openclaw mcp list` shows `kint`. Setup allows the command 120 seconds and drops lines from OpenClaw's `string-bridge` plugin out of what it prints. OpenClaw's embedded runtime launches the server and exposes the same fourteen tools as `kint__memory_*`. Verified Sep 9 2026 (OpenClaw 2026.4.10), on a dedicated agent with a model that calls tools reliably: ```sh openclaw agents add kint-test --workspace ~/.openclaw/workspace-kint-test --model openrouter/anthropic/claude-sonnet-4.5 --non-interactive openclaw agent --agent kint-test --local --message '...' ``` It called `kint__memory_status`, `kint__memory_search` and `kint__memory_verify` and answered with the owner, head seq 2 at block 51081880, the exact rule text and decision `proceed`, 11 seconds after the prompt. Three things that are OpenClaw's, not kint's. The model matters: the default `openrouter/z-ai/glm-5.1` route returned tool-use stops with no payload ("incomplete turn ... stopReason=toolUse payloads=0") on every message, so test with a model that has reliable tool calling. A chain-polling plugin (`string`) made every CLI invocation take minutes; disable it for the test. And `openclaw agent` needs `--agent `, `--session-id` or `--to` to pick a session. ## Any other MCP client Claude Desktop, Cursor and other stdio MCP clients were not tested, but `kint-server` is a plain stdio server and needs nothing a client does not already give Sibyl's. Most clients take a JSON server entry; this one uses the launch line `kint setup openclaw` saves: ```json { "mcpServers": { "kint": { "command": "/usr/bin/env", "args": ["PYTHONPATH=", "KINT_TENANT=kint-demo", "/abs/path/kint-server"] } } } ``` Use the absolute path to `kint-server` (`command -v kint-server` prints one), set the timeouts the client offers generously for the startup pull and for push calls, and remove the client's Sibyl entry. ## A VPS A server is a machine like a laptop, with three differences. - **No Keychain.** The session key's passphrase comes from `KINT_SESSION_PASSPHRASE`, else a `~/.kint/local.secret` file (mode 0600) created on first use. The same secret encrypts the cached data key, so `kint connect` and the `kint-server` your harness starts must see the same value. Leaving the variable unset and letting both read `local.secret` is the simplest way. - **A long cache.** The data key cache lasts `KINT_KEY_TTL` (default `24h`), read when you connect, and `kint-server` renews it after every successful pull and push with the `KINT_KEY_TTL` in its own environment. Connect once over SSH with `KINT_KEY_TTL=30d` for an unattended machine, and register the server with the same value (`--env KINT_KEY_TTL=30d`). Do not use `session` there: it keeps the data key in the memory of the `kint connect` process, which exits. When the cache expires anyway, the watcher stops pushing and `memory_push` returns `KEY_EXPIRED`; run `kint connect` again, nothing is dropped. - **Authorize from elsewhere.** `kint authorize page` binds 127.0.0.1 on the machine that runs it. On a VPS, `kint authorize payload` then `submit` fits better: sign the typed data with the owner wallet on your own machine and pipe the signature to `kint authorize submit ... --signature -` on the server. Fund the session key with a little ETH on Base. Without a Keychain, kint reads and sends through a public Base endpoint unless `KINT_RPC_URL` is set, and a cold start asks a second, different endpoint (`KINT_RPC_URL_2`); see [Configuration](/docs/configuration). Then register the harness as above: the agent pulls on start and pushes when quiet. A compromised VPS can read that space; Sibyl's `memory.db` is already plaintext there. Revoke its session key on the contract ([Expiry and revocation](/docs/keys#expiry-and-revocation)) and run [`kint rekey`](/docs/keys#rotate-the-data-key) from a machine you trust. Everything anchored before that rotation stays readable to whoever took the old key: a rotation protects what comes next, not what is already on a public ledger. Read [For agents](/docs/agents) next. Source: [`src/kint/cli.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/cli.py), [`src/kint/server.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/server.py), [`src/kint/store.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/store.py), [`src/kint/paths.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/paths.py), [`src/kint/keys.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/keys.py), [`docs/agents.md`](https://github.com/s0nderlabs/kint/blob/main/docs/agents.md). --- ## For agents > Source: https://github.com/s0nderlabs/kint/blob/main/src/kint/server.py You are an agent, and a human asked you to set up or use kint. This page says what you can do for them, what must go back to them, and what never goes through the chat. ## If you only read one line **kint is not on PyPI yet (install it from the git tag), run it with `PYTHONPATH` unset, call `memory_verify` before you act on anything you recalled (push your own edits first), and never ask the human for the wallet signature, the vault passphrase or the recovery code in chat.** ## The install gotchas kint is not on PyPI yet, so an install by bare name (`uv tool install kint`, `pip install kint`) does not get this project. Install from the tag instead (Python 3.10 or newer): ```sh uv tool install git+https://github.com/s0nderlabs/kint@v0.3.0 ``` That puts two binaries on `PATH`: `kint`, the CLI, and `kint-server`, the MCP server a harness launches. If the user's shell sets `PYTHONPATH`, Python puts its entries ahead of the tool's own packages, so the binaries must run with it unset: `env -u PYTHONPATH kint ...`. `kint doctor` notes it on its last row, `python`: `(PYTHONPATH is set: run with env -u PYTHONPATH)`. `kint setup` keeps it out of every registration it writes: Claude Code gets `-e PYTHONPATH=x -- /usr/bin/env -u PYTHONPATH `, Codex gets `PYTHONPATH = ""` in its env table, and Hermes and OpenClaw launch the server through `/usr/bin/env PYTHONPATH=`. Four more things trip agents up: - **One store, one server.** `kint-server` serves Sibyl's eight tools itself. If Sibyl's own server is also registered, remove it (`claude mcp remove -s user sibyl-memory` for Claude Code); `kint setup claude` prints that reminder. - **User scope only.** `kint setup claude` registers at user scope. A project-scoped registration is done by hand; [Harnesses](/docs/harnesses#claude-code) has the line. - **The tenant.** kint uses `KINT_TENANT`, else `tenant_id` or `account_id` from Sibyl's `credentials.json`, else Sibyl's default. Pin it per harness with `kint setup --env KINT_TENANT=`. The CLI's `--tenant` and `--db` flags go before the command: `kint --tenant kint-demo status`. - **`kint setup` edits the user's harness configs.** Say so before you run it. For Claude Code it removes any existing user-scope `kint` entry and adds it again. For Codex it copies `~/.codex/config.toml` to `config.toml.bak-` first, and does nothing if a `[mcp_servers.kint]` block is already there. ## What you can do for the user - Install kint and run `kint setup `, where target is `all`, `claude`, `codex`, `hermes` or `openclaw`, with `--env KEY=VALUE` as often as needed. - Create this machine's session key: `kint session-key create`. It prints the new address. The key only signs transactions to the contract; it can never decrypt. - Print the payloads the human will sign: `kint canonical-payload --owner 0x...` (the frozen EIP-712 message) and `kint authorize payload --owner 0x...` (the session-key authorization; it prints the next step on stderr). Neither payload is a secret. What the wallet returns when it signs the first one is. - Diagnose: `kint doctor` (exits 1 when any row says FAIL), `kint status`, `memory_status`, and the log at `~/.kint/kint.log`. - Recall with Sibyl's tools, check with `memory_verify`, read old versions with `memory_history`, and push or pull with `memory_push` and `memory_pull`. - Write the recovery-code file again on a connected machine: `kint recovery-code`. Tell the human where it is. Do not open it. On a machine joining a vault that already exists, `kint join --owner 0x...` with exactly one secret (`--signature -` piped from the wallet, `--base-account` which prompts for the passphrase, or `--recovery-code-stdin`) creates the session key, connects, restores and registers the harnesses, and writes nothing to the chain. The human supplies the secret in their own terminal; `writes: off` in its report means the session key still needs funding and authorizing. A failure prints `join stopped at : ` ([One command: kint join](/docs/new-machine#one-command-kint-join)). ## What must go back to the human These steps need a wallet, a secret or money. Hand the human the command and wait. | step | why it is theirs | what they run | |---|---|---| | Sign once per machine (EOA owner) | the signature is the key to their memory | the canonical-payload and `cast wallet sign` pipe into `kint connect --signature -` | | Type the vault passphrase (Base Account owner) | it derives the key | `kint connect --owner 0x... --smart-account` (it prompts), or with `--passphrase-stdin` | | Fund the session key | it pays for every push | send a little ETH on Base to the address `kint session-key create` printed | | Authorize the session key | only the owner can | `kint authorize page` (Base Account, in the browser), or `payload`, sign, then `submit` | | Keep the recovery code | it opens the vault when the wallet and passphrase are gone | copy `~/.kint/RECOVERY-.txt` somewhere that is not this machine | | Rotate the data key | it needs every key that should keep working | `kint rekey`, in their terminal; there is no rekey tool | Some details that change what you tell them: - Fund before `kint authorize submit`: that transaction is sent by the session key itself. `kint doctor` warns until the balance is above 0.00002 ETH and the key is authorized. - `kint authorize payload` stamps a deadline 300 seconds out, so the human signs and submits within five minutes or prints a fresh payload. - An authorization runs for `--days` (default 30). The contract's `canWrite` is true only while the expiry is in the future, so after that pushes fail until they authorize again. `memory_status` shows `session_key.expiry`. - `kint authorize direct` and `kint authorize burn-nonce` read the owner's private key from `KINT_OWNER_KEY`. That is the human's to set in their own shell, never yours to ask for. - On the machine that created the vault, push refuses while the recovery-code file is missing or empty. After a `kint rekey`, the old code no longer opens the vault and a new file is written. ## Never ask for a secret in chat `memory_connect` does accept two secrets. Called with `passphrase` (a Base Account owner) or `recovery_code`, it connects directly, and `owner` is required the first time (without one it answers `OWNER_REQUIRED`). It never accepts the derive signature: that travels on stdin through `kint connect --signature -` (or in a file that `--signature-file` reads and then deletes), never on argv. Do not use the two it accepts. Anything a human types into an agent chat lands in the transcript, and a tool call's arguments are part of it. Call `memory_connect` with no arguments instead. If the machine is not connected, it returns `connected: false` and `human_steps`: three lines, each a label and then a command for the human's own terminal. The commands are: ```sh kint canonical-payload --owner 0x... > kint-canonical.json && cast wallet sign --data --from-file kint-canonical.json --ledger | kint connect --owner 0x... --signature - kint connect --owner 0x... --smart-account --passphrase-stdin kint connect --owner 0x... --recovery-code-stdin ``` The first is for an EOA wallet, the second for a Base Account or smart wallet owner, the third for the day both are lost. The server looks for the enrolment and the cached key on every call, so once the human has connected, your next `memory_status` sees it without a restart. Then call `memory_pull`: the server only pulls on its own at startup. > **Warning.** The derive signature is not a login. The frozen EIP-712 message says so in its `purpose` field: "kint-memory-v1: signing this reveals your memory encryption key. Only sign it in a kint terminal or page you opened yourself." A phished derive signature is a permanent key. Never ask the human to paste it, never read `~/.kint/session.key`, the vault cache or the recovery file into the conversation, and never read the macOS Keychain. ## The tool-call recipe 1. **`memory_status` first.** Read `connected`, `data_key_cached`, `in_step`, `unanchored`, `held`, `chain_head` (an `error` key there means the chain could not be read) and `session_key` (`exists`, `authorized`, `balance_eth`, `expiry`). If `connected` or `data_key_cached` is false, go to `memory_connect` with no arguments. If `held` is not `null`, kint-server is refusing to push: tell the human its `message`. The cached key expires after `KINT_KEY_TTL`, 24 hours by default, and every successful pull and push renews it. 2. **`memory_pull` when `in_step` is false.** The chain head is not what this machine last saw anchored: another machine pushed, or this one has not pulled yet. With unanchored changes here, the pull answers `FORK`, which is the human's call. `memory_verify` refuses while the head is past this machine's mirror, so bring the mirror up to the head first. 3. **Recall with Sibyl's tools.** `memory_search`, `memory_recall` and `memory_get_state` work as they always did. Their results are what the store holds now, not what the chain vouches for. 4. **`memory_verify` before acting on a recalled row.** Pass the query, not a row key: it runs the same search `memory_search` runs (Sibyl's `multi_record_search`, precision gates included) and checks the top `limit` hits (default 5, where `memory_search` defaults to 10). Act only when `decision` is `proceed`, and only on the row the `reason` names. 5. **Write with Sibyl's tools.** kint anchors after the write has returned. While the server runs, a watcher pushes once the store has been quiet for `KINT_QUIET_SECONDS` (default 300), or sooner when the change set is large. To verify a row you just changed, `memory_push` first: an unpushed edit to an anchored row is refused as `drifted`, and that refusal holds every kint-server push until the human runs `kint push`. 6. **`memory_push` when a task is done**, so the next machine sees it now rather than after the quiet period. `memory_push(snapshot=true)` anchors one epoch that holds the whole state. 7. **`memory_history` for "what did this say before".** Pass `tier` (`entity`, `state` or `reference`), `key`, `category` for entities, and optionally `block`. ```text memory_status() memory_search(query="release rule") memory_verify(query="release rule") # act only on decision "proceed" memory_remember(category="rules", name="release-gate", body={...}) memory_push() ``` `memory_history` reads the decrypted epochs cached on this machine. On a machine whose restore stopped at a snapshot epoch, older versions appear only after `memory_pull(full=true)`. Report each version's block as an upper bound: the row existed no later than block N. Never turn it into a wall-clock time. ## Reading a memory_verify result ```text ok true when the check ran; a refusal still has ok: true decision "proceed" or "refuse" reason one sentence: the row and the block it rests on, or why it refused verdict Sibyl's search verdict: code, returned, recovery, tokens, explain hits what the search returned: tier, key, category, snippet, rank, ts checks one per hit: tier, key, category, status, local_leaf, anchored_leaf, anchored_seq, anchored_block, anchored_tx, head_seq, head_block, rows_root, proof, proof_ok, reason refusal_entity "kint_refusal/" when a refusal was written back, else null chain_head the head read for this check: seq, digest, block; null when not read ``` `ok: false` means the check itself failed (`error` holds the exception name), which is not a proceed either. Each check has one of four statuses: | status | meaning | |---|---| | `verified` | the stored text hashes to the leaf anchored on Base and the merkle proof against the anchored `rows_root` passes | | `drifted` | the stored text no longer matches the anchored leaf, or the proof failed; your own edit to an already anchored row reads this way until it is pushed | | `unanchored` | the row is new since the last anchored epoch; nothing on the chain vouches for it yet | | `missing` | the row the search returned is no longer in the store | The decision is `refuse` when Sibyl's verdict is not `ok` (`abstained_on`, `negation_abstain`, `gated`, `empty_store` or `no_match`) or there are no hits, when this machine has no mirror (it never connected, pulled or pushed; the reason says "never pulled or pushed"), when its mirror is incomplete (a skipped epoch, or a root that does not equal the anchored one), when the chain head moved past the mirror (the reason starts "chain moved to seq N at block B, pull first"), when any hit is `drifted` or `missing` (even if another hit verified), or when every hit is `unanchored`. Otherwise it is `proceed`, and the reason names the top verified row, for example `entity rules/release-gate verified against rows_root 390336ff71dbcb85 anchored at epoch 2, block 51081880`. That epoch and block are the head this machine last saw; the epoch that last changed the row is in the check's `anchored_seq` and `anchored_block`. If higher-ranked hits were unanchored, the reason adds a `NOTE` naming them: act on the verified row and push before relying on the others. What to do on refuse: - Do not act on the row. Tell the human, and quote the reason: a drift names the epoch and block that anchored the last good value, and the head epoch and block. - Match the fix to the reason. Unanchored rows: `memory_push`, then verify again. "Never pulled or pushed", "pull again" or "chain moved ... pull first": `memory_pull`, then verify again. A verdict that is not `ok`: there was nothing to act on, so rephrase the query (on `abstained_on`, drop the word in `verdict.tokens[0]`) or ask. - A drifted row is a human decision. If the change was theirs and intended, `kint push` in their terminal anchors it; `memory_push` answers `HELD` while the refused value is in the store. If it was not, `kint pull --discard-local` moves the store aside and restores from the chain, taking every unanchored change into the backup with it; a plain pull reports "up to date" and leaves the drift in place. - The first `drifted` or `missing` hit is written back as a Sibyl entity (category `kint_refusal`, status `refused`, named `---`) so the next fresh session sees it. While the row still holds the refused value, every kint-server push answers `HELD`; the entity itself is anchored by the first push that goes through. The other refusals write nothing. The tool always writes it; `kint verify --no-write` is the CLI's way to skip it. ## Anti-patterns to avoid - **Do NOT** treat `memory_search` or `memory_recall` output as checked. Only `memory_verify` decides. - **Do NOT** push past a refusal or a `HELD`. `memory_push` is held on a refused value, and `kint push` from a shell is not held: it anchors whatever the store holds, including a drifted row, so it is the human's decision. - **Do NOT** run `kint compact --over-skipped` or `kint rekey --over-skipped` yourself. They anchor this machine's store as the whole state on top of an epoch that will never open: the owner's recovery step, after revoking the session key that wrote it. There is deliberately no tool argument for it. - **Do NOT** run `memory_pull(discard_local=true)` or `kint pull --discard-local` without the human. It moves `memory.db` (and its `-wal` and `-shm` files) aside to `.kint-backup-`, so every unanchored change leaves the live store and survives only in that backup. - **Do NOT** suggest `kint pull --rebase`. There is no such flag: `kint pull` takes `--owner`, `--discard-local`, `--force-scan` and `--full`. - **Do NOT** rely on `force_scan`. `memory_pull` and `kint pull` accept it, and v0.3.0's pull never reads it. - **Do NOT** look for a rekey tool. There is deliberately none: the secrets that rotate the key are typed in a terminal. - **Do NOT** tell the user reading costs gas. Restoring and reading never send a transaction. - **Do NOT** start a second push while one runs. The tool answers `BUSY`, and a second process waits up to 30 seconds for the head lock, then fails. - **Do NOT** promise a restore without a key. The wallet signature, a passphrase that has a wrap, or the recovery code opens the vault; kint has no reset. - **Do NOT** set `KINT_KEY_TTL=session` and expect a terminal `kint connect` to reach the server. With `session` the key lives only in the memory of the process that connected. ## Common errors and fixes Tool errors come back as `{"ok": false, "error": ...}`, usually with a `message`, a `hint`, or both. The error is a code (`NOT_CONNECTED`) or, for anything else, the exception's class name (`ConnectError`). | error | where | fix | |---|---|---| | `NOT_CONNECTED`, hint `call memory_connect` | `memory_push`, `memory_pull` | `memory_connect` with no arguments; give the human the `human_steps` | | `OWNER_REQUIRED` | `memory_connect` with a secret | this machine has no owner yet; the human connects in their terminal with `--owner` | | `KEY_EXPIRED`: `the data key on this machine has expired or is missing` | push | the human runs `kint connect` again; nothing was dropped | | `CHAIN_MOVED`: `... another machine pushed.`, hint `call memory_pull first` | push | `memory_pull`; with unanchored changes here it answers `FORK` (below), which is the human's call | | `BUSY`, hint `a push is already running` | `memory_push` | wait, then `memory_status` | | `HELD`: `refusing to anchor: ...`, hint `nothing was anchored and nothing was dropped; memory_verify still refuses the row` | `memory_push` (the watcher and the exit push log the same) | stop and tell the human the `message`: they check the rows it names, then run `kint push` (anchor the value) or `kint pull --discard-local` (restore the anchored value, dropping every unanchored change) in their terminal | | `PUSH_FAILED`: `session key 0x... is not authorized for owner 0x...` | push | the human funds and authorizes it (`kint authorize`); also the message after the expiry passes | | `PUSH_FAILED`: `refusing to push: this machine created the vault but its recovery code file is gone` | push | `kint recovery-code`, the human copies it off the machine, then push | | `PUSH_FAILED`: `refusing to push: this machine could not apply epoch(s) [...] on its last pull` | push | `memory_pull` again; a wrong key needs `kint connect`. An epoch that will never open (a leaked session key wrote it) is the owner's to clear: they revoke the key, then run `kint compact --over-skipped` in their terminal | | `PUSH_FAILED`: `row ... compresses to more than 90 KB on its own and cannot fit one epoch` | push | shrink or split that row in Sibyl; nothing else is blocked | | `PUSH_FAILED`: `the full state (N rows) compresses to ... snapshots are single-epoch` | `memory_push(snapshot=true)` | push without `snapshot`; a restore just walks more epochs | | `KeyError_`: `no session key on this machine` | push | `kint session-key create`, then fund and authorize | | `TimeoutError`: `another kint process holds the head lock for this space` | push, pull | another push or pull is running on this machine; wait and retry | | `FORK`: `the chain moved to seq N and this store has ... rows that were never anchored: a fork` | pull | the human decides: `discard_local` moves the store aside and restores; the unanchored rows are then only in the backup | | `FORK`: `the local store already holds N rows for tenant T and this machine has no record of what the chain last saw` | pull | wrong tenant, or a store from before kint; the human moves it aside or uses `--discard-local` | | `NOT_FRESH`: `cold start needs a second, independent RPC and KINT_RPC_URL_2 resolves to the same endpoint` | pull | the human sets `KINT_RPC_URL_2` to another provider, or accepts one RPC with `KINT_ALLOW_SINGLE_RPC=1` | | `NOT_FRESH`: `could not get a second opinion on the head from ...` | pull | the second RPC did not answer on a cold start; the human fixes `KINT_RPC_URL_2`, or accepts one RPC with `KINT_ALLOW_SINGLE_RPC=1` | | `NOT_FRESH`: `two RPCs disagree on the head` | pull | wait and retry; do not override it | | `NOT_FRESH`: `... stale or lying RPC, refusing` | pull | the RPC served a head older than this machine already saw; use another endpoint | | `PULL_FAILED`: `no data key on this machine` | pull | `memory_connect` with no arguments | | `ConnectError`: `this key (tag ...) does not open the vault anchored under 0x...` | connect | wrong wallet, passphrase or tenant; the recovery code still opens it | | `ConnectError`: `this recovery code does not belong to the vault anchored under this owner` | connect | check the owner and the tenant | | `KintCryptoError`: `recovery code checksum failed (typo?)` | connect | the code was mistyped; the human types it again | | `ChainUnreadable`: `cannot read the chain head` | connect | an RPC problem, never a security verdict; retry or set `KINT_RPC_URL` | | `kint: derive signature recovers to 0x..., expected 0x...; refusing to derive a key from it` | CLI connect | the wallet that signed is not `--owner`, or the payload was for another owner | | `kint: give the derive signature with --signature - (stdin) or --signature-file PATH; never on argv` | CLI connect | pipe the signature in; never put it on the command line | | `kint: --passphrase-stdin and --signature - cannot share stdin` | CLI connect, rekey | use `--passphrase-prompt` or `--signature-file` | | `kint: no owner known on this machine: pass --owner 0x... or run kint connect` | CLI | pass `--owner`, or connect first | When you script the CLI, the exit codes carry the same information. Any `kint:` error exits 2 unless noted. `push`, `compact` and `rekey` exit 3 for a missing data key or session key, 4 when the chain moved, 5 for any other push failure. `pull` exits 4 for a fork, 6 for a freshness refusal, and 5 for any other failed pull (a missing data key included). `kint verify` exits 0 on proceed and 1 on refuse, except with `--json`, which exits 0 either way: read `decision`. A head-lock timeout (`TimeoutError`) or a chain failure during a push (`ChainError`) is not caught by the CLI and ends in a Python traceback. ## Where state lives Everything kint keeps is under `KINT_HOME` (default `~/.kint`), mode 0700. Every state file is written 0600; `kint.log` is created with the default file mode. `` is the first 16 hex characters of the space id, `keccak256("kint-space-v1" || tenant_id)`; `memory_status` returns the full id as `space`. ```text ~/.kint/ ├── session.key this machine's session key, a V3 scrypt keystore ├── session.key.old the previous key, after kint session-key rotate ├── local.secret the local passphrase, only when no Keychain is used ├── enrol-.json the enrolment: owner, tenant, space, key tags ├── vault-.aes the data key, encrypted at rest, with an expiry ├── wraps-.json the wrapped data keys, one per unlock path ├── RECOVERY-.txt the recovery code: copy it off the machine ├── mirror-.json what this machine last saw anchored: rows, leaves, anchored root ├── watermark-.json the highest head this machine has seen; never decreases ├── head-.lock one pusher or puller per space per machine ├── kint.log kint's log (stderr gets the same lines) └── epochs/ └── / ├── 00000001.bin the ciphertext, as sent to Base ├── 00000001.json the decrypted plaintext, which memory_history reads └── 00000001.meta.json seq, digest, prev, block, tx, bucket, rows_root, writer ``` The Sibyl store itself is at `SIBYL_MEMORY_DB` (default `~/.sibyl-memory/memory.db`), and it is plaintext, as it always was. On macOS the local passphrase that protects `session.key` and the vault cache is kept in the Keychain item `dev.kint-session-key` unless `KINT_SESSION_PASSPHRASE` is set (a server) or `KINT_NO_KEYCHAIN=1`. The full list of variables is in [Configuration](/docs/configuration#every-variable). Read [Built on Sibyl Memory](/docs/sibyl) next. Source: [`src/kint/server.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/server.py), [`src/kint/cli.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/cli.py), [`src/kint/connect.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/connect.py), [`src/kint/push.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/push.py), [`src/kint/pull.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/pull.py), [`src/kint/keys.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/keys.py), [`src/kint/verify.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/verify.py), [`src/kint/paths.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/paths.py). --- ## Built on Sibyl Memory > Source: https://github.com/s0nderlabs/kint/blob/main/src/kint/server.py Sibyl decides what the agent remembers; kint decides where that memory lives and who can read it. This page names every seam between the two, and what breaks if you take Sibyl away. ## Two jobs, two owners | question | answered by | how | |---|---|---| | what the agent remembers, and in which tier | Sibyl | their eight tools, unmodified | | which stored row answers a query, and why a search came back empty | Sibyl | `multi_record_search` and the typed verdict from `verdicts.py` | | how much a free account may store | Sibyl | `CapGate`, 5 MiB (5,242,880 bytes); kint adds its own bytes to the count | | where the memory lives once it leaves the machine | kint | encrypted epochs in Base calldata under EpochAnchor | | who can read it | kint | a data key that only the owner's wallet signature, vault passphrase or recovery code opens | | whether a recalled row is still what the chain vouches for | kint | `memory_verify` | | what a row held at an earlier block | kint | `memory_history` | kint never changes how Sibyl ranks, gates or stores a row. Rows enter Sibyl's store through Sibyl's own write methods, whether the agent wrote them, a restore replayed them, or `memory_verify` wrote a refusal. kint's direct reads of the SQLite file open it read-only (`mode=ro`). The one time kint touches the file itself is `kint pull --discard-local` (or `memory_pull` with `discard_local`), which moves `memory.db`, `memory.db-wal` and `memory.db-shm` aside to a `.kint-backup-` name before it restores. Nothing is deleted. ## kint-server is their server plus six tools ```python from sibyl_memory_mcp.server import build_server def build_kint_server(): mcp = build_server() # their eight tools, exactly as shipped ... @mcp.tool() def memory_status() -> dict[str, Any]: ... ``` `build_kint_server()` calls Sibyl's `build_server()`, which returns their FastMCP server with their eight tools registered, then adds six `@mcp.tool()` functions to the same object. No line of Sibyl is edited and none of their functions is replaced. kint puts one thing into their module, the client described below, and wraps five write methods on that one client object so it can tell a write through the tools from an edit made behind them ([The writes kint watches](/docs/sibyl#the-writes-kint-watches)). | tool | from | what it does | |---|---|---| | `memory_remember` | Sibyl | store an entity | | `memory_recall` | Sibyl | read an entity by category and name | | `memory_search` | Sibyl | FTS5 search across entities, state, reference and journal | | `memory_list` | Sibyl | list entities, optionally filtered by category | | `memory_forget` | Sibyl | archive an entity (moved to `archived_entities`, out of recall, list and search) | | `memory_set_state` | Sibyl | write a HOT-tier state document | | `memory_get_state` | Sibyl | read a HOT-tier state document | | `memory_record_event` | Sibyl | append a COLD-tier journal event | | `memory_status` | kint | owner, space, chain head, mirror, unanchored changes, session key, whether a push is held, cap accounting | | `memory_connect` | kint | connect this machine to the vault, or return the terminal steps a human runs | | `memory_pull` | kint | restore or refresh the store from Base | | `memory_push` | kint | anchor unanchored changes now; `snapshot` anchors the whole state | | `memory_verify` | kint | search through Sibyl, check every hit against its anchored leaf and the chain head, decide | | `memory_history` | kint | every anchored version of a row, each with a block-height upper bound | `tests/test_mcp_stdio.py` starts `python -m kint.server` over real stdio, asserts that all fourteen names are listed, and calls Sibyl's `memory_remember` and `memory_search`, then five of kint's tools, through it. The full parameter list for every tool is in [MCP tools](/docs/tools). `kint setup` registers `kint-server` with Claude Code, Codex, Hermes and OpenClaw and leaves any `sibyl-memory-mcp` registration alone. For Claude Code it prints a reminder to run `claude mcp remove -s user sibyl-memory` if Sibyl's own server is also registered (one store, one server). A second server over the same store would build its own client, so its copy of the eight tools would not count kint's bytes against the cap, and its writes would look to kint like edits made behind the tools. ## One client, one cap `store.open_client()` builds the `MemoryClient` "exactly the way Sibyl's server does, plus the volunteered cap". It reads the same credentials (Sibyl's `_load_credentials()`), takes the tier from them (default `free`), and keeps Sibyl's `tier_cache.json` beside the store. The store is `SIBYL_MEMORY_DB`, or `~/.sibyl-memory/memory.db` when that is unset. The tenant is `KINT_TENANT`, then the credentials' `tenant_id`, then their `account_id`, then Sibyl's `DEFAULT_TENANT`. `store.seed_sibyl_server_cache()` then writes that client into Sibyl's module-level `_client_cache`, under Sibyl's own `_client_lock`, together with the current `credentials.json` mtime and whether the file exists. Sibyl's `_open_client()` rebuilds its client only when there is none, or when that mtime or existence changed, so their eight tools find kint's client and use it. If `credentials.json` changes while the server runs (Sibyl's trigger for `sibyl upgrade`), Sibyl rebuilds its own client, with a gate that counts only Sibyl's stores, and their tools use it until a kint-server pull that finds new epochs builds kint's client again and reseeds the cache, or until a restart. kint's own tools keep kint's client. The rebuilt client is not one kint watches, so while their tools use it, a write through them to a row that is already anchored reads as a change made behind them, and kint-server holds its own pushes until you run `kint push` from a terminal or restart the server. More on the client in [How it works](/docs/how-it-works#one-client). The cap is volunteered through the size function Sibyl's gate calls. `MemoryClient` takes a `cap_gate`, and `CapGate` takes a `db_size_fn`: ```python def volunteered_size_fn(db_path: Path): def fn() -> int: return aggregate_db_size(db_path) + paths.footprint_bytes() return fn gate = CapGate(..., db_size_fn=volunteered_size_fn(db_path), local_tier_hint=tier, ...) MemoryClient(storage, ..., cap_gate=gate, ...) ``` `aggregate_db_size` is Sibyl's own count across every store it resolves on the machine; `footprint_bytes()` is every file under `KINT_HOME` (default `~/.kint`). Before each write, Sibyl's gate checks that size plus the size of the proposed write. The cap (`FREE_TIER_CAP_BYTES`, 5 MiB), the tier decision and the refusal stay Sibyl's; kint changes only what the size counts. A write that would take the sum past the cap meets Sibyl's gate the way Sibyl's own overflow would, and that includes rows a pull replays. Inside the write transaction Sibyl re-checks the store's own absolute size, which leaves kint's bytes out unless that measurement fails and the gate falls back to the size function. The size function can run under Sibyl's `BEGIN IMMEDIATE` write lock (`archive_entity` checks the cap inside its transaction, and so does that fallback), which is why kint's half is a local file walk: nothing in `store.py` touches the chain, an RPC or a signer. The last four lines of `kint status`: ```text cap accounting (the same 5 MiB free cap Sibyl enforces): sibyl stores kint state (mirror, epoch cache, keys; volunteered) enforced total of 5.00 MB ``` Sizes print in KB with one decimal below 1 MiB, and in MB with two decimals from 1 MiB up. `memory_status` returns the same numbers as `cap`: `sibyl_bytes`, `kint_bytes`, `volunteered_total`, `cap_bytes` and `db_path`. On an activated free-tier account, Sibyl's own `sibyl status` reports one store's WAL-inclusive size as a share of the free cap, and lists every store it resolves when there is more than one. It knows nothing about `~/.kint`, so its percentage leaves kint's bytes out. The number the gate checks before writes made through kint's client is the `enforced total` line. `KINT_HOME` is outside every store `aggregate_db_size` sizes, so kint's bytes enter the count once, through the size function, and `kint doctor` fails its `kint home placement` check when `KINT_HOME` is inside `~/.sibyl-memory` ([why](/docs/how-it-works#why-kint-sits-outside-sibyls-paths)). ## The writes kint watches On the one client, `kint-server` wraps five write methods: `set_entity`, `set_state`, `set_reference`, `archive_entity` and `delete_entity`. Each wrapper calls Sibyl's method first, unchanged, and returns its result. Only after it returns does kint re-read that one row read-only and note its leaf as the value the tool path wrote; a failure in that bookkeeping never reaches the caller. Their eight tools, kint's own tools and a pull's replay all write through this client. Those notes are what let kint tell a write through the tools from an edit made behind them. The watcher, the exit hook and `memory_push` check them before anchoring. A row whose stored text differs from the leaf this machine last saw anchored holds the push when either: - it changed, or vanished, while the server was running, with no write through the tools to account for it (edited straight in SQLite, say); or - a `kint_refusal` entity names exactly the value it holds now. A held push returns error `HELD` with nothing anchored and nothing dropped, `memory_status` carries the message under `held`, and the watcher waits for the store to change again instead of retrying. The notes reset to the store's current leaves at start and after every successful pull and push, so a row changed before the server started is not held on that ground alone. `write_event` is not wrapped: a journal event has no key to overwrite, so an edited one shows up as a new row plus a vanished anchored one, and the vanished one holds the push. `kint push` from a terminal never runs this check. ## What kint relies on inside Sibyl kint uses a short, fixed list of Sibyl's code. Everything else in Sibyl is theirs to change. | Sibyl | kint uses it for | kint file | |---|---|---| | `build_server()` in `sibyl_memory_mcp.server` | serving their eight tools | `server.py` | | `_client_cache`, `_client_lock`, `_credentials_mtime()`, `DEFAULT_CRED_PATH`, `_load_credentials()` in `sibyl_memory_mcp.server` | seeding the one client; reading the same credentials | `store.py` | | `MemoryClient`, `DEFAULT_TENANT`, `storage.Storage` | building the client | `store.py` | | `CapGate`, `TierCache`, `aggregate_db_size`, `FREE_TIER_CAP_BYTES` in `sibyl_memory_client._capcheck` | volunteering the cap; printing the cap numbers | `store.py` | | `multi_record_search` in `sibyl_memory_client.multi_record`, `verdicts.VerdictCode`, `verdicts.explain` | finding the row through every precision gate and keying the decision on the verdict | `verify.py` | | `set_entity`, `set_state`, `set_reference`, `write_event`, `delete_entity` | replaying rows and deletions; writing the refusal entity | `restore.py`, `verify.py` | | `set_entity`, `set_state`, `set_reference`, `archive_entity`, `delete_entity` on the shared client | noting which rows the tool path wrote, for the hold | `server.py` | | `storage.loads`, and the TEXT `storage.dumps` wrote | decoding stored TEXT for replay; hashing it as stored | `restore.py`, `canon.py` | | the `entities`, `state_documents`, `reference_documents` and `journal_events` tables | the read-only export, and the re-read of one row by key | `export.py` | > **Note.** `MemoryClient`, `Storage`, `DEFAULT_TENANT`, `CapGate`, `TierCache`, `FREE_TIER_CAP_BYTES`, `VerdictCode` and `explain` are exported by `sibyl_memory_client` itself (kint imports the cap names from `_capcheck`, where they are defined). The rest is not published as API: the server's `_client_cache`, `_client_lock`, `_credentials_mtime()`, `_load_credentials()` and `DEFAULT_CRED_PATH`, `aggregate_db_size`, `multi_record_search` (not in the package's `__all__`; kint imports it from `sibyl_memory_client.multi_record`, where Sibyl's own `memory_search` imports it from), and the table and column names the export reads. That is why kint pins `sibyl-memory-client>=0.8.1,<0.9` and `sibyl-memory-mcp>=0.2.1,<0.3`: an install of kint never resolves a Sibyl release outside those ranges. ### Search and the typed verdict `memory_verify` calls `multi_record_search(client, query, limit=limit)` (`limit` defaults to 5) on the same client that serves Sibyl's tools. It is the call Sibyl's `memory_search` makes when it is given no `tiers`: a two-stage search that retrieves candidates with `client.search` (Sibyl's cross-tier FTS5 search over `entities_fts`, `state_documents_fts`, `reference_documents_fts` and `journal_events_fts`, with the folded-trigram shadow table as a fallback), then drops the ones Sibyl's precision gates reject. It returns a `SearchResults`: a list of hits in the shape `client.search` returns, with one typed `verdict` attached. Six verdict codes are reachable. `ok` comes with hits; the other five name why a result is empty: | code | what Sibyl found | |---|---| | `ok` | ranked hits that cleared every gate | | `abstained_on` | a content word in the query has no support anywhere in the store | | `negation_abstain` | a negation word was dropped from the query, and Sibyl's negation policy abstains | | `gated` | candidates were found, then dropped by a scoring gate | | `empty_store` | the tenant has no searchable row in any tier | | `no_match` | the query ran with every gate armed and nothing matched | kint keys on the code: anything but `ok`, or zero hits, refuses with `no ranked candidate to act on: Sibyl's verdict is `. Sibyl's `memory_search` given `tiers` calls `client.search` directly and skips those gates, so a tier-filtered search can return a row that `memory_verify` then refuses. The rest of the decision is in [Verify before acting](/docs/verify#sibyl-finds-the-row). After the verdict, verify checks the chain head when its caller could read it. `memory_verify` reads the head outside every Sibyl write and passes it in; `kint verify` does the same unless `KINT_OFFLINE=1`. A head that differs from this machine's mirror refuses with `chain moved to seq N at block B, pull first`, because another machine may have superseded the row. When the head cannot be read, verify checks against the local mirror only and says so, in `kint.log` for the tool and on stderr for the CLI. A refusal over a drifted row goes back into Sibyl as an entity: `client.set_entity("kint_refusal", name, {...}, status="refused")`, with the name `---` cut to 200 characters. A fresh session finds it through Sibyl's own tools, and kint-server reads it back: while the row still holds the value a refusal named, every push the server makes itself is held. ### The four write methods A restore replays rows through the SDK, never through SQL (`restore.py`): ```python def write_row(client, row: Row) -> None: if row.tier == "entity": client.set_entity(row.category, row.key, loads(row.body), status=row.status) elif row.tier == "state": client.set_state(row.key, loads(row.body)) elif row.tier == "reference": client.set_reference(row.key, row.body if row.body is not None else "", metadata=loads(row.meta) if row.meta is not None else None) elif row.tier == "journal": client.write_event( evaluated=loads(row.evaluated), acted=loads(row.acted), forward=loads(row.forward), extra=loads(row.extra), ts=row.ts, ) ``` Deletions replay first, then rows in the order the export read them (rowid order on the machine that pushed). Only entities have an SDK delete, so a deletion replays as `delete_entity` and any other tier is reported as `no SDK delete for this tier`. After the replay every row is exported again and its leaf compared with the anchored one; a row that differs is reported as `stored text differs from the anchored leaf after replay`, and one that did not come back as `row missing after replay`. Pull records either as a warning on that epoch, never as a silent success. When the epoch is a snapshot, only rows whose stored text differs are replayed, because writing a journal event again would duplicate it (an event has no key to overwrite). Going through the SDK is what makes the restored store answerable: Sibyl's indexes, its shadow table and its cap gate see a replayed row the way they see any write. The row ids (uuids) and the entity, state and reference timestamps regenerate on replay. The journal `ts` survives, because `write_event` takes it as an argument. ### The exact stored TEXT Sibyl's `storage.dumps()` turns every JSON value the SDK stores into TEXT (entity and state bodies, journal payloads, reference metadata, and a reference body passed as a dict or list) with `json.dumps(payload, separators=(",", ":"), ensure_ascii=False)`. It does not sort keys; its docstring says `sort_keys=False`, to preserve insertion order. A reference body passed as a string is stored as given. kint's leaf hashes the stored TEXT column exactly as it sits in SQLite, read by SQL and never re-serialised. In `canon.py`'s words, re-serialising "would refuse every row on device two". On replay, `loads()` hands the SDK a value with its keys in stored order, and `dumps()` writes it back as the same TEXT (a reference body goes back as the stored string itself), so the leaf a new machine computes is the leaf the chain anchored. The leaf leaves out the timestamps Sibyl regenerates (entity, state and reference `updated_at`) and includes the journal `ts`. Leaves are ordered by their canonical key, never by rowid, so every machine computes the same root over the same content. ## What the export covers `export.py` reads the four tiers the SDK writes, for one tenant, by raw SQL in rowid order, over a read-only connection. The SDK has no enumeration for state or reference documents and no rowid ordering anywhere (`list_entities` orders by `updated_at DESC` and clamps), so the export reads the tables directly. The restore is the half that goes through the SDK. | tier | table | columns read | key | |---|---|---|---| | entity | `entities` | `rowid, category, name, status, body, updated_at` | `name`, with `category` | | state | `state_documents` | `rowid, document_key, body, updated_at` | `document_key` | | reference | `reference_documents` | `rowid, doc_key, body, metadata, updated_at` | `doc_key` | | journal | `journal_events` | `rowid, id, ts, evaluated, acted, forward, extra` | a content key over `ts, evaluated, acted, forward, extra`; identical events get `:1`, `:2` in rowid order | `read_row()` re-reads one row by the key Sibyl's search returned: an entity by tenant, category and name, a state or reference document by its key, a journal event by its Sibyl event id (and recomputes the same ordinal the export assigns). That is the text `memory_verify` hashes. ### What it does not cover - **Other tables.** `entity_relations`, `revenue_events`, `error_events`, `archived_entities`, `flagged_actors`, `skill_proposals` and `learning_runs` are not exported. - **The archive.** `memory_forget` moves an entity from `entities` into `archived_entities` with its reason. kint sees the entity leave `entities` and anchors a deletion, so the anchored state is right, but the archived copy is not carried: a restore replays the forget as a plain delete, and the restored machine has the entity gone and no archive row. - **Derived indexes.** The FTS5 indexes and the shadow table are not exported. Sibyl's own insert triggers fill them as the replay writes rows. - **Other tenants.** Every query filters on the one tenant kint serves. - **Row ids and write timestamps.** They regenerate on replay, as above. - **Account files.** `credentials.json` and `tier_cache.json` are not rows and are not exported. ## Delete the Sibyl Memory layer and what breaks In the README's words: The decision beat reaches the row through Sibyl's `memory_search` (four FTS5 indexes plus the shadow fallback) and keys on the typed verdict from `verdicts.py`. Without Sibyl there is no ranked candidate and no verdict to key on: the epoch is a decrypted blob with no query surface, so a fresh session neither refuses nor proceeds. Base holds the ciphertext; Sibyl is what turns it back into an answerable store. See `docs/judge.md` for the claims table with file and line. Precisely, kint calls `multi_record_search`, the call `memory_search` makes, on the same client. Taken one call at a time: - Remove the `multi_record_search(...)` call in `src/kint/verify.py` and there is no ranked candidate and no verdict: the decision cannot be made. - Remove `sibyl_memory_mcp.build_server()` from `src/kint/server.py` and no harness can read or write memory at all. - Remove the four SDK write calls in `src/kint/restore.py` and a restored machine has an encrypted blob and an empty store. ## Prior work - **Sibyl Sovereign** (Sibyl Labs, in development): a cryptographic seal on every critical file, re-checked before every action, fail-closed, with an append-only audit trail. kint applies that mechanism one tier down, to the memory store itself, on Base. Same idea, theirs first. - **anima** (s0nderlabs, 0G): anchors a keystore root hash on chain, roots only. On 0G the storage primitive is native; on Base a root alone is a receipt for data you still have to store somewhere, which is why kint puts the ciphertext itself in calldata. - **ERC-8350** (Agent Memory State Registry, draft): the vocabulary of a per-space linear chain of committed deltas describes kint's epochs exactly. Reference shape only, not implemented. - The verify-a-local-artifact-against-an-anchor pattern exists in several hackathon entries. Restoring the memory itself from the chain, and showing a superseded belief, do not. ## The claims table [`docs/judge.md`](https://github.com/s0nderlabs/kint/blob/main/docs/judge.md) pairs kint's claims with the file and line that implement them, against Sibyl Memory at revision 761bfc6 (client 0.8.1, mcp 0.2.1), and lists what is not built. `pytest -q` and `forge test` run the tests that pin the claims; `tests/test_audit_fixes.py` pins the hold, the verdict and chain-head refusals, and the pull past an epoch that never opens. `scripts/gen_judge.py` regenerates the table by searching each file for the line that implements each claim. Read [Limits and threat model](/docs/limits) next. Source: [`src/kint/server.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/server.py), [`src/kint/store.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/store.py), [`src/kint/export.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/export.py), [`src/kint/restore.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/restore.py), [`src/kint/verify.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/verify.py), [`src/kint/canon.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/canon.py), [`src/kint/paths.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/paths.py), [`tests/test_audit_fixes.py`](https://github.com/s0nderlabs/kint/blob/main/tests/test_audit_fixes.py), [`README.md`](https://github.com/s0nderlabs/kint/blob/main/README.md), [`docs/judge.md`](https://github.com/s0nderlabs/kint/blob/main/docs/judge.md), and Sibyl's `sibyl_memory_mcp/server.py`, `sibyl_memory_client/_capcheck.py`, `client.py`, `multi_record.py`, `schema.sql`, `shadow.py`, `storage.py` and `verdicts.py` in [Sibyl-Memory](https://github.com/Sibyl-Labs/Sibyl-Memory). --- ## Limits and threat model > Source: https://github.com/s0nderlabs/kint/blob/main/README.md Every limit kint ships with, then one attacker at a time: what each one gets, what the code or the contract still refuses, and what anyone reading Base can see. ## The honest limits These are the README's limits, each checked against the code, plus the few the code adds. Paths below use `~/.kint`, the default of `KINT_HOME`; `` is the first 16 hex characters of the space id. ### What comes back on a restore - Content-exact and search-exact across the four tiers the SDK writes: entities, state, reference and journal. `tests/test_export_restore.py` replays a store into a second one and compares every body, status and metadata, then Sibyl's own search results and their verdict for a set of queries. Uuids and the `updated_at` of entities, state and reference documents regenerate on replay; the journal `ts` survives. The export reads those tables by SQL in rowid order (`src/kint/export.py`); the restore goes through the SDK's write methods (`src/kint/restore.py`). - Those are four of the eleven data tables in Sibyl's schema (not counting its schema-version table and search indexes). The other seven (`entity_relations`, `revenue_events`, `error_events`, `archived_entities`, `flagged_actors`, `skill_proposals`, `learning_runs`) are not exported. Sibyl's `memory_forget` moves an entity into `archived_entities`, so a restored machine has the deletion, not the archive. - Deletions replay for entities only, because `delete_entity` is the SDK's only delete. Sibyl never deletes a state, reference or journal row itself; one removed behind its back is reported by the pull as a warning, not removed. - The ciphertext lives in transaction calldata. Reading it back needs a node that keeps its transaction index, and Base's L1 data availability is the blob retention window. kint keeps its own copy of every epoch it pushes or fetches under `~/.kint/epochs//` and checks a cached blob's keccak against the Epoch event before trusting it. ### What a restore costs - A cold start stops at the newest snapshot epoch (`kint compact` writes one), so restore cost is bounded by the size of the memory, not its history. `kint pull --full` walks past snapshots for the older versions, as far back as the newest key rotation: epochs sealed under a retired key stay closed to a machine that only holds the current one. On a machine already up to date, `--full` walks from the head to the first epoch and caches every one it has not decrypted, including the diff epochs a warm pull jumped on its way to a snapshot, so `memory_history` sees their versions. - One epoch carries at most 90 KB of measured compressed change (`MAX_COMPRESSED_PER_EPOCH` in `src/kint/push.py`, the gzip size plus 512 bytes of headroom; the largest bucket is 98,304 bytes). A bigger change set is split across epochs, and a single row that does not fit on its own is refused by name. A snapshot is always one epoch, so once the whole state compresses past that limit, `kint compact` and `kint rekey` refuse. Ordinary pushes carry on. ### Keys and what leaks - Base holds ciphertext, but the size bucket is public, and the README counts the row count as public too. No cleartext field carries it (`n_rows` is inside the ciphertext), but the bucket and the cadence of epochs say roughly how much memory there is, so plan as if it were public. The full list is under [What is public on Base](/docs/limits#what-is-public-on-base). - A vault passphrase can be guessed offline. Every header carries each wrap's 16-byte key tag, so anyone reading Base can test guesses against it at the cost of one scrypt per guess. For a Base Account owner that passphrase is the whole lock: make it long and random. - A phished derive signature is a permanent key. The EIP-712 message says so in the one field every wallet renders. kint never accepts that signature as a login and never sends it anywhere. See [A phished derive signature](/docs/limits#a-phished-derive-signature). - A space has one data key at a time. `kint rekey` rotates it: a new key, new wraps, one snapshot epoch under the new key, a new recovery code. Epochs sealed before the rotation stay readable to whoever held the old key; that is a property of any ledger, not something a rotation can undo. - The cached data key lasts `KINT_KEY_TTL`, 24 hours by default. kint-server renews it after every successful pull and push, so a server in use keeps it; one that goes a whole TTL without a successful pull or push (no writes, or every push held) still lapses. Once it lapses, kint-server skips the pull at its next start and every automatic push until `kint connect` runs again; `memory_verify` and `memory_history` keep working. Set `KINT_KEY_TTL=30d` on a machine that stays up, in the harness registration as well as the terminal. - `kint authorize page` handles no secret, but it loads the Base Account SDK from esm.sh (`@base-org/account@2.5.10`), so that CDN is trusted for the one `setSessionKey` transaction the page asks the account to send. ### Writers - One wallet owns one memory per Sibyl tenant; many machines may write under it, one at a time. Two machines writing at once is a fork: kint refuses and tells you (`kint pull --discard-local`). - Two machines that both connect before the first push each create their own vault. The second opens nothing until it runs `kint connect` again, which adopts the vault in the head epoch (its pull is also a fork if its store holds rows the chain never saw). The recovery code it wrote first opens nothing; `kint recovery-code` writes the right one. - kint-server holds a push rather than anchor drift. Every push it makes (the watcher's, the exit push, `memory_push`) answers `HELD`, anchoring nothing and dropping nothing, when a row the chain already vouches for changed behind Sibyl's tools while the server ran, or when `memory_verify` already refused exactly the value the row holds. The first ground only sees changes made while the server runs and since its last successful pull or push. A row edited while no kint-server was running, or before one of those, is anchored by the next automatic push unless `memory_verify` refused it first; `memory_history` still shows the edit as a new version. `KINT_NO_WATCHER=1` turns the automatic push off where that matters (the exit push still runs, checked the same way). - A hold stops the whole push, so every other unanchored change waits with it until the human runs `kint push`, which is never held, or `kint pull --discard-local`, which restores the anchored state and moves every unanchored change to a backup. - Verifying your own unpushed edit holds the server. An edit to an anchored row reads as `drifted` until it is pushed, so `memory_verify` refuses it and writes a `kint_refusal` naming that value; from then on every kint-server push answers `HELD` until the human decides. Push an edit before you verify it. - A second writer on the same store while kint-server runs (another harness's server, an SDK-direct process) makes its updates to anchored rows read as changes behind Sibyl's tools, and the server holds its pushes until those rows are anchored. - A session key can append any bytes as the next epoch. A pull walks past an epoch it cannot open when a later snapshot in the same walk opens; otherwise it stops there, and push and verify refuse on that machine. The owner revokes the key, then runs `kint compact --over-skipped` (or `kint rekey --over-skipped`) on a machine whose last pull stopped there: that machine's store is anchored as one snapshot chained on the chain head, and every later pull stops at it. See [A stolen session key](/docs/limits#a-stolen-session-key). - `kint connect` and `kint rekey` read the key wraps from the head epoch, stepping back up to 32 epochs over one whose header does not parse. An epoch forged with a header that parses (the parser checks only the version byte and the length) still stops them: the key or the recovery code is refused. From a machine whose data key is still cached, `kint pull` then `kint compact --over-skipped` makes the head readable again. - A push whose transaction lands but whose confirmation wait fails (an RPC timeout, say) leaves this machine's mirror behind the chain: the next push reports that another machine pushed, and the next pull a fork. The Epoch event's `writer` on Basescan is then this machine's session key; `kint pull --discard-local` takes the same rows back from the chain, and anything written since stays in the backup. - The exit push is best effort: a harness that kills the server soon after closing it ends the push before its transaction lands (`src/kint/server.py` notes that Claude Code allows about 500 ms). The next kint-server session on that machine starts with those rows marked and pushes them after its quiet period; `kint push` anchors them at once. ### Install kint is not on PyPI yet. Install from the tagged release: `uv tool install git+https://github.com/s0nderlabs/kint@v0.3.0` (Python 3.10+). ## Who has what Seven actors, each set against the code or the contract that stops it. The sections after the table carry the detail. | Who | Can | Cannot | Enforced in | |---|---|---|---| | Someone reading Base | Read every epoch's ciphertext and cleartext header, every Epoch event, each head, and which session keys may write until when; confirm a guessed tenant name; test passphrase guesses offline | Read a row, a key or a category; get an altered epoch accepted | `crypto.py` `seal_epoch`, `epoch_aad`; `pull.py` `_open_one` | | A phished derive signature | Derive the wallet's KEK for every space that owner has, and so the data key from every epoch carrying a signature wrap, past and future | Send a transaction or authorize a key; open a vault salted with `--passphrase-prompt` without that passphrase; open epochs sealed after a rotation that dropped its wrap | `crypto.py` `derive_kek_from_signature`; `connect.py` `rekey` | | A stolen session key | Append epochs under the owner until it expires or is revoked, each naming it as the writer; with one that does not open, stop pushes and verify on every machine whose pull stops at it, until the owner anchors a snapshot over it | Decrypt anything; rewrite or remove an anchored epoch; extend its own expiry or authorize another key; make a cold start stop at a forged snapshot | `EpochAnchor.sol` `canWrite`, `push`; `pull.py`; `push.py` `override_skipped` | | A compromised machine or VPS | Everything that machine can do: read the whole memory in the clear, seal epochs that open and verify, edit the store and anchor the edit with `kint push` | Derive a KEK, unless you connect or rekey there again; authorize a session key, unless the owner key is there; open epochs sealed after a rotation done elsewhere | `keys.py`; `connect.py` `read_secret`, `rekey` | | A malicious or lagging RPC | Withhold or delay data; learn which owner and space this machine reads; on a single-RPC cold start, choose which head counts as newest | Forge an epoch that opens; roll back a machine that has pulled before; disagree with the second operator on a cold start unnoticed | `pull.py` `_check_freshness`, `_fetch_blob_uncached`, `_open_one` | | A second machine writing at once | Take the head if its push lands first | Overwrite the other machine's epoch; make kint merge blindly | `EpochAnchor.sol` `push`; `push.py`; `pull.py` | | You, with the owner key lost | Keep writing with existing session keys until they expire; open the memory with the recovery code or a passphrase wrap | Authorize or extend a session key; rotate the owner; rotate the data key without a passphrase wrap | `EpochAnchor.sol` `canWrite`, `setSessionKey`, `setSessionKeyBySig`; `connect.py` `rekey` | ### Someone reading Base Anyone with an RPC reads every `push` calldata and every `Epoch` event. That is the design: the ciphertext has to live where a wiped machine can find it. `seal_epoch` in `src/kint/crypto.py` compresses with gzip, pads to a size bucket and encrypts with AES-256-GCM under a random 32-byte data key, with an AAD of `keccak256(abi.encode(chainId, owner, space, seq, prev, lenBucket, rows_root, dek_id))`. The digest the contract stores is the keccak of the whole blob, header included, and the AAD ties the ciphertext to one owner, space, position and chain, so an epoch lifted anywhere else fails to open. The attack on the keys that a reader can run offline is a passphrase guess. A passphrase wrap's key is HKDF over `scrypt(passphrase, salt = owner address, n = 2^17, r = 8, p = 1)`, and the header publishes each wrap's tag, the first 16 bytes of an HMAC under that key. A guess that reproduces the tag is the passphrase. scrypt makes each guess slow; it does not make a short or common passphrase safe. The passphrase `--passphrase-prompt` mixes into a wallet key falls the same way, but only to someone who also holds the signature. ### A phished derive signature The vault message carries no nonce, no space and no contract: `KintVault(owner, purpose)` under the domain `kint`, version `1`, chain id 8453. The KEK is HKDF-SHA256 over the signature's `r || s` (low-S normalised, so a malleated twin derives the same key) with `info = "kint-kek-v1" || owner || space`. One signature therefore derives the KEK of every space that wallet owns, on any machine, for good, and every epoch header carries its wraps in the clear. The docstring of `src/kint/crypto.py` says it plainly: permanent, retroactive over all public calldata, unrevocable, covering every space of that wallet. Wallets render the warning in `purpose`: "kint-memory-v1: signing this reveals your memory encryption key. Only sign it in a kint terminal or page you opened yourself." Before deriving anything, kint checks that the signature recovers to the owner and refuses otherwise. The signature cannot authorize a session key either: that authorization is a different EIP-712 type under a different domain (`kint EpochAnchor`, pinned to the contract address), so the digests never match. Two things narrow the damage. A vault whose signature wrap was made with `kint connect --passphrase-prompt` salts the wallet key with a passphrase, so the signature alone opens nothing. And a rotation can leave the signature wrap behind. Add a passphrase wrap with `kint connect --add-passphrase`, let one epoch carry it onto the chain (`kint compact` writes one even when nothing changed), then run `kint rekey --smart-account --drop-missing` and type that passphrase at the prompt. A passphrase wrap is derived the same way whichever flag created it, so it is carried over and the signature wrap is dropped. The phished signature then opens every epoch up to the rotation and nothing after it, and the passphrase becomes the lock described above (connect with `--smart-account` from then on). ### A stolen session key The session key is a plain EOA in a V3 scrypt keystore at `~/.kint/session.key` (mode 0600), unlocked by a local secret: `KINT_SESSION_PASSPHRASE` when set, else the macOS Keychain item `dev.kint-session-key`, else `~/.kint/local.secret`. It signs `push` and `setSessionKeyBySig` transactions and holds nothing that decrypts. On chain, `canWrite(owner, writer)` is true for the owner or for a key whose expiry is still in the future (`kint authorize` sets 30 days unless you pass `--days`). `push` only appends: it must name the current head as `prev` or it reverts with `StaleHead`, and the contract keeps only the new digest, seq and block. The thief cannot extend its own expiry: `setSessionKey` writes under the caller's own address, and `setSessionKeyBySig` needs the owner's signature over the current `authNonce`. What it can do is append epochs until it expires or is revoked, each naming it as the `writer` in the Epoch event. Those epochs do not open, since it has no data key. A pull walks past one when a later snapshot in the same walk opens, because a snapshot's rows are the whole state; the report names the epoch and the snapshot that superseded it. Otherwise the pull stops there, and push and verify refuse on that machine. Only an epoch this machine cannot open is walked past: one the RPC would not serve, or one that opened and disagrees with the chain, still stops the pull, so the next pull retries (`tests/test_audit_fixes.py`). The owner's way past an epoch that will never open is `kint compact --over-skipped`, or `kint rekey --over-skipped` to rotate at the same time, run on a machine whose last pull stopped there. It anchors that machine's store as one snapshot chained on the chain head, over the epoch it could not apply, so every later pull stops at the snapshot and never needs that epoch. The flag is honoured only for a snapshot on a machine whose last pull reported such an epoch, and it is a CLI flag, never a tool argument. Revoke the key first, or it can append another. `kint connect` and `kint rekey` read the key wraps from the newest epoch whose header parses, up to 32 back from the head; a forged epoch with a header that parses still stops them until such a snapshot sits on top. It cannot shorten a cold start: the snapshot flag sits outside the AAD, so a pull that stops at a claimed snapshot which cannot be applied walks the full history instead (`tests/test_e2e_anvil.py` pins this). kint has no revoke command. `kint session-key rotate` makes a new key on this machine and keeps the old file as `session.key.old`; it revokes nothing on chain. `kint authorize burn-nonce` consumes the owner's nonce but sets this machine's key again with its current expiry. Revoke from the owner wallet with the contract's `revokeSessionKey(key)`, which zeroes the expiry in the same block and consumes the nonce, so a signed authorization the thief still holds cannot re-arm the key: ```sh cast send 0xa22E03f7a4145Bf4909a83595C90a38E14d79600 "revokeSessionKey(address)" 0xSESSION_KEY \ --ledger --rpc-url https://mainnet.base.org ``` A Base Account owner sends the same call from the account, or `setSessionKey(key, 0)`, which has the same effect. The authorize page only ever sends `setSessionKey` with a future expiry. ### A compromised machine or VPS A connected machine holds what it needs to work, and an attacker on it holds the same: - the session key and the local secret that unlocks it; - the cached data key at `~/.kint/vault-.aes`, encrypted under a key derived from that same local secret and valid for `KINT_KEY_TTL` (24 hours by default, renewed by kint-server after every successful pull and push; `session` keeps it in memory only); - on the machine that created the vault, and wherever `kint recovery-code` or `kint rekey` ran, `~/.kint/RECOVERY-.txt`: the data key itself in base32. Push refuses on the creating machine when that file is gone, so it stays; - the Sibyl store (`~/.sibyl-memory/memory.db` or `$SIBYL_MEMORY_DB`, plain SQLite), the mirror `~/.kint/mirror-.json` (the full row set) and the decrypted plaintext of every cached epoch; - `KINT_OWNER_KEY`, if you left it in the environment after `kint authorize direct`. With the session key and the data key together, an attacker can seal epochs that open and verify, or edit the store and anchor the edit with `kint push`, which kint-server's hold does not cover. `memory_verify` proves a row matches what the chain anchored; it does not prove the machine that anchored it was honest. What the machine never holds: the owner key (unless you left it there), the derive signature (read from stdin, or from a file that is read and then unlinked), any vault passphrase (used to derive a KEK, never stored) and any KEK. That stays true only while you never connect or rekey on it again. To recover, revoke its session key first, then run `kint rekey` from a clean machine. The order matters: a key that is still live can append another epoch that does not open, which then takes `--over-skipped` again. The new data key is wrapped under KEKs the compromised machine never stored, so it opens nothing sealed after the rotation. What it already read stays read. ### A malicious or lagging RPC Reads and sends go to `KINT_RPC_URL`, else an Alchemy key from the macOS Keychain item `dev.api.alchemy`, else `https://mainnet.base.org`. That endpoint sees which owner and space this machine asks about. It can refuse, time out or serve a pruned node; the pull then stops, at a gap or with an error, and the next pull retries. It cannot forge an epoch: kint checks that the calldata's owner, space and prev agree with the event and that `keccak(ct)` equals the event digest, then opens the AEAD, which nothing made without the data key passes and which binds each epoch to its seq and prev. It cannot roll back a machine that has pulled before: the watermark (`~/.kint/watermark-.json`) never decreases, and an older head, or a different digest at the same seq, is refused as a "stale or lying RPC". The weak moment is the cold start, before there is a watermark. kint asks a second, independently operated endpoint (`KINT_RPC_URL_2`, else `https://base-rpc.publicnode.com` when the primary is the public Base RPC, else `https://mainnet.base.org`) for the head, retries once after 3 seconds, and refuses "to restore from a contested head" if the two still disagree on seq and digest. It also refuses when both URLs resolve to the same host, port and path. The second opinion covers the head only; the epochs come from the primary and pass the checks above. `KINT_ALLOW_SINGLE_RPC=1` accepts one operator, which then decides alone which head is newest on that first pull. Every error on its way to a tool result, a kint log line, or a `kint status`, `kint verify` or `kint doctor` line passes through `redact` in `src/kint/chain.py`, which keeps each URL's scheme and host and scrubs the path and query of the configured endpoints wherever they appear. kint-server logs an unexpected push, pull or verify failure as its error type and the redacted message, never a raw traceback; the CLI still prints a Python traceback, on your own terminal, for an error it does not catch. ### Two machines writing at once `EpochAnchor.push` reverts with `StaleHead` when `prev` is not the current head, so two epochs can never claim the same seq. Before sending, `kint push` compares the chain head with this machine's mirror and refuses: "another machine pushed. Run `kint pull` first". The machine that lost holds rows the chain never saw while the chain moved on: a fork, and `kint pull` refuses rather than lose them, telling you to save what you need out of the store first. `kint pull --discard-local` moves `memory.db` and its `-wal` and `-shm` files aside to a `.kint-backup-` copy and restores from the chain; the unanchored rows stay in that backup and are not replayed. It moves the whole file, so the rows of any other tenant in the same store are only in that backup too. On one machine, a file lock per space (`~/.kint/head-.lock`) keeps two kint processes from pushing or pulling at once. The second waits up to 30 seconds, then stops with "another kint process holds the head lock for this space". ### Losing the owner key The contract has no owner rotation. `canWrite` admits the owner or an unexpired session key, so existing session keys keep writing until they expire. No new key can be authorized and no expiry extended: `setSessionKey` must come from the owner, and `setSessionKeyBySig` needs the owner's signature. Reads never need the owner key once you have the data key: the recovery code (`kint connect --recovery-code-stdin`) or a passphrase wrap opens the memory on a new machine, and a connected machine keeps pushing until its session key expires, reconnecting with the recovery code whenever its cached data key lapses. An EOA owner without the key cannot sign the vault message again, so keep the recovery code somewhere that is not the machine. For a Base Account owner the vault key is already a passphrase, so losing the account ends new authorizations, not reads. ## What is public on Base | On chain | What a reader learns | |---|---| | `head(owner, space)` | The latest digest, seq and block of every owner and space | | `Epoch` event | `owner`, `space` and `writer` (all indexed), `seq`, `prev`, `digest`, `prevBlock`: who wrote each epoch and in which block, so when the memory changed | | `push` calldata | `owner`, `space`, `prev` and `ct`, which is the cleartext header followed by the ciphertext | | The header | Envelope version, flags (a snapshot epoch is visible), nonce, `rows_root`, `dek_id` (8 bytes, new after every rotation), the bucket, the wrap count and every wrap: kind (`0x01` signature, `0x02` passphrase), 16-byte key tag, nonce, wrapped data key. The tag is what a passphrase guess is tested against | | The ciphertext length | Exactly the bucket plus a 16-byte tag, so only which bucket: 4096, 8192, 16384, 32768, 65536 or 98304 bytes. A space never publishes a smaller bucket than before | | `sessionKeyExpiry`, `SessionKeySet`, `authNonce` | Which session keys may write for an owner, and until when. A session key pays its own gas, so the transfer that funded it is public too | | The space id | keccak256 of `kint-space-v1` followed by the tenant id, so a guessable tenant name can be confirmed by hashing it | Not public: row text, keys, categories, statuses, tier names, the row count (`n_rows` is inside the ciphertext), the data key, any KEK, the derive signature, any passphrase and the recovery code. `rows_root` is a 32-byte merkle root over keccak leaves: it commits to the whole state without spelling out any row. ## What is not built - `kint pull --rebase` (row-level last-writer-wins over a fork). Refuse-and-tell ships; rebase is next, and `kint pull` does not accept the flag. - A hosted (HTTPS) door for Claude on the web. The owner and session-key split makes it possible without uploading a key; the user hosts. - The EOA-owned smart account as a key source (Coinbase Smart Wallet with an EOA owner). Measured on a fork, not shipped; Base Account owners use the passphrase path. Wrap kind `0x04` is reserved for it, and `0x03` for WebAuthn PRF. - Owner rotation. Losing the owner key ends new writes for that owner once its session keys expire; reads never need it. - The frontend viewer. [`docs/frontend-contract.md`](https://github.com/s0nderlabs/kint/blob/main/docs/frontend-contract.md) is its contract. ## Secrets stay in the terminal The derive signature travels only on stdin (`kint connect --signature -`) or through `--signature-file`, a file kint reads and then unlinks. Any other value given to `--signature` is read as a file path, never as the signature. No MCP tool takes it: `memory_connect` has no parameter for it. kint prints the owner and the first 8 hex characters of the key tag, never the signature, the KEK or the data key. `memory_connect` does accept two secrets as parameters (`src/kint/server.py`): `passphrase` and `recovery_code`, plus `owner`, which is required the first time. Either secret connects this machine directly, and it passes through the agent's context on the way. With `passphrase` the tool takes the Base Account path, and where no vault exists yet for that owner and tenant it creates one; it returns the path of the recovery code file, never the code. > **Warning.** Anything typed into an agent chat lands in the transcript. Type the vault passphrase and the recovery code into a terminal instead: `kint connect --owner 0x... --smart-account --passphrase-stdin` or `kint connect --owner 0x... --recovery-code-stdin`. Called with no arguments on a machine that is not connected, `memory_connect` returns those commands for the human to run. Rotating the data key is CLI only: `kint rekey` needs the wallet signature or the vault passphrase, and there is no rekey tool. `kint authorize direct` and `kint authorize burn-nonce` read the owner key from `KINT_OWNER_KEY` in the environment, never from argv; clear it when you are done. Read [Introduction](/docs/introduction) next. Source: [`README.md`](https://github.com/s0nderlabs/kint/blob/main/README.md), [`docs/judge.md`](https://github.com/s0nderlabs/kint/blob/main/docs/judge.md), [`src/kint/crypto.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/crypto.py), [`src/kint/pull.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/pull.py), [`src/kint/verify.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/verify.py), [`src/kint/connect.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/connect.py), [`src/kint/push.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/push.py), [`src/kint/keys.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/keys.py), [`src/kint/chain.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/chain.py), [`src/kint/restore.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/restore.py), [`src/kint/server.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/server.py), [`src/kint/page.py`](https://github.com/s0nderlabs/kint/blob/main/src/kint/page.py), [`contracts/src/EpochAnchor.sol`](https://github.com/s0nderlabs/kint/blob/main/contracts/src/EpochAnchor.sol).