
# Storage and API keys

Agentak keeps four kinds of Pi choice:

- one API key per provider
- the last provider
- the last model per provider
- the last thinking level per provider and model

Nothing is written to browser storage by default. The shared page-memory store lets a key
entered in one session work in another session on the same page, then forgets everything
when the page is gone.

## Use localStorage

```ts
import { browserStorage, createPiSession } from "agentak/pi";

const session = createPiSession({
  storage: browserStorage(),
});
```

`browserStorage()` prefixes its `localStorage` keys with `agentak:`. It catches storage
errors, so a sandbox or browser policy that blocks storage falls back to this session
without breaking the chat. It reads at once and answers with a promise all the same,
because that is what a `PiStorage` is.

When the same storage also backs `history: true`, conversations persist there too.

## API keys are encrypted

`browserStorage()` writes API keys as ciphertext. Everything else — the provider, the model,
the thinking level, and stored conversations — is written as it is.

The encryption key is AES-GCM, generated with `extractable: false` and kept in IndexedDB
(`agentak` / `crypto` / `secret-key`). The browser can use it and no script can copy it out:
`crypto.subtle.exportKey()` throws on it, and a structured clone of it carries no key
material. A dump of `localStorage` therefore carries no usable key, and neither does a
copied profile directory.

This does not defend against script running on your own origin. Such a script can ask the
same layer to decrypt, exactly as the chat does. What it removes is the plaintext secret
that outlives the visit.

Three cases are worth knowing:

- A key stored in plain text by an older build is re-encrypted the first time it is read.
- If the encryption key is gone — site data cleared while `localStorage` survived — the
  stored key reads back as missing, and the next key you enter replaces it.
- On an origin without `crypto.subtle` (insecure context) or without IndexedDB, the key is
  **not** stored. It works for the session and goes with the page.

A stored value says which key sealed it, in the prefix it carries:

| Prefix          | Sealed with                                       |
| --------------- | ------------------------------------------------- |
| `agentak-enc1:` | the key this browser keeps in IndexedDB           |
| `agentak-enc2:` | the key the device's authenticator derives, below |
| anything else   | nothing — written in the clear                    |

Both are AES-GCM over the same bytes, so the prefix is the only thing that tells them
apart. It has to: a value the device lock sealed and a value nothing can open any more look
identical without it, and a chat that mistakes the second for the first offers an unlock
that can never succeed. `storage.sealed(name)` reads that prefix against the current lock
and answers `"open"`, `"locked"`, or `"stale"`.

Wrap any other store the same way:

```ts
import { encryptedStorage } from "agentak/pi";

const session = createPiSession({
  storage: encryptedStorage(myStorage),
});
```

`encryptedStorage(inner, options)` takes `secret`, which decides which names hold a secret.
The default is `isSecret`, which is every `api-key:*` name.

## Lock the keys to the device

The encryption key above is one the browser hands back to any script on the origin. A
person can ask for more: the settings page has a **Device lock** section that puts the key
in the device's own authenticator — Touch ID, Windows Hello, an Android screen lock — so
nothing reads a stored key until they confirm it.

It is off by default, and the section only appears where the browser can do it. Under it:

- `navigator.credentials.create()` registers a platform passkey with the WebAuthn `prf`
  extension. No password, no account, no server: the credential exists to derive a key.
- Unlocking calls `navigator.credentials.get()` with a stored salt. The authenticator
  answers 32 bytes that only it can produce, and only after user verification.
- Those bytes go through HKDF into a non-extractable AES-GCM key, held in memory for the
  visit. Nothing derived from them is written to disk.
- What is stored is a credential ID and a salt. Neither is secret, and neither opens
  anything on its own.

The chat asks for it at the moment it is needed. WebAuthn requires a user gesture, so the
send button's click is what opens the dialog: a locked chat sends its first message after
one confirmation, and the rest of the visit needs none. The settings page has its own
**Unlock** button for the same thing.

Note what this does and does not do. The stored key stops being usable by a script that
runs when nobody is at the machine, and stops being recoverable from a copied profile. A
script running on your origin while the keys are unlocked can still use them — the chat
itself does.

Three things to plan for:

- **A deleted passkey cannot be recovered.** The keys sealed under it are unreadable. The
  settings page says so and asks for a new key rather than offering an unlock that would
  open a dialog for a credential that no longer exists.
