Documentation
Everything Keyholdr does,
in one place.
How secrets are stored, how to install the app and CLI, scratch
notes, and the full keyholdr command reference —
including the full-screen terminal UI, multi-select
run/env and env var naming conventions.
Overview
Keyholdr is a native macOS menu bar vault for API keys, plus a terminal companion that reads the same vault. The app stores non-secret metadata (platform, label, tags) in a local JSON file and writes the actual secrets straight into the OS's hardware-backed Keychain — they're never on disk in cleartext and every read requires Touch ID or Apple Watch.
There's no dock icon, no server, no sync, and no analytics. Everything happens locally, gated by biometrics.
Beside the vault there's a Notes tab for quick, plain-text scratch notes — the things you'd otherwise open Notes.app for. Notes are not secrets and are not encrypted; see Scratch notes.
Architecture
Metadata and secrets never travel together. Names, labels, and tags live in a local file; the secrets themselves live in the operating system's secure vault and are only pulled out — after biometric verification — at the moment you copy them.
| Layer | Where it lives |
|---|---|
| Metadata | ~/Library/Application Support/com.olixstudios.Keyholdr/keys.json — platform, label, tags, and age only |
| Secrets | macOS Keychain Services (Security API) |
| Unlock | Touch ID / Apple Watch (LocalAuthentication) |
| Notes | ~/Library/Application Support/com.olixstudios.Keyholdr/notes.json — plain text, not encrypted, never in the Keychain |
Self-healing
Every save also mirrors the (non-secret) metadata into a Keychain
item. If keys.json is ever deleted or corrupted, the
app silently restores it from that mirror — and since secrets
already live in the Keychain, deleting the app or its files loses
nothing.
Vault export
Export the whole vault to a single passphrase-encrypted file (PBKDF2 + AES-256-GCM) and import it on another machine. No cloud account required.
Install
The easy way, via Homebrew:
brew install --cask olixignacious/tap/keyholdr
Or grab the latest release
directly — unzip Keyholdr-macOS-*.zip and move
Keyholdr.app to /Applications. It's
signed and notarized, so it opens without any Gatekeeper prompt.
Requires Apple Silicon.
Or install from the Mac App Store — search for "Keyholdr". Sandboxing limits the command-line tool in the App Store build; for the terminal, use the Homebrew or direct-download build (see below).
CLI link
Homebrew links keyholdr onto your PATH
automatically. For a direct download, link it once:
mkdir -p ~/.local/bin
ln -sf /Applications/Keyholdr.app/Contents/MacOS/keyholdr-cli ~/.local/bin/keyholdr
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
Restart Terminal (or run source ~/.zshrc) afterward.
/usr/local/bin isn't user-writable on modern macOS by
default, which is why ~/.local/bin is used instead —
the in-app CLI installer (direct-download build only) does the
same thing automatically via a footer button.
Terminal with the Mac App Store build
The App Store build is sandboxed, which limits the command-line
tool bundled inside it — it can't put itself on your PATH and it
can't run the full-screen terminal UI. If you
want keyholdr in the terminal, install the Homebrew or
direct-download build alongside it; it reads the same vault:
brew install --cask olixignacious/tap/keyholdr
You can copy that command from inside the App Store app too: ⋯ menu → Terminal Setup…. Keys whose secrets were saved by the App Store app may need re-adding for the terminal to read them — see the FAQ.
The first read of each key shows a one-time macOS Keychain consent dialog — choose Always Allow and it won't ask again.
Scratch notes
A KEYS | NOTES switcher at the top of the popover flips between the vault and a scrap pad for the text you'd otherwise open Notes.app for: a command to re-run, a link to paste later, a half-formed thought.
- Fast.
⌘Non the Notes tab starts a new note. Type or paste — it autosaves as you go, including when the popover closes mid-sentence. No save button, no Touch ID. - Searchable. Filter the list by title or body; one click on
COPYputs the whole note on the clipboard. - Tidy. Back out of a blank note and it's discarded. Deleting asks you to click twice.
- In the terminal too. The terminal UI has the same Notes tab, sharing the same notes.
Notes are plain text and are not encrypted. Unlike keys, they live in a regular file and are not protected by the Keychain or Touch ID, so keep secrets in Keys. Notes are also not included in vault export.
Shortcuts
| Keys | Action |
|---|---|
| ⌃⌥⌘K | Summon or dismiss Keyholdr — works system-wide |
| ⌘N | Add a new key (a new note, on the Notes tab) |
| Esc | Dismiss the add/edit form (or leave the note editor) |
| just type | Search is focused by default |
CLI reference
The terminal companion reads the same vault as the app — the same key list and the same macOS Keychain entries. Every secret access requires Touch ID (or your account password as a fallback).
| Command | What it does |
|---|---|
| keyholdr | Full-screen UI for keys and notes (default subcommand) |
| keyholdr list | List every key (never the secrets) |
| keyholdr get <platform> | Print or copy a secret |
| keyholdr run | Run a command with secrets injected as env vars |
| keyholdr env | Emit export / .env lines for eval |
| keyholdr add <platform> | Add a new key |
| keyholdr rm [platform] | Delete one or more keys |
Run keyholdr <command> --help for full flag details.
keyholdr / keyholdr pick [filter] — the terminal UI
Opens a full-screen UI with KEYS and NOTES tabs, the same as the menu bar app. The left pane lists entries; the right pane shows the selected one — env var name, tags and dates for a key (with the secret masked), the full text for a note.
# browse everything
keyholdr
# start pre-filtered to "aws"
keyholdr aws
# the simple inline picker instead (also: KEYHOLDR_CLASSIC=1)
keyholdr --classic
Keys tab. Copy and reveal need Touch ID, exactly
like get. Mark several keys with space
and press ⏎ to copy all their secrets, one per
line — anything it can't read is named, and the rest still copy. A
revealed secret hides itself after 10 seconds. ⌃N
adds a key (the secret field is hidden as you type) and
⌃X deletes after a confirmation — both follow the
same rules as keyholdr add and keyholdr rm.
Notes tab. Quick plain-text notes, shared with the menu bar app. ⏎ copies a note (no Touch ID — notes aren't secrets), ⌃N starts a new one and ⌃E edits the selected one in a multi-line editor. Pasting keeps its newlines; Esc saves and closes, and an empty note is discarded.
It draws on the terminal's alternate screen, so nothing you reveal
stays in your scrollback. It needs a real terminal (stdin and
stderr must both be a tty), a TERM other than
dumb, and at least 60×16 cells — below that it falls
back to the inline picker. In scripts or pipes, use
list, get, run, or
env instead. If a terminal is ever left in an odd
state, reset restores it.
⌘ shortcuts never reach a terminal. Terminal apps
handle ⌘ keys themselves — ⌘N in VS Code opens a new file — so the
UI uses Ctrl combinations. You can also just type a note's text and
press ⏎ when nothing matches: it becomes a new
note. To forward ⌘N to the terminal as Ctrl+N in VS Code, add this
to keybindings.json:
{ "key": "cmd+n", "command": "workbench.action.terminal.sendSequence",
"args": { "text": "\u000e" }, "when": "terminalFocus" }
Login-password prompts. Each key is its own Keychain item, so the first time the terminal reads it macOS asks for your login password. Choose Always Allow — it asks once per key, then not again.
keyholdr list
Prints every key — platform, label, tags, and age — without ever touching the Keychain or prompting for Touch ID.
keyholdr list
# PLATFORM LABEL TAGS AGE
# github work dev,ci 14d
# aws default 3mo ⚠
A ⚠ next to the age means the key is old enough that
Keyholdr suggests rotating it.
keyholdr get <platform>
Resolves <platform> (case-insensitive,
substring match — keyholdr get git finds
github), prompts for Touch ID, then prints the secret
to stdout.
# prints to stdout
keyholdr get aws
# disambiguate when one platform has multiple keys
keyholdr get github --label work
# clipboard instead of stdout
keyholdr get github --label work --copy
If a platform has more than one matching key and
--label isn't given, the picker opens to choose — but
only in an interactive terminal. In scripts, ambiguity is a hard
error listing the --label values to disambiguate
with, so automation never blocks on hidden interactivity.
keyholdr run
Runs a command with secrets injected as environment variables in the child process only — they never touch stdout, files, or shell history.
Explicit mappings, one or more -e ENV_VAR=platform[/label]:
keyholdr run -e AWS_ACCESS_KEY_ID=aws -e GITHUB_TOKEN=github/work -- npm start
Multi-select, with no -e flags, in an interactive terminal:
keyholdr run -- npm start
This opens the multi-select picker (⇥ or
space to mark, ⏎ to confirm) and
derives conventional env var names automatically — e.g.
GITHUB_TOKEN, AWS_ACCESS_KEY_ID (see
env var naming below).
keyholdr env
Selects keys and prints export NAME='value' lines (or
NAME=value with --dotenv) designed to be
eval'd into your current shell — nothing
lands in files or history.
# multi-select picker
eval "$(keyholdr env)"
# name keys directly
eval "$(keyholdr env aws github/work)"
# .env-style NAME=value
eval "$(keyholdr env --dotenv aws)"
# dry run: shows the name mapping only,
# no Touch ID, nothing exported
keyholdr env --names
eval is required — without it, the
export lines just print to your terminal and don't
affect your shell's environment. --names is a
preview: it never reads secrets or requires Touch ID, and on its
own doesn't export anything.
keyholdr add <platform>
Adds a key. The secret is never an argument
(arguments are visible to every process via ps) —
it's read from a hidden prompt, or piped on stdin.
# hidden prompt
keyholdr add github --label work --tags dev,ci
# piped from the clipboard
pbpaste | keyholdr add aws
--label defaults to default. An
identical platform + label pair is rejected — use a different
--label to keep keys addressable.
keyholdr rm [platform]
Deletes one or more keys and their Keychain secrets.
# confirms, then deletes
keyholdr rm github --label work
# skip the confirmation prompt (scripts)
keyholdr rm github --force
# multi-select: ⇥/space to mark, ⏎ deletes
keyholdr rm
With no platform, opens the multi-select picker
(interactive terminals only). Without --force,
deletion always asks for confirmation — and refuses outright in
non-interactive contexts so scripts can't silently wipe keys.
When several keys match (keyholdr get aws with a work
and a personal key), the same picker opens to choose — in scripts
and pipes it stays a hard error with --label hints
instead, so automation never blocks. The app refuses to create two
keys with an identical platform + label, so every key stays
addressable.
Env var naming
keyholdr run (multi-select) and
keyholdr env derive a conventional environment
variable name per platform:
| Platform contains | Env var |
|---|---|
| github | GITHUB_TOKEN |
| gitlab | GITLAB_TOKEN |
| huggingface | HF_TOKEN |
| slack | SLACK_TOKEN |
| discord | DISCORD_TOKEN |
| telegram | TELEGRAM_BOT_TOKEN |
| vercel | VERCEL_TOKEN |
| netlify | NETLIFY_AUTH_TOKEN |
| cloudflare | CLOUDFLARE_API_TOKEN |
| twilio | TWILIO_AUTH_TOKEN |
| npm | NPM_TOKEN |
| sentry | SENTRY_AUTH_TOKEN |
| claude / anthropic | ANTHROPIC_API_KEY |
| chatgpt / openai | OPENAI_API_KEY |
| anything else | <PLATFORM>_API_KEY |
If selecting multiple keys would produce the same env var name,
the label is appended (then a numeric counter) to keep names
unique — e.g. GITHUB_TOKEN and
GITHUB_TOKEN_WORK.
UI & picker controls
Terminal UI
| Key | Action |
|---|---|
| type | filter the current tab (^U clears it, ^W deletes a word) |
| ↑ / ↓, PgUp / PgDn, Home / End | move the selection |
| ⏎ | copy the selection (or every marked key). When nothing matches what you typed, create a new note (or key) from that text |
| space | mark / unmark a key (keys tab) |
| ⌃R | reveal / hide the secret (keys tab, Touch ID) |
| ⌃N (Ctrl+N) | new key (keys tab) or new note (notes tab) |
| ⌃E | edit the selected note (notes tab) |
| ⌃X | delete the selection or marked keys, after a y confirmation |
| ⇥ / ⇧⇥ / ← / → | switch between KEYS and NOTES |
| esc | clear the filter; quit when it is already empty (saves and closes the note editor) |
| ^C, ^D | quit |
Inline picker
What --classic shows, and what rm,
run, env and ambiguous key references use.
| Key | Action |
|---|---|
| type | filter by platform, label, or tag |
| ↑ / ↓ | move selection |
| ⏎ | select (single) / confirm (multi) |
| ⇥ or space | mark/unmark an entry (multi-select only) |
| ^U | clear the filter |
| esc, ^C, ^D | cancel |
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success (also when you quit the terminal UI) |
| 1 | Validation error or declined confirmation |
| 130 | Picker cancelled (esc / ^C / ^D) |
| other | Forwarded from the child process for keyholdr run |
MCP bridge
MCP (Model Context Protocol) is how tools like Claude Desktop and Claude Code call out to local tools. A small bridge script lets an MCP client ask Keyholdr for a secret by name instead of you pasting it into a config file or a chat, and every request still goes through the same Touch ID prompt as using the app directly.
The bridge is just a thin wrapper: it shells out to the
keyholdr CLI and hands back stdout. It never touches
the Keychain itself and can't skip the biometric check.
1. Install the SDK
npm install @modelcontextprotocol/sdk
2. Save the bridge script
Save this as keyholdr-mcp-bridge.mjs:
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
const KEYHOLDR = process.env.KEYHOLDR_BIN || "keyholdr";
const server = new Server(
{ name: "keyholdr-bridge", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "list_keys",
description: "List available key names in the vault (never the secret values).",
inputSchema: { type: "object", properties: {} },
},
{
name: "get_secret",
description:
"Retrieve one secret from the local Keyholdr vault. Triggers a Touch ID prompt on the Mac running this bridge.",
inputSchema: {
type: "object",
properties: {
platform: { type: "string", description: "Platform name, e.g. github or aws" },
label: { type: "string", description: "Optional label, e.g. work or personal" },
},
required: ["platform"],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args = {} } = request.params;
if (name === "list_keys") {
const { stdout } = await run(KEYHOLDR, ["list"]);
return { content: [{ type: "text", text: stdout }] };
}
if (name === "get_secret") {
const cliArgs = ["get", args.platform];
if (args.label) cliArgs.push("--label", args.label);
const { stdout } = await run(KEYHOLDR, cliArgs);
return { content: [{ type: "text", text: stdout.trim() }] };
}
throw new Error(`Unknown tool: ${name}`);
});
await server.connect(new StdioServerTransport());
3. Point an MCP client at it
For Claude Desktop, add it to
~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"keyholdr": {
"command": "node",
"args": ["/absolute/path/to/keyholdr-mcp-bridge.mjs"]
}
}
}
Restart the client, and it'll have list_keys and
get_secret tools backed by your real vault.
Keep in mind
- Anything
get_secretreturns becomes part of the model's context for that conversation, same exposure as pasting a key into chat yourself, so only wire up secrets you're comfortable with an agent seeing transiently. - The Touch ID prompt appears on the Mac running the bridge, not remotely. If you run the bridge over SSH on a headless Mac, the CLI has no biometric prompt to show and will fail rather than silently skip it.
- This bridge isn't shipped by Keyholdr. It's a pattern you run yourself; audit and adapt it before pointing it at anything sensitive.
Build from source
macOS needs Xcode Command Line Tools
(xcode-select --install):
# release build → app bundle → launches in your menu bar
./build.sh
Tagged releases (v*) automatically build the macOS
app on CI and attach it to the GitHub release.
The Windows app (C# 12, WPF, .NET 8) lives in its own repo: keyholdr-windows.
Questions about building it yourself? Get in touch.