API Keys
Paste an OpenAI key into Settings so the in-process engine can answer the first message
Paste an OpenAI key into the Settings screen and the in-process engine picks it up on the very next message — no relaunch, and the key never touches the settings file.
How a new user finds out a key is needed
First-launch “no key” guidance now happens on the chat screen, before any message is sent.
Before this, opening the app on a fresh install rendered a blank rectangle, and the only “no key” signal was the raw provider prose The OPENAI_API_KEY environment variable is missing or empty, shown after the user tapped Send.
The chat screen now shows an “Add an API key to start” panel with an “Open settings” button before any message is sent — see Empty Chat.
| Rule | Behaviour |
|---|---|
| The panel appears only on a definite absence | It shows when the engine in force needs a key and SecretsPort.has() has definitely returned false. |
| An in-flight lookup reads as the neutral welcome | A keychain lookup in flight is KeyPresence.unknown (a three-value type), which resolves to the welcome copy, not “missing” — see the Empty Chat page. |
| The remote engine is never told to get a key | It authenticates at its own server, so it never shows the “add a key” panel. |
Quick Start
- Open Settings and find the OpenAI API key row
The OpenAI API key row is the lead section on the screen, in its own API key heading above Engine. It shows Not set and a masked field. The Remove button appears only once a key is actually stored (
canClear), so a “Not set” row has no button under it. On a platform without a hardware keychain (the web adapter, whereisHardwareBacked === false), a warning above the rows says secrets are kept in app memory. - Paste your key
Paste the key into the masked field and blur it or press Enter. The row flips to Configured, the field returns to its placeholder, and nothing is echoed back.
- Send the very next message
No relaunch. The in-process engine reads the key on every turn, so the next message you send is authenticated.
Ask anything — the first reply arrives. - Rotate or revoke
To rotate, paste a new value over the old one — the replacement also survives a restart. To revoke, press Remove (shown only while the row is Configured) — the row returns to Not set, the button disappears in the same pass, and the keychain entry is gone.
How It Works
A pasted key is committed as a set-secret intent, written straight to SecretsPort, and read back by the engine on the next turn.
The field carries data-action="set-secret" and its key; a root-delegated change listener decodes it through intentFrom and calls facade.setSecret(ref, value). On the next turn, createInProcessEngine calls apiKeyFor(secrets, settings.defs()) and builds the agent with the key it finds.
Where set(ref, value) lands depends on the platform, both reached through src-tauri/plugins/secrets:
| Platform | Write goes to | How |
|---|---|---|
| iOS / macOS | The system Keychain | SecItemAdd (kSecClassGenericPassword) via the security-framework crate — the value is held by the keychain daemon, not the app sandbox. Source: src-tauri/plugins/secrets/src/lib.rs, apple module. |
| Android | EncryptedSharedPreferences | Values AES256-GCM, keys AES256-SIV, master key generated in AndroidKeyStore and never extractable. Source: android/src/main/java/ai/praison/mobile/secrets/SecretsPlugin.kt. |
| Any other host | Refuses | A desktop dev build has no hardware store, so the plugin refuses rather than writing a file. |
What the field does and does NOT do
The masked field is governed by three rules from secretControls in app/src/main.ts, and each has a broken version that looks completely normal on screen.
| Rule | Why |
|---|---|
| The field value is never assigned from storage | SettingsFacade has no secret getter — there is nothing to echo. A masked stand-in like sk-…abcd would still put the value in the render tree, where a screenshot, crash report or accessibility dump could reach it. |
type="password", autocomplete="off", autocapitalize="off", autocorrect="off", spellcheck="false" | Dots for a shoulder-surfer or a screen recording; no browser autofill, and no unrecognised token shipped off to a spellchecker. |
| Presence is a separate node, starting at UNKNOWN | hasSecret is async and the paint is not. Rendering the in-flight check as “Not set” tells someone their key is missing while it sits in the keychain — which is how a working key gets pasted twice. |
| Remove is painted only when a key exists to remove | SecretRow.canClear is state === "configured" && ref !== undefined; the button sets both hidden and disabled from !canClear, so an unknown (in-flight) or not-set row carries no live button. See the canClear rule. |
Configuration Options
The row is declared by one SettingDef in SETTING_DEFS (source: app/src/registry.ts).
{
key: "openaiApiKey",
default: "",
label: "OpenAI API key",
help: "The in-process engine signs its requests with this. It is kept in the platform secret store, never in the settings file, and never shown back to you.",
section: SECTION_API_KEY, // "API key" — the lead section, above "Engine"
secret: true,
secretRef: { slot: "openai", account: "default" },
}| Field | Value | Notes |
|---|---|---|
secret | true | Routes the value to SecretsPort and never to the settings file. |
secretRef | { slot: "openai", account: "default" } | Where the key lives in the keychain. |
default | "" | Required by SettingDef.default‘s type, but never stored and never shown — a secret def has no value row at all. |
Common Patterns
An empty commit is refused, and the value is trimmed — from intentFrom in app/src/intents.ts.
// A cleared field on blur does NOT delete the key:
intentFrom([{ dataset: { action: "set-secret", settingKey: "openaiApiKey" }, value: " " }]);
// → null (raw.trim() === "" is refused)
// Removing a credential must be asked for by name, via the Remove button:
intentFrom([{ dataset: { action: "clear-secret", settingKey: "openaiApiKey" } }]);
// → { kind: "clear-secret", key: "openaiApiKey" }The engine reads the key on every turn, not at boot — from createInProcessEngine in app/src/main.ts.
const apiKey = await apiKeyFor(secrets, settings.defs());
return new Agent({
instructions: "You are a helpful assistant.",
llm: "gpt-4o-mini",
// Omitted, never passed as "" or null, when unset:
...(apiKey === null ? {} : { apiKey }),
});The engine is built once and held for the session, so a construction-time read would need a force-quit to pick up a pasted key. enginesFor already learned this with baseUrl, and it is worse for a credential — the failure it produces is the same missing-key error the user was trying to clear.
Best Practices
Rotate through Settings, not by editing files
The key lives in the iOS keychain or Android keystore, never in the plaintext settings file. Paste a new value over the old one to rotate; press Remove to revoke.
Trust the presence label, not the field
The field is empty on every paint — syncSecret empties it again after every commit. Configured / Not set on the presence node is the truth about whether a key is stored; the field only ever holds what you are typing right now.
A masked echo would lie — that is why there is none
Showing dots for a stored key would read as “a key is already here”, which is what the presence label is for. The field paints empty precisely so it never claims a key is present when it may not be.
An absent key is omitted, not sent as empty
When no key is set, apiKey is left off the agent config rather than passed as null or "". Upstream treats a falsy apiKey as “fall back to the environment”, and a phone has no process.env — so the honest shape for “no key” is an absent field and the provider’s own missing-credential error.
What if my key is gone after an Android backup restore?
Enter it once more and it sticks. Google’s auto-backup copies the encrypted preferences file onto a new device, but the AndroidKeyStore master key that decrypts it is device-local and does not travel — so the old file cannot be read. The plugin catches that KeyStoreException, deletes the unreadable file with deleteSharedPreferences, and recreates the store empty, so the settings screen is never bricked. Any pre-restore secrets are lost and re-entered once, exactly like a fresh install. Source: SecretsPlugin.kt, store() / discard().
Related
How a secret row renders, commits, and refreshes its presence.
Storage & SecretsSecretsPort, readSecretSetting, and the isHardwareBacked warning.
The keychain plugin behind this row, and why it refuses on an unsupported platform.
EnginesHow the in-process engine reads its key on every turn.
i18n & A11yHow the four secret strings are announced.
Empty ChatThe first-screen “Add an API key to start” panel, shown before any Send.