Killing 1Password Biometric Spam in Claude Code with One Pipe
Killing 1Password Biometric Spam in Claude Code with One Pipe
Environment
| Component | Version |
|---|---|
| Claude Code | 2.1.x (Opus 4.6, 1M context) |
1Password CLI (op) | 2.33.1 |
| jq | 1.7+ |
| yq (Mike Farah) | 4.x |
| macOS | 15.7.x (Sequoia) with Touch ID |
The problem
I run Claude Code in agent mode all day for infra work. Most of it needs secrets out of 1Password — Proxmox passwords, Cloudflare API tokens, iDRAC creds, OpenTofu service tokens. The natural way to fetch them is op item get, which triggers an authorization prompt every time you don’t have a live session.
Run that once: prompt. Run it again 30 seconds later if the first call’s session lapsed: prompt. Inside an agent loop that needs five secrets: five separate interruptions. If I’m not looking at the screen when the prompt fires, the loop stalls until I notice. On a long reasoning run with Opus — where the model sits thinking for two or three minutes between tool calls — this happens constantly.
The two auth modes, and why both have the same problem
On macOS, the 1Password CLI offers two ways to authorize:
- Touch ID prompt — fast biometric, fires in a native dialog you have to focus
- Desktop CLI authorization dialog — enabled via
Settings → Developer → Integrate with 1Password CLI, routes the prompt through the 1Password app itself with a per-terminal approval flow

