Storage and API keys

Control where provider keys and model choices live in the browser.

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

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:

PrefixSealed with
agentak-enc1:the key this browser keeps in IndexedDB
agentak-enc2:the key the device's authenticator derives, below
anything elsenothing — 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:

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:

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.

#Pass keys in code

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

Or supply several providers:

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

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:

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:

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.