# 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
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.`.
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-