The dialog text is explicit: “Authorization expires after 10 minutes of inactivity or when 1Password locks.”
That 10-minute idle timeout is the crux. In an active Claude Code session, spending a couple of minutes reviewing model output between tool calls is normal — especially on multi-step reasoning tasks. Both auth modes share this limit. Five minutes after your last op call, the next one re-prompts. It doesn’t matter which mode you use. The session isn’t kept warm by thinking, only by calling op.
The cache approach described below is unaffected. The secrets live in a JSON file in /tmp — /tmp doesn’t have an idle timeout.
The naive cache I had been running
Before this, I had infra-secret.sh — a script that called op item get once per key, wrote the result to a per-key temp file, and returned from cache on subsequent calls:
# simplified version of what I hadCACHE_DIR="/tmp/.claude-infra-secrets-$(id -u)"mkdir -p "$CACHE_DIR" && chmod 700 "$CACHE_DIR"
CACHE_FILE="$CACHE_DIR/$KEY"if [ -f "$CACHE_FILE" ]; then cat "$CACHE_FILE" exit 0fi
op item get "$ITEM" --fields "label=$FIELD" --reveal > "$CACHE_FILE"chmod 600 "$CACHE_FILE"cat "$CACHE_FILE"This worked for a fixed set of known keys. The problem: only kicks in after the first call per key, so adding a new secret means accepting a fresh prompt the first time it runs. It also doesn’t solve the case where an agent task needs a secret I hadn’t pre-declared. Six items in a workflow: six separate prompts on first use, then cached for the rest of the session.
The wish was simpler: one prompt per vault per session, all secrets available from that point on.
The trick: one pipe, one prompt
This is buried in the op docs but easy to miss because every documented example layers on --fields which, as I’ll show, blows everything up.
op item list --vault Infrastructure --format=json \ | op item get - --format=jsonop item list enumerates item IDs. Piping to op item get - makes the second op call fetch all of them in one invocation, sharing the auth session. Two op processes, one prompt.
My first attempt added --fields label=password to extract only passwords:
op item list --vault Infrastructure --format=json \ | op item get - --fields label=password --format=jsonIt blew up:
[ERROR] unable to process element 1: "password" isn't a field in the "monitoring-vm age private key (SOPS)" itemWorse: the error aborts the entire stream. Items processed before the error were lost on re-run because op item list ordering is non-deterministic — sometimes the bad item was at position 5 and four items printed before the abort, sometimes it was at position 1 and nothing came out. Non-deterministic partial output with no recovery path.
Then I dropped --fields entirely:
op item list --vault Infrastructure --format=json \ | op item get - --format=json18/18 items returned, zero errors, one prompt, full JSON for every item — passwords, usernames, custom fields, sections, the lot. Concealed values returned unredacted. Total time: ~9 seconds for 18 items.
That’s the trick. Without --fields, op doesn’t gate items on field presence — it dumps each item’s full shape and moves on. The flag I had never thought to remove was the entire problem.
Verifying it is byte-faithful
Spot-checked three items (Telegram bot token, Cloudflare API token, Umami Postgres password) against individual op item get calls. Identical down to the byte.
One edge case worth knowing: items with custom fields defined but never filled in return empty values from the bulk fetch — not because of bulk mode, but because that’s the actual state of the item. The 1Password GUI hides empty fields in view mode but they exist in the item structure. I found two ghost fields on my Umami item this way and cleaned them up.
Wrapping it as a cache with auto-refresh on miss
The cache layer is straightforward:
op-bulk-load.shruns the pipe, dumps the JSON output into/tmp/.op-toolkit-$(id -u)/<vault>.jsonop-cache-get.shdoes ajqlookup against the file using 1Password’sop://Vault/Item/fieldURI scheme- Directory is
chmod 700, files arechmod 600— plaintext JSON in/tmp, user-only perms
The first version errored on cache miss. That was wrong. The most common cause of a cache miss is “I added an item to 1Password since the cache was warmed.” So op-cache-get.sh now checks the cache file’s mtime on miss. If it’s older than 30 seconds, it auto-refreshes that vault and retries the lookup. The 30-second debounce stops typos from infinite-looping — back-to-back misses on a freshly-refreshed cache short-circuit to a clear error message.
op-cache-get.sh 'op://Infrastructure/SomeNewItem/password'# miss → cache age 47s ≥ 30s → refresh → re-lookup → return valueOverride the debounce with OP_TOOLKIT_REFRESH_DEBOUNCE=60. Disable auto-refresh entirely with --no-refresh.
The numbers
| Operation | Before | After |
|---|---|---|
| First secret of session | ~2s + 1 prompt | ~9s + 1 prompt (full vault loaded) |
| Each subsequent unique secret | ~2s + 1 prompt | 72ms, zero prompts |
| 5 secrets in a workflow | 5 prompts, ~10s, interruptions | 1 prompt, ~9s, all prefetched |
| Adding a 6th secret | 1 fresh prompt | zero prompts (already in cache) |
| Effect of 10-minute idle timeout | next call re-prompts | irrelevant, cache outlives idle |
The biometric prompt cost is now constant — exactly one per vault per session — independent of how many secrets the agent ends up needing.
Init flow
op-toolkit-init.sh# lists your 1Password vaults, asks which ones to cache# writes ~/.config/op-toolkit/config.envAfter that, scripts work without arguments — they pick up the configured vault list automatically. No alias setup required. References use 1Password’s own URI scheme:
PASS=$(op-cache-get.sh 'op://Infrastructure/Proxmox - Main/password')Aliases are an optional convenience layer on top, not a setup requirement.
Creating items from YAML templates
The other half of the toolkit. The hard part of “use 1Password well” isn’t reading secrets — it’s creating them with consistent metadata so future-you (or future-Claude) can understand what an entry is for six months later.
A login.yaml example:
title: "Cloudflare API Token - OpenTofu IaC"category: api-credentialvault: Infrastructureurl: https://dash.cloudflare.com/profile/api-tokensusername: stanguc@personalpassword: <paste-token-here>sections: Permissions: scope: "Account: All zones, DNS: Edit" role: limited IP restrictions: none Issued: created: 2026-04-12 expires: nevernotes: | Purpose: OpenTofu Cloudflare provider for personal infra IaC Rotation: every 90 days Related: stanguc/sergen-infra-home repo Where used: ~/.claude/scripts/load-infra-secrets.sh exports as $CF_API_TOKENThen:
op-item-new.sh login.yaml# created: op://Infrastructure/Cloudflare API Token - OpenTofu IaC/password# cache refreshedThe script enforces conventions by construction:
- title must be ≥ 10 chars (no
test, nofoo) - category must be from a known list (
login,api-credential,server,secure-note) - notes are mandatory prose, not optional
- soft warning if notes don’t mention purpose, rotation, or related resources
- sections become
op’s"Section.field[type]=value"syntax - field types are auto-detected: anything with
password,token,secret, orkeyin the name becomes concealed; everything else is text
--dry-run prints the full op item create command without running it. After creation, the script refreshes the vault cache so the new item is immediately readable via op-cache-get.sh — no manual warm-up needed.
Packaging it as a Claude Code plugin
Claude Code 2.x has a real plugin system. The layout:
plugins/op-toolkit/├── .claude-plugin/plugin.json # plugin manifest├── README.md├── bin/ # auto-added to PATH on install│ ├── op-toolkit-init.sh│ ├── op-bulk-load.sh│ ├── op-cache-get.sh│ ├── op-cache-clear.sh│ └── op-item-new.sh├── templates/ # YAML examples for op-item-new.sh│ ├── login.yaml│ ├── server.yaml│ ├── api-credential.yaml│ └── secure-note.yaml└── skills/op-toolkit/ └── SKILL.md # behavioral doc Claude readsThe bin/ directory is the key: anything there becomes available on PATH while the plugin is enabled, no path prefixes needed in prompts or hook scripts.
Validation is built into the CLI:
$ claude plugin validate ./claude-marketplace✔ Validation passedLocal testing without publishing:
$ claude --plugin-dir ./claude-marketplace/plugins/op-toolkitInstall
/plugin marketplace add tanguc/claude-marketplace/plugin install op-toolkit@tangucSource: github.com/tanguc/claude-marketplace. MIT license.
Requires op 2.x, jq, and yq (brew install yq).
Limitations worth knowing
- Document category items — return metadata only. File content needs
op document get. Not handled by this toolkit. - File attachments on regular items — metadata only, not downloaded.
- TOTP fields — return the OTP URI, not the live 6-digit code.
- Item titles containing
/— break theop://Vault/Item/fieldURI parser. This is a 1Password CLI limitation, not specific to this toolkit. - Plaintext cache — JSON file in
/tmp, user-only permissions but plaintext. If that’s unacceptable for your threat model, do not use this.
Why this took embarrassingly long to find
The op item list | op item get - pattern is in the official op docs. I had read those docs more than once and never tried it because every documented example used --fields, and --fields blows up the stream on any item that doesn’t have that field. The fix was removing the flag I had never thought to question, because every example included it.
The lesson is not new but it bears repeating: when a pipeline aborts on the first invalid element, the answer is rarely “skip the bad elements” and almost always “ask for less specific output.” The CLI has no --ignore-errors flag for --fields, and it doesn’t need one — it already has a mode that returns everything without gatekeeping on field presence. I was just not using it.
One pipe. One prompt. Done.