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.

LayerWhere 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. ⌘N on 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 COPY puts 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

KeysAction
⌃⌥⌘KSummon or dismiss Keyholdr — works system-wide
⌘NAdd a new key (a new note, on the Notes tab)
EscDismiss the add/edit form (or leave the note editor)
just typeSearch 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).

CommandWhat it does
keyholdrFull-screen UI for keys and notes (default subcommand)
keyholdr listList every key (never the secrets)
keyholdr get <platform>Print or copy a secret
keyholdr runRun a command with secrets injected as env vars
keyholdr envEmit 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 containsEnv var
githubGITHUB_TOKEN
gitlabGITLAB_TOKEN
huggingfaceHF_TOKEN
slackSLACK_TOKEN
discordDISCORD_TOKEN
telegramTELEGRAM_BOT_TOKEN
vercelVERCEL_TOKEN
netlifyNETLIFY_AUTH_TOKEN
cloudflareCLOUDFLARE_API_TOKEN
twilioTWILIO_AUTH_TOKEN
npmNPM_TOKEN
sentrySENTRY_AUTH_TOKEN
claude / anthropicANTHROPIC_API_KEY
chatgpt / openaiOPENAI_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

KeyAction
typefilter the current tab (^U clears it, ^W deletes a word)
↑ / ↓, PgUp / PgDn, Home / Endmove the selection
copy the selection (or every marked key). When nothing matches what you typed, create a new note (or key) from that text
spacemark / unmark a key (keys tab)
⌃Rreveal / hide the secret (keys tab, Touch ID)
⌃N (Ctrl+N)new key (keys tab) or new note (notes tab)
⌃Eedit the selected note (notes tab)
⌃Xdelete the selection or marked keys, after a y confirmation
⇥ / ⇧⇥ / ← / →switch between KEYS and NOTES
escclear the filter; quit when it is already empty (saves and closes the note editor)
^C, ^Dquit

Inline picker

What --classic shows, and what rm, run, env and ambiguous key references use.

KeyAction
typefilter by platform, label, or tag
↑ / ↓move selection
select (single) / confirm (multi)
⇥ or spacemark/unmark an entry (multi-select only)
^Uclear the filter
esc, ^C, ^Dcancel

Exit codes

CodeMeaning
0Success (also when you quit the terminal UI)
1Validation error or declined confirmation
130Picker cancelled (esc / ^C / ^D)
otherForwarded 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_secret returns 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.