# Stash API Reference — exact shapes

The authoritative request/response contract, written against the live code.
Base URL: `https://stash.sanctumx.ai`. Auth on every route except `/init`
and `/health`: `Authorization: Bearer stash1_<capId>_<secret>`. All bodies
are JSON. Serve this file next to `stash-skill.md`.

## A note on the cipher fields (read this first)

**Every content byte is always encrypted.** Log lines, summaries, and
config are sealed with XChaCha20-Poly1305 under the identity's master key
*before touching SQLite*, AAD-bound to their exact slot — this layer is
unconditional and cannot be turned off. The API reports it as
`content_cipher: "xchacha20-poly1305"`. Separately, `file_cipher`
(`"sqlcipher"` or `"none"`) reports the **optional second layer**:
whole-file SQLCipher page encryption, applied when the server's SQLite
build supports it. `"none"` means "no SQLCipher file layer on this host,"
never "plaintext." Older deployments emitted a single `cipher: "plain"`
field with exactly the `file_cipher: "none"` meaning. Verify the sealing
yourself: note a distinctive string, then grep the store file on disk —
zero matches, WAL included.

## Scopes

`read` < `full` < `owner`. read: wake, recall, zoom, config (GET). full:
adds note, nap, forget, import, config (POST). owner: adds the grants
routes. Owner is never transferable.

---

## GET /health — no auth

`200` → `{"ok": true, "content_cipher": "xchacha20-poly1305", "sqlcipher": false}`
— `sqlcipher` reports the *server build's* file-layer capability;
`content_cipher` is the unconditional row sealing.

## POST /init — no auth (lockable)

If the server sets `STASH_INIT_KEY`, send header `X-Init-Key: <key>`
(else `403`). Optional body: `{"url": "https://stash.sanctumx.ai"}` —
fills the `{STASH_URL}` placeholders in the returned prompt. **Omit it and
the placeholders arrive unfilled, by design** — they are labeled slots,
not a bug, for prompts templated at install time.

`201` →
```json
{
  "user_id": "d37f9060d0d3284e",
  "token":   "stash1_<capId>_<secret>",
  "content_cipher": "xchacha20-poly1305",
  "file_cipher":    "none",
  "note":    "This token is shown once and never stored. ...",
  "prompt":  "## Memory\n..."
}
```
The token appears exactly once and is never stored server-side.

## GET /wake?part=&T= — read

First call: no parameters. `200` →
```json
{
  "T": 296,            // the log-length snapshot this document renders
  "part": 1,
  "parts": 2,          // present even when 1
  "lines": ["#0-127 <summary>", "#295 2026-08-26 <memory>"],
  "awake": false,      // true only on the last part
  "next": {"part": 2, "T": 296},   // null when awake
  "nap": null,         // on the LAST part only: a due compression, or null
  "message": "..."     // only when T=0 (fresh memory)
}
```
Loop `GET /wake?part=<next.part>&T=<next.T>` until `"awake": true`, using
the returned `T` verbatim — it pins the snapshot between parts.

`409` — **only** when the document needs a summary that does not exist
yet. This requires the log to exceed `WAKE_LINES` (default 96) while
blocks are unsettled; an agent that answers its naps promptly may never
see one. Body: `{"error": "...", "nap": {...}, "pending": n}`. Do the nap,
wake again.

## POST /note — full

Body: `{"text": "<one line, max 280 bytes>"}` (bytes, not characters).
`201` → `{"id": 7, "nap": null | {…nap object…}}`

The nap object, everywhere it appears:
```json
{"block": "0-15", "prompt": "Compress memories #0-15 ...", "remaining": 2}
```
`remaining` counts the compressions still due **after** this one —
`remaining: 0` still means do this nap; it marks the last item in the
queue, never an idle state. The `prompt` is the authoritative signal that
work exists.
Naps come due from the second memory on — the tree is built continuously,
one agent-written line at a time. (Wake simply doesn't *need* any of it
until the log outgrows the budget.) **The service never summarizes and
never deduplicates: both judgments are the agent's.** "Do not register
redundant memories" is discipline demanded *of* the agent, not a feature
performed by the API.

## POST /nap — full

Body: `{"block": "0-15", "summary": "<one line, max 280 bytes>"}`, or `{}`
to ask what is due. `200` →
`{"result": "0-15 saved." | "pending" | "nothing-pending" | "...already settled...", "nap": null | {…}}`
Blocks settle strictly in order; submitting the wrong block returns `409`
naming the right one.

## GET /recall?q=<regex> — read

Searches the verbatim log only — never the summaries. `200` →
`{"matches": 12, "lines": ["#42 2026-08-25 ..."], "truncated": false, "message": "12 matches."}`
When `truncated`, only the newest matches that fit are returned: narrow
the regex.

## GET /zoom?block=<a>-<b> — read

Block ids are inclusive and must be aligned power-of-two ranges as wake
prints them (`16-31`, not `17-32`). `200` → `{"lines": ["#16-23 <summary>", "#24-31 <summary>"]}` — down to raw memories when a half is single.

## POST /forget — full

Body: `{"block": "<a>-<b>"}`. Drops that summary and everything built on
it; the log is untouched. `200` →
`{"forgot": 3, "from": "0-3", "message": "...", "nap": {…next rebuild…}}`

## GET /config — read · POST /config — full

GET `200` → each knob as
`{"WAKE_LINES": {"value": 96, "default": 96, "overridden": false, "what": "..."}}`.
POST body: `{"WAKE_LINES": 300}` to set, `{"WAKE_LINES": null}` to reset.
`ENTRY_CHARS` can be lowered, never raised above 280.

## POST /import — full

Body: `{"entries": [{"date": "2020-01-01", "text": "..."}]}` — the key is
`entries`, dates are `YYYY-MM-DD`, oldest first, and no date may precede
the newest memory already in the log. Bootstrap only. `200` →
`{"imported": 2, "first": 0, "last": 1, "pending": 1, "nap": {…}}`

## GET /grants — owner

`200` → `{"grants": [{"cap_id": "...", "scope": "owner", "label": "...", "created_at": "...", "revoked_at": null}]}`

## POST /grants — owner

Body: `{"scope": "read" | "full", "label": "optional"}`. **Scope is
required — a capability is never minted by default**; `{}` returns `400`.
`201` → `{"capId": "...", "token": "stash1_...", "scope": "read", "note": "...shown once..."}`

## DELETE /grants/{capId} — owner

`200` → `{"revoked": "<capId>"}`. The owner capability is irrevocable
(`400`).

---

## Errors

Always `{"error": "<actionable message>", ...}` with the status telling
the story: `400` malformed input (message says what and the limit),
`401` missing/invalid/revoked token — stop and tell your human, don't
retry-loop, `403` scope too low, `404` no such route or grant,
`409` ordered work exists — for wake, do the attached nap; for nap, use
the block the message names. `500` means the server, not you.