- **A device that registers a passkey but cannot derive from it leaves that passkey
  behind.** Setup fails and nothing is sealed, but the credential is listed in the person's
  passkey manager, because no web API can delete one.
- **Turning the lock on or off re-encrypts the stored keys.** `PiSession` does this with the
  keys it holds in memory, so a key it never read is not carried across.

The seam is `browserStorage().lock`:

```ts
const storage = browserStorage();

await storage.lock.ready; // which of the two the browser holds
storage.lock.supported(); // Promise<boolean> — can this browser do it at all
storage.lock.state(); // "off" | "locked" | "open"
storage.lock.enable(); // register a passkey; needs a user gesture
storage.lock.unlock(); // ask for the key; needs a user gesture
storage.lock.disable(); // back to the browser-held key; must be open
storage.sealed(name); // "open" | "locked" | "stale" — what is in the way
```

A host driving `Chat` itself renders the same section from the `keyLock` snapshot field and
the `setKeyLock` and `unlockKeys` methods — see
[Custom agents](/agents/custom).

## Pass keys in code

```ts
const session = createPiSession({
  provider: "openrouter",
  apiKey: "sk-or-v1-…",
});
```

Or supply several providers:

```ts
const session = createPiSession({
  apiKey: {
    openrouter: "sk-or-v1-…",
    "vercel-ai-gateway": "vck_…",
  },
});
```

A string belongs to the provider selected through `provider`. A map is keyed by Agentak
provider ID. Keys passed by the host take priority over stored keys for that session.

Do not put a private server credential in public client code. Provider calls leave from
the visitor's browser, so use a user-owned key, a public scoped key, an anonymous or local
provider, or a custom session that calls your own backend.

## Key controls

A provider that already has a key shows **Change key** instead of an empty input. If the
session can remove keys, it also shows **Remove**. Removing a key deletes it from the
session and store, then steps off that provider until a key is supplied again.

With the device lock on, a provider whose key has not been unlocked yet is listed as
**Locked** and shows **Unlock** with **Use another key** beside it. A provider whose stored
key can no longer be opened at all shows the normal input, with a line saying the old key
was locked to a device this browser no longer has.

Agentak never displays a stored key. It only replaces or removes it.

## Implement `PiStorage`

```ts
import type { PiStorage } from "agentak/pi";

const storage: PiStorage = {
  async get(name) {
    return (await db.read(name)) ?? undefined;
  },
  async set(name, value) {
    await db.write(name, value);
  },
  async remove(name) {
    await db.delete(name);
  },
};
```

The contract is:

```ts
interface PiStorage {
  get(name: string): Promise<string | undefined>;
  set(name: string, value: string): Promise<void>;
  remove?(name: string): Promise<void>;
}
```

Every method is asynchronous, so `chrome.storage`, IndexedDB, and an encrypted store that
waits on WebCrypto all fit without a cache in front of them. `set()` must reject when the
write did not land: built-in history reads that as a full store and gives up an older
conversation. `remove()` is optional; without it, Agentak writes an empty value that reads
back as missing.

## Wait for the stored choices

The session is created synchronously and reads the store after, so it opens with no
provider, no model, and no key, then fills in. The chat redraws when the choices land, so
most hosts need to do nothing.

A host that must not show a chat with every choice forgotten, and then change it under the
reader, mounts on `session.ready`:

```ts
const session = createPiSession({ history: true, storage: browserStorage() });

await session.ready; // keys, provider, model, level, and stored conversations
mount(element, { session });
```

The Agentak extension does this, because its panel is the whole document.

## Storage helpers

`agentak/pi` also exports:

- `memoryStorage()` for an isolated in-memory store
- `pageStorage`, the shared default memory store
- `createChoices(storage)` for named key/provider/model/thinking access
- `browserStorage()` for guarded `localStorage`
- `encryptedStorage(storage, options)` to seal the secrets in any store
- `isSecret(name)` for the default test of what a secret is
- `isSecretStorage(storage)` to tell a sealing store from a plain one
- `createVault(options)` for the key source alone, without a store around it
- `passkeySupported()`, `createPasskey()`, `derivePasskey()`, and `passkeyFailure()` for the
  WebAuthn layer under the device lock

Storage is host policy. Choose it based on the origin's script trust, the lifetime users
expect, and whether conversation data or API keys are allowed to remain on the device.
