The Flat Environment: Demystifying Linux Terminal Variables and Secret Management

🤖 Read Raw Markdown📄 Google Doc (Gemini can summarize it)

Setting the Stage: Context for the Curious Book Reader

Context for the Curious Book Reader

As we navigate the complexities of local-first tooling and automated workflows, understanding the foundational mechanics of the operating system becomes essential. This chapter explores how something as seemingly straightforward as Linux environment variables actually operates beneath the surface, revealing a flat, string-to-string key-value architecture. By examining how we map and manage primary root secrets versus ephemeral session tokens, we establish a clean, predictable boundary between raw system state and secure out-of-band credential handling.


TL;DR: This entry documents a working session on Pipulate, a local-first, Nix-reproducible toolkit for API-connected workflows. The session’s subject is credential architecture: where secrets live, how they reach a shell, and how a machine reports which of them actually work.

Three tiers are confirmed and now hold system-wide: primary secrets (API keys, bearer <redacted:6>) live in one flat ~/.config/pipulate/.env, sourced natively by the Nix shell hook; schema (auth kinds, required variable names, file paths — never values) lives in ~/.config/pipulate/connectors.json; derivatives (OAuth tokens, browser profiles) are re-mintable on demand and excluded from backup. Two changes landed. A sixth auth kind, mcp_oauth, was added so remote Model Context Protocol servers appear as scored rows on the credential board, with token paths derived from the server URL rather than configured. And Google Search Console was rewired from a service-account key to ordinary user OAuth, eliminating the last non-human credential in the connector layer.

The credential board has two lanes by design: an offline scoreboard that reads file timestamps and variable names only, and a live check that spends one bounded API call per slot and reports green only when a service accepted the credential just now.


Technical Journal Entry Begins

🔗 Verified Pipulate Commits:

MikeLev.in: What is the command to view my Linux terminal environment variables?

Google AI Overview: To view all of your Linux terminal environment variables, use the printenv or env command. [1, 2]

Alternative Commands

Depending on what exactly you need to see, you can use these variations:

  • printenv - Lists all current environment variables.
  • env - Lists all current environment variables.
  • printenv VARIABLE_NAME - Displays the value of one specific variable (do not include the $ sign).
  • echo $VARIABLE_NAME - Displays the value of one specific variable using the dollar sign.
  • set - Lists all variables, including environment variables, local shell variables, and shell functions. [1, 2, 3]

Quick Examples

If you want to view a specific variable like your system PATH or current USER, you can run:

bash

printenv PATH

Use code with caution.

Or:

bash

echo $USER

If you are looking for a specific variable in a long list, you can pair the command with grep: [1]

bash

printenv | grep USER

If you want to do more with your variables, would you like to know how to create new variables or make them persist permanently across terminal sessions?

[1] https://www.youtube.com/watch?v=Mv-w7ojtnRo
[2] https://www.runxbuild.com/blog/linux-view-environment-variables/
[3] https://www.youtube.com/watch?v=yM8v5i2Qjgg
[4] https://www.scaler.com/topics/set-environment-variable-in-linux/
[5] https://askubuntu.com/questions/651508/how-do-i-correctly-display-the-content-of-an-environment-variable-here-current

MikeLev.in: Okay, fair enough. How would you describe such a system as is used in a Linux bash terminal? Is it a giant key-value store? Is it nested able to take subkeys like browser cookies with arbitrary hierarchies in there which I suppose must be the case because there is printenv PATH? And if so is that actually done a lot beyond paths or do most people try to keep it flat? Like is there a namespace concept? And if flat, how does one organize or think about it?

Gemini 3.7 Flash Extended: At the operating system level, the Linux environment is a strictly flat, string-to-string key-value store. It possesses no native hierarchy, subkeys, or nested namespaces. Under the hood in C, it is literally represented as a single-dimensional array of null-terminated strings (char **environ) formatted as KEY=VALUE.

The Flat Architecture of the Operating System

The printenv PATH Misconception

Running printenv PATH does not traverse a path or query a nested object. PATH is just the literal name of the variable, named after its purpose: holding the search path for executable binaries.

Running printenv PATH simply asks the system: “What string is mapped to the key 'PATH'?”


How Hierarchy and Organization Work in Practice

Because the underlying system is completely flat, any structure or namespacing is handled purely by convention and application-level parsing:

  • Prefix-Based Namespacing: Applications and tools emulate namespaces by prefixing keys with their project or domain name in uppercase:
  • AWS_ACCESS_KEY_ID / AWS_REGION
  • GIT_AUTHOR_NAME / GIT_COMMITTER_EMAIL
  • POSTGRES_USER / POSTGRES_DB
  • REACT_APP_API_URL / NEXT_PUBLIC_SITE_URL

  • Delimited String Values: When a variable needs to represent a list or set of values, the application defines a separator:
  • Colons (:) are standard for path lists (PATH, LD_LIBRARY_PATH, PYTHONPATH, MANPATH).
  • Commas or spaces are common for arbitrary lists (e.g., ALLOWED_HOSTS=localhost,127.0.0.1).

  • Serialized Payloads: Modern cloud-native tooling or microservices sometimes store structured config as a raw JSON or YAML string inside a single variable, which the application deserializes at runtime.

Convention, Prefixing, and Structured Serialized Payloads


How to Mentally Model It

Think of the terminal environment as a **flat Map<String, String>** assigned to your shell process:

  • No Typing: Every key is a string; every value is a raw string. Numbers, booleans, and arrays do not exist until an application parses them.
  • Inheritance by Value: When a shell executes a command or script, it copies its exported key-value pairs to the new child process. Changes made in the child never propagate back to the parent.
  • Global to the Process: Any code running inside a process can read any environment variable passed to it, which is why prefixes are critical to avoid key collisions.

MikeLev.in: Okay so the terminal environment variables are completely flat, and occasionally a key like PRINT field-stuffs a list or a sub-key system into the value because it’s a string and you can do tricks like that with strings, and given the Python tools that you could for example use to read and write such strings in the terminal environment, it’s trivial to field-stuff a whole JSON object for example. Got it. Makes sense.

Okay then. I need create total global clarity across the system regarding credentials. There will be an out-of-bounds JSON somewhere, meaning no git repo will pick it up and it lives in the dot-env space usually ~/.confg/pipulate/ (in our case) location and the only way it gets backed up during a 3,2,1 backup routine. If you lost it without backing it up you’d have to reconstruct it from scratch and it may be the only place you store things like bearer <redacted:6>, so back it up. I guess what I’m saying is that I want to zero in on one of the files in there, probably the one that already has that responsibility, and just open and look at it and see at a glance all my API-keys and bearer <redacted:7> It wouldn’t be any of the stuff like an OAuth refresh token or downstream derivative tokens, see? And then I will make sure there’s a unified way to go from what’s in that file into the terminal’s environment variables with a system that suits the Pipulate use cases and the terminal commands that go along with it. Is all of this making sense?

Gemini 3.7 Flash Extended: Context verified.

Yes, this makes complete architectural sense. Separating Primary Root Secrets (API keys, bearer <redacted:6>, basic auth passwords) from Derivative/Ephemeral State (OAuth access/refresh tokens, session cookies, browser caches) is the cleanest way to manage credentials and 3-2-1 backups.


1. Primary Secrets vs. Downstream Derivatives

~/.config/pipulate/
├── .env                     <-- THE VAULT: Primary root secrets (API keys, bearer <redacted:6>). 3-2-1 Backup target.
├── connectors.json          <-- THE WALLET: Schema, auth types, required var names, non-secret defaults.
├── credentials.json         <-- App OAuth client definitions (Google Cloud Console Desktop Client).
├── gmail_token.json         <-- DERIVATIVE: Ephemeral OAuth refresh/access token.
├── sheets_token.json        <-- DERIVATIVE: Ephemeral OAuth refresh/access token.
└── mcp/<host>.json          <-- DERIVATIVE: Warmed OAuth 2.1 access/refresh tokens.
  • Primary Root Secrets (.env or secrets.json): Static credentials you manually copy from developer consoles (e.g., BOTIFY_API_TOKEN, OPENAI_API_KEY, CONFLUENCE_TOKEN, SLACK_USER_TOKEN). If your machine dies, you cannot regenerate these without logging into every provider console. This is what you back up.
  • Derivative / Ephemeral Artifacts: Short-lived tokens minted by handshakes (OAuth refresh tokens, PKCE tokens, browser cookies). If lost, you simply run wallet login <slot> or warm to re-mint them.

Separating Primary Root Secrets from Ephemeral Artifacts


2. Flat .env vs. Structured JSON

Because Linux environment variables are strictly flat string mappings (KEY=VALUE), storing primary secrets in a flat .env file versus a structured JSON presents a direct tradeoff:

Format Pros Cons
**Flat .env** (~/.config/pipulate/.env) Natively sourceable by Bash/Nix in one line (set -a; source ~/.config/pipulate/.env; set +a). No parsing tools required. Flat namespace; requires explicit prefixes (SLACK_USER_TOKEN, BOTIFY_API_TOKEN) to avoid collisions.
Structured JSON (~/.config/pipulate/secrets.json) Nested grouping by service (e.g., {"slack": {"user_token": "..."}}). Easy programmatic inspection. Cannot be sourced directly by /bin/sh or Bash. Requires a hydration layer (jq or Python) to flatten and export into the shell environment.

3. How Pipulate Already Handles This

Pipulate currently implements this exact two-tier pattern:

  1. The Schema / Directory (connectors.json): Tracks the names of required environment variables, file paths, and non-secret defaults per connector. It holds zero secret values and is safe to inspect or version.
  2. The Vault (~/.config/pipulate/.env): A chmod 0600 flat file holding all paste-kind secrets (KEY='VALUE').
  3. Automatic Shell Hydration: When you enter nix develop, flake.nix automatically sources ~/.config/pipulate/.env first (via miscSetupLogic), exporting all root keys into your terminal environment. wallet.py warm <slot> writes directly to this file.

(1) PROBES

python scripts/connectors/wallet.py
python -c "from pathlib import Path; p = Path.home() / '.config/pipulate/.env'; print(f'Vault: {p} (exists={p.exists()})')"
jq 'keys' ~/.config/pipulate/connectors.json 2>/dev/null || echo "No connectors.json"

(2) NEXT CONTEXT

! python scripts/connectors/wallet.py
! python -c "from pathlib import Path; p = Path.home() / '.config/pipulate/.env'; print(f'Vault: {p} (exists={p.exists()})')"
! jq 'keys' ~/.config/pipulate/connectors.json 2>/dev/null || echo "No connectors.json"

(3) PATCHES

No repo patches required.


(4) PROMPT

Review the probe receipts. Would you like to keep the primary vault as the flat `~/.config/pipulate/.env` (which `flake.nix` and `wallet.py` already read/write directly), or design a structured JSON vault schema (`secrets.json`) with an automated flattening hydrator?

(5) EXTERNAL DELIVERABLES

None.

MikeLev.in:

1: Probe:

(nix) pipulate $ $ git status
On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean
(nix) pipulate $ python scripts/connectors/wallet.py
python -c "from pathlib import Path; p = Path.home() / '.config/pipulate/.env'; print(f'Vault: {p} (exists={p.exists()})')"
jq 'keys' ~/.config/pipulate/connectors.json 2>/dev/null || echo "No connectors.json"
# wallet.py — connector auth scoreboard (read-only, offline)
# wallet: /home/mike/.config/pipulate/connectors.json
# repo:   /home/mike/repos/pipulate  (anchors browser_session profiles)
# stale after: 7d — mtime heuristic for oauth/browser, not a validity proof

     state    kind      slot            evidence                                                                   where
  [x] filled   oauth     gmail           2026-08-30 (1d ago)                                                        /home/mike/.config/pipulate/gmail_token.json
  [x] filled   oauth     sheets          2026-08-30 (1d ago)                                                        /home/mike/.config/pipulate/sheets_token.json
  [x] filled   bearer    <redacted:6>          set: BOTIFY_API_TOKEN (env)                                                env / .env (out of git)
  [x] filled   basic     confluence      set: CONFLUENCE_URL (env), CONFLUENCE_EMAIL (env), CONFLUENCE_TOKEN (env)  env / .env (out of git)
  [x] filled   svc-acct  gsc             2026-07-15 (48d ago)                                                       /home/mike/.config/pipulate/service-account-key.json
  [x] filled   basic     jira            set: JIRA_URL (env), JIRA_EMAIL (env), JIRA_TOKEN (env)                    env / .env (out of git)
  [x] filled   bearer    <redacted:5>           set: SLACK_USER_TOKEN (env)                                                env / .env (out of git)
  [/] partial  basic     gong            set: GONG_ACCESS_KEY (env) | unset: GONG_ACCESS_KEY_SECRET                 env / .env (out of git)
  [~] stale    browser   botify_browser  2026-07-29 (34d ago)                                                       /home/mike/repos/pipulate/data/uc_profiles/botify
  [~] stale    browser   semrush         2026-07-23 (40d ago)                                                       /home/mike/repos/pipulate/data/uc_profiles/semrush

# 7 filled | 2 stale | 1 partial | 0 empty
# Next: python scripts/connectors/wallet.py warm botify_browser   (confirms, then opens this slot's own site + profile)
Vault: /home/mike/.config/pipulate/.env (exists=True)
[
  "_rule",
  "_schema",
  "botify",
  "botify_browser",
  "confluence",
  "gmail",
  "gong",
  "gsc",
  "jira",
  "semrush",
  "sheets",
  "slack"
]
(nix) pipulate $

2: Context:

# adhoc.txt    _   _   _ to set context____ _   _  ___  ____  _   <F5> Simpson Couch Gag Here (explain anything to the audience you feel needs it explained)
#     / \   __| | | | | | ___   ___   / ___| | | |/ _ \|  _ \| |  
# ahe/ _ \ / _` | | |_| |/ _ \ / __| | |   | |_| | | | | |_) | |  We really have to start with just wrangling our dot env's and top-level bearer <redacted:6> and API keys.
# ahc ___ \ (_| | |  _  | (_) | (__  | |___|  _  | |_| |  __/|_|  
#  /_/   \_\__,_| |_| |_|\___/ \___|  \____|_| |_|\___/|_|   (_)  
# Ad Hoc CHOP: The Not-Managed-by-Git Safe-for-Client-Data place  

#    # THE ROLLING PIN BOOK ORE SPINE / FODDER FOR THE BOOK
#    ! python scripts/articles/lsa.py -t 1 --reverse --fmt dated-slugs  # <-- The "Rolling Pin" that gives the 40K foot book-spine view of book-ore.
#    GLOSSARY.md                 # <-- I think this glossary goes well with the book-ore spine to do world building.
#    # scripts/articles/lsa.py     # <-- Useful for refining commands like `posts`, critical to Second Brain concept.
#     
#    # THE QUIRKY AMIGA-LOVING HUMAN
#    # ~/repos/nixos/autognome.py  # <-- Letting the AIs really understand my environment (The Brave Little Tailor punches above Their Weight Class proving the dunning-kruger effect the gate-keeper's (lower-case) lament.)
#    # init.lua                    # <-- Daily driver hot-keys that overlap with aliases in flake.nix
#     
#    # AGENTIC FRAMEWORK & FOREVER MACHINE BIG FILES
#    prompt_foo.py               # <-- Prompt Fu compiler, makes the very README for AGENTS-like payload you're reading right now, but it needs to be more like that
#    foo_files.py                # <-- This is the router, evolving book outline and the things you pin-up to produced the recursive self-improvement loops
#    flake.nix                   # <-- Solves world's WRITE ONCE RUN ANYWHERE problem like Java never could. Also resolves the bootstrap paradox.
#    
#    # MAIN ACTUATORS, IaC & NEGATIVE SPACE
#    apply.py                    # <-- How can "Web UI" ChatBots edit your code? With this Aider-inspired Player Piano patch applier.
#    .gitattributes              # <-- Model: understand that `nbstripout` and `jupytext` are both in play. Just talk the human through .ipynb patches.
#    .gitignore                  # <-- Creates "negative space" for sub-rep's to share parent environment and "snap" proprietary secret features into place.
#    requirements.in             # <-- All known dependencies and (necessary) version pinning. WORA gotcha's exposed.
#    __init__.py                 # <-- Master versioning
#    pyproject.toml              # <-- The PyPI Packaging details
#    
#    # cli.py                      # <-- Catch-all actuator for PyPI envs, Python anchoring, MCP tool-call (plus alternatives) and **kwargs like wrapping for CLI
#    # scripts/xp.py               # <-- Transforms host OS copy-paste buffer player-piano music into context-payload.
#    # scripts/ai.py               # <-- How I constantly use local AI to write git commit messages with `m` alias.
#     
#    # CONTEXT PORTABILITY SYSTEM
#    3 scripts/foo_cartridge.py    # Needs description
#    3 scripts/foo_replay.py       # Needs description
#          
#    # FREQUENTLY USEFUL TO HAVE IN CONTEXT
#    # release.py                  # <-- How everything ends up where it does (GitHub, PyPI, etc.)
#    
#    # scripts/weblogin.py         # <-- Lets the user "warm up" the cache for their web logins at their leisure on a profile that persists.
#    # scripts/crawl.py            # <-- Feel free to ask for something to be crawled and included in the next turn.
#    
#    # imports/voice_synthesis.py  # <-- The wand can talk to you
#    # scripts/release/version_sync.py  # <-- Needs to be wrapped into release.py and eliminated, I think.
#    
#    #                         --- Under this line is were you paste what the AI gives you ---
#    #                         --- We call it context but it's really just the right-hand  ---
#    #                         --- blast-radius of the "probes" to make this all science.  ---
#    
#    # --- END `adhoc.txt` TEMPLATE ---
#    
#    # server.py
#     

flake.nix
.gitattributes              # <-- Model: understand that `nbstripout` and `jupytext` are both in play. Just talk the human through .ipynb patches.
.gitignore                  # <-- Creates "negative space" for sub-rep's to share parent environment and "snap" proprietary secret features into place.
requirements.in             # <-- All known dependencies and (necessary) version pinning. WORA gotcha's exposed.
__init__.py                 # <-- Master versioning
pyproject.toml              # <-- The PyPI Packaging details

# STICKBUG & MOTHER CAT KATA
# assets/trails/botify_pageworkers.yaml
assets/installer/mck.sh
assets/installer/replay.sh
assets/trails/first_context.yaml
assets/trails/practice.yaml
assets/trails/public_walk.yaml
scripts/bookmark_import.py
scripts/boot_menu.py
scripts/connectors/README.md
scripts/connectors/botify.py
scripts/connectors/confluence.py
scripts/connectors/gmail.py
scripts/connectors/gsc.py
scripts/connectors/jira.py
scripts/connectors/mcp.py
scripts/connectors/mcp_warm.py
scripts/connectors/sheets.py
scripts/connectors/slack.py
scripts/connectors/wallet.py
scripts/mother_cat.py
scripts/sources_menu.py
scripts/walk.py
scripts/walk_cartridge.py
scripts/walk_compile.py
scripts/weblogin.py
tools/scraper_tools.py

#    # # adhoc.txt -- Cleanup inert public_walk environment export block
#    # 
#    # # --- BEFORE/AFTER STRADDLE ---
#    # ! grep -n -C 2 'PIPULATE_TRAIL_WALK_ONE_URL' assets/installer/mck.sh || echo "export_block_removed"
#    # ! bash -n assets/installer/mck.sh; echo "mck_syntax=$?"
#    # ! bash assets/installer/mck.sh --where
#    # 
#    # # --- TARGET SCRIPT ---
#    # assets/installer/mck.sh
#    # 
#    # ! rg -n -F 'TRAIL_NAME="${TRAIL_NAME:-public_walk}"' assets/installer/mck.sh
#    # ! rg -n -e '^ *walk\(\)' -e '^ *alias walk=' flake.nix; echo "shell_walk_exit=$?"
#    # ! test -e walk; echo "root_walk_exists=$?"
#    # ! rg -n -e 'alias mothercat=' -e 'Three words to start from' flake.nix scripts/boot_menu.py
#    # flake.nix
#    # scripts/boot_menu.py
#    # assets/installer/mck.sh
#    
#    # ! bash -n walk; echo "walk_syntax=$?"
#    # ! bash walk --where
#    # ! rg -n -e '^ *alias walk=' -e '^ *walk\(\)' -e 'writeShellScriptBin "walk"' flake.nix; echo "shell_walk_exit=$?"
#    # ! .venv/bin/python -c 'import sys; sys.path.insert(0,"scripts"); import boot_menu as b; print("count=" + b._count_word(len(b.DOOR_TWO_WORDS))); print(" ".join(w for w, _ in b.DOOR_TWO_WORDS))'
#    # walk
#    # flake.nix
#    # scripts/boot_menu.py
#    # assets/installer/mck.sh
#    
#    # ! rg -in "three words|four words|door 2" flake.nix scripts/ assets/ README.md
#    # ! walk --where
#    # walk
#    # flake.nix
#    # scripts/boot_menu.py
#    # assets/installer/mck.sh
#    
#    # ! python scripts/connectors/wallet.py check slack
#    # ! python -c "import os, sys; from pathlib import Path; sys.path.insert(0, '.'); from scripts.connectors import wallet; pairs = wallet._dotenv_pairs(); print('env SLACK_USER_TOKEN:', bool(os.getenv('SLACK_USER_TOKEN')), 'env SLACK_BOT_TOKEN:', bool(os.getenv('SLACK_BOT_TOKEN')), 'vault SLACK_USER_TOKEN:', 'SLACK_USER_TOKEN' in pairs, 'vault SLACK_BOT_TOKEN:', 'SLACK_BOT_TOKEN' in pairs)"
#    # ! python scripts/connectors/wallet.py warm slack --dry-run
#    
#    # Now that slack is working (on my workspace) we trim back things here to just the stick bug and Mother Cat Kata stuff

# /home/mike/repos/trimnoir/_posts/2026-08-30-terminal-native-mcp-practice.md
# deleteme.txt
# assets/nbs/Onboarding.ipynb                       # <-- Where Workflows go to get born. Common `.venv` to FastHTML. Also to Cursor or VSCode too if you know how to set it up.
# assets/nbs/imports/core_sauce.py                  # <-- Make those workflows being born in Notebooks look clean by pushing the sausage factory back to code-behind Python secret sauce import modules.
# assets/nbs/imports/onboard_sauce.py               # <-- Now you're cooking!
# apps/015_config.py                                # <-- Carrying onboarding values over to FastHTML-side, especially dotenv values. New home for Botify API key!

! python scripts/connectors/wallet.py
! python -c "from pathlib import Path; p = Path.home() / '.config/pipulate/.env'; print(f'Vault: {p} (exists={p.exists()})')"
! jq 'keys' ~/.config/pipulate/connectors.json 2>/dev/null || echo "No connectors.json"

3: Patches: None

Ignition, sed or post-patch-pre-ahc probes here.

4: Prompt: Review the probe receipts. Would you like to keep the primary vault as the flat ~/.config/pipulate/.env (which flake.nix and wallet.py already read/write directly), or design a structured JSON vault schema (secrets.json) with an automated flattening hydrator?

5: Deliverables: None

Gemini 3.7 Flash Extended: Context verified.

The live receipts confirm the system is already configured and working in the two-tier model:

  • The Vault (~/.config/pipulate/.env): exists=True, holding all paste-kind credentials (BOTIFY_API_TOKEN, SLACK_USER_TOKEN, CONFLUENCE_TOKEN, JIRA_TOKEN, GONG_ACCESS_KEY, etc.).
  • The Wallet (~/.config/pipulate/connectors.json): 10 active connectors registered with zero secrets exposed.

Architectural Verdict: Keep the Flat .env Vault

Keep ~/.config/pipulate/.env as the primary root vault rather than migrating to a structured secrets.json.

  • Native Zero-Overhead Sourcing: In flake.nix (miscSetupLogic), shell hydration is a native one-liner:
    set -a; source "$VAULT_ENV"; set +a
    

A JSON vault would require running jq or Python on every single shell launch (nix develop, subshells, and .#quiet), adding startup latency and a bootstrap dependency.

  • Direct 1:1 Parity with os.getenv(): Libraries (langchain, httpx, llm, Google API clients) and custom connector scripts look for specific environment variable names (e.g., OPENAI_API_KEY, BOTIFY_API_TOKEN). Storing them as flat KEY='VALUE' avoids maintainability overhead from maintaining an arbitrary translation map ({"botify": {"api_token": "..."}} -> BOTIFY_API_TOKEN).
  • connectors.json Already Provides the Schema: connectors.json acts as the structural directory (auth types, required variable names, file paths, defaults). Storing the secret payload in .env keeps configuration metadata cleanly separated from sensitive values.

The 3-2-1 Backup Map

For a complete backup and restore of the Pipulate machine state, the backup target boundary is:

~/.config/pipulate/
├── .env                  <-- BACKUP TARGET: Root static API keys & bearer <redacted:6> (0600)
├── connectors.json       <-- BACKUP TARGET: Connector definitions & defaults (0600)
├── credentials.json      <-- BACKUP TARGET: Google Desktop OAuth Client ID/Secret (0600)
├── blogs.json            <-- BACKUP TARGET: Local blog publish targets & paths
├── pii_substitutions.txt <-- BACKUP TARGET: PII sanitization mapping rules
└── commit_denylist.txt   <-- BACKUP TARGET: Commit airlock patterns

Derivative state (gmail_token.json, sheets_token.json, mcp/*.json, and data/uc_profiles/) can be omitted from backups, as wallet login <slot> or warm re-mints them on demand.


(1) PROBES

cat ~/.config/pipulate/.env | sed 's/=.*/=******/'
python scripts/connectors/wallet.py

(2) NEXT CONTEXT

! cat ~/.config/pipulate/.env | sed 's/=.*/=******/'
! python scripts/connectors/wallet.py

(3) PATCHES

No repo patches required.


(4) PROMPT

Review the masked vault keys and wallet scoreboard. Everything is aligned with the flat `.env` vault architecture. What connector or workflow step should we tackle next?

(5) EXTERNAL DELIVERABLES

None this turn.

MikeLev.in: So what you’re saying is that I already have something better than what I’m planning. Also I need to remind myself that the warm command that does this:

(nix) pipulate $ warm
# wallet check — LIVE credential board (one bounded call per slot)
# wallet: /home/mike/.config/pipulate/connectors.json
# green means the service accepted this credential just now, not merely that a token exists

  🟢 gmail           oauth     gmail GREEN [author email]
  🔴 sheets          oauth     sheets RED gate2: transport failure: HTTPSConnectionPool(host='sheets.googleapis.com', port=443): Read timed out. (read timeout=15)
  🟢 botify          bearer    <redacted:6> GREEN michael.levin
  🟢 confluence      basic     confluence GREEN michael.levin
  ⚪ gsc             svc-acct  no --check yet — this connector has no health probe
  🟢 jira            basic     jira GREEN michael.levin (via site host)
  🟢 slack           bearer    <redacted:5> GREEN mikelevin @ Pipulate (user token) | 5 scope(s), LIST+FETCH covered
  ⚪ gong            basic     no connector module (gong.py)
  🔴 botify_browser  browser   botify_browser RED gate2: 7 live cookies for botify.com but none HttpOnly — the site was VISITED, not logged into
  🟢 semrush         browser   semrush GREEN 5 HttpOnly cookie(s) for semrush.com in profile 'semrush' (32 live total, soonest expiry 20d)

# 6 green | 2 red | 2 unchecked
# Fix a red:  python scripts/connectors/wallet.py warm <slot>
# An unchecked slot blocks GOLD on purpose: give it a --check, or bench it with "enrolled": false.
(nix) pipulate $

…which is pretty amazing on its own is actually this function:

          warm() {
            if [ "$#" -eq 0 ]; then
              "$PIPULATE_ROOT/.venv/bin/python" "$PIPULATE_ROOT/scripts/connectors/wallet.py" check
            else
              "$PIPULATE_ROOT/.venv/bin/python" "$PIPULATE_ROOT/scripts/connectors/wallet.py" warm "$@"
            fi
          }

Which means I can also type this which I saw in one of the probes above:

(nix) pipulate $ python scripts/connectors/wallet.py
# wallet.py — connector auth scoreboard (read-only, offline)
# wallet: /home/mike/.config/pipulate/connectors.json
# repo:   /home/mike/repos/pipulate  (anchors browser_session profiles)
# stale after: 7d — mtime heuristic for oauth/browser, not a validity proof

     state    kind      slot            evidence                                                                   where
  [x] filled   oauth     gmail           2026-08-31 (0d ago)                                                        /home/mike/.config/pipulate/gmail_token.json
  [x] filled   oauth     sheets          2026-08-31 (0d ago)                                                        /home/mike/.config/pipulate/sheets_token.json
  [x] filled   bearer    <redacted:6>          set: BOTIFY_API_TOKEN (env)                                                env / .env (out of git)
  [x] filled   basic     confluence      set: CONFLUENCE_URL (env), CONFLUENCE_EMAIL (env), CONFLUENCE_TOKEN (env)  env / .env (out of git)
  [x] filled   svc-acct  gsc             2026-07-15 (48d ago)                                                       /home/mike/.config/pipulate/service-account-key.json
  [x] filled   basic     jira            set: JIRA_URL (env), JIRA_EMAIL (env), JIRA_TOKEN (env)                    env / .env (out of git)
  [x] filled   bearer    <redacted:5>           set: SLACK_USER_TOKEN (env)                                                env / .env (out of git)
  [/] partial  basic     gong            set: GONG_ACCESS_KEY (env) | unset: GONG_ACCESS_KEY_SECRET                 env / .env (out of git)
  [~] stale    browser   botify_browser  2026-07-29 (34d ago)                                                       /home/mike/repos/pipulate/data/uc_profiles/botify
  [~] stale    browser   semrush         2026-07-23 (40d ago)                                                       /home/mike/repos/pipulate/data/uc_profiles/semrush

# 7 filled | 2 stale | 1 partial | 0 empty
# Next: python scripts/connectors/wallet.py warm botify_browser   (confirms, then opens this slot's own site + profile)
(nix) pipulate $

…which is really giving me the sort of transparency that I need. But I do need the Botify MCP server to show up in this process, which by the way uses MCP OAuth whereas this other MCP server I have to get into the swing of using that doesn’t have its own dedicated WET connector file is for the Botify MCP renderer which is going to take a lot of special handling because it’s MCP that both has a time-delayed “render farm” to wait for the results of like build a job, make the request then keep pooling until the result is available type of scheme on top of having to carry session from one request to the next. So there’s double-whammy complexity in there and maybe more but I have to go there. We don’t have to do it in this turn, but maybe somewhere during this discussion-ride / article; we’ll see based on the peeling away of the layers here to obtain clarity which I’m finally starting to feel.

1: Probe:

Blast Radius Check to establish bisection Left-hand Causal Boundary. It is a Popper-thing. Science.
On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean
(nix) pipulate $ cat ~/.config/pipulate/.env | sed 's/=.*/=******/'
python scripts/connectors/wallet.py
CONFLUENCE_EMAIL=******
# JIRA_TOKEN=******
SLACK_USER_TOKEN=******
GONG_ACCESS_KEY=******
JIRA_URL=******
JIRA_EMAIL=******
JIRA_TOKEN=******
# wallet.py — connector auth scoreboard (read-only, offline)
# wallet: /home/mike/.config/pipulate/connectors.json
# repo:   /home/mike/repos/pipulate  (anchors browser_session profiles)
# stale after: 7d — mtime heuristic for oauth/browser, not a validity proof

     state    kind      slot            evidence                                                                   where
  [x] filled   oauth     gmail           2026-08-31 (0d ago)                                                        /home/mike/.config/pipulate/gmail_token.json
  [x] filled   oauth     sheets          2026-08-31 (0d ago)                                                        /home/mike/.config/pipulate/sheets_token.json
  [x] filled   bearer    <redacted:6>          set: BOTIFY_API_TOKEN (env)                                                env / .env (out of git)
  [x] filled   basic     confluence      set: CONFLUENCE_URL (env), CONFLUENCE_EMAIL (env), CONFLUENCE_TOKEN (env)  env / .env (out of git)
  [x] filled   svc-acct  gsc             2026-07-15 (48d ago)                                                       /home/mike/.config/pipulate/service-account-key.json
  [x] filled   basic     jira            set: JIRA_URL (env), JIRA_EMAIL (env), JIRA_TOKEN (env)                    env / .env (out of git)
  [x] filled   bearer    <redacted:5>           set: SLACK_USER_TOKEN (env)                                                env / .env (out of git)
  [/] partial  basic     gong            set: GONG_ACCESS_KEY (env) | unset: GONG_ACCESS_KEY_SECRET                 env / .env (out of git)
  [~] stale    browser   botify_browser  2026-07-29 (34d ago)                                                       /home/mike/repos/pipulate/data/uc_profiles/botify
  [~] stale    browser   semrush         2026-07-23 (40d ago)                                                       /home/mike/repos/pipulate/data/uc_profiles/semrush

# 7 filled | 2 stale | 1 partial | 0 empty
# Next: python scripts/connectors/wallet.py warm botify_browser   (confirms, then opens this slot's own site + profile)
(nix) pipulate $ 

2: Context:

! cat ~/.config/pipulate/.env | sed 's/=.*/=******/'
! python scripts/connectors/wallet.py

3: Patches: None

Ignition, sed or post-patch-pre-ahc probes here.

4: Prompt: Review the masked vault keys and wallet scoreboard. Everything is aligned with the flat .env vault architecture. What connector or workflow step should we tackle next?

5: Deliverables: None

How are we doing? I want to be able to more or less hand this system over to anyone. Anyone can install it using:

curl -fsSL https://pipulate.com/install.sh | bash

DetSys will conditionally install and tell them to run the installer again (just once per machine) and then after that they can cd pipulate usually from their default terminal, then nix develop and hit “2” to drop into the terminal and then they can just start using these tools, right? And if not why not? I’m still confused. Why do I have warm pointing to wallet.py but then I also have mcp_warm.py? I have a universal MCP connector whose warming issues are different because you might be using a different MCP server endpoint every time you use the generic one. But then there are also the WET connectors which it almost doesn’t matter it’s MCP, but which I fear I may have mixed semantics with the universal one. Can you help me sort out what’s what file-wise and imagine an login-warming experience that talks to you saying “Here are the standard connectors we expect you to warm up once.” and that would include everything for which you’re supposed to bring your own API-key or bearer <redacted:5> and it would say so out loud for each. And then it would walk you through each OAuth login too so you could get a refresh token for each and forever forward not have to worry about OAuth login again unless the refresh token is revoked. And so on.

Once they’re “warmed up” then we can start doing some fancy context-compiling. See? How far are we from that vision?

Gemini 3.7 Flash Extended: Context verified.

You are much closer to this vision than it feels. In fact, roughly 90% of the machinery is already built and working on disk.


1. Sorting Out the Files: Why warm vs. mcp_warm.py?

The confusion comes from two different levels of abstraction: the Conductor vs. the Specialized Instrument.

┌──────────────────────────────────────────────────────────────────┐
│                   THE CONDUCTOR (wallet.py)                      │
│                  Dispatches: warm / check / login                │
└──────────────┬──────────────────┬─────────────────┬──────────────┘
               │                  │                 │
    [oauth_token_file]    [browser_session]   [oauth_pkce (MCP)]
               │                  │                 │
       ┌───────┴──────┐           │                 │
       │  Reuses own  │           ▼                 ▼
       │ connector    │      weblogin.py       mcp_warm.py
       │ get_service()│   (Chrome profile)   (RFC 9728 / 8414)
       └──────────────┘
  1. scripts/connectors/wallet.py (warm) is the Conductor: It is the master scoreboard and router. It does not know how to log into Google, Chrome, or Atlassian directly; it reads ~/.config/pipulate/connectors.json and delegates to whatever tool owns that auth kind:
    • Paste kinds (bearer_token, basic_auth): wallet.py prompts the user and writes to ~/.config/pipulate/.env.
    • Google OAuth (oauth_token_file): wallet.py delegates to gmail.py or sheets.py (get_service()).
    • Browser Profiles (browser_session): wallet.py delegates to scripts/weblogin.py.
    • MCP Remote OAuth (mcp): It should delegate to scripts/connectors/mcp_warm.py.
  2. scripts/connectors/mcp_warm.py is a Specialized Worker: Just as weblogin.py specializes in launching undetected-chromedriver to capture session cookies, mcp_warm.py specializes in OAuth 2.1 PKCE with RFC 9728 Protected Resource Discovery. It knows how to talk to authorization servers, handle dynamic client registration (RFC 7591), catch the localhost redirect, and write the token to ~/.config/pipulate/mcp/<host>.json.
  3. WET Connectors vs. Generic mcp.py:
    • WET Connectors (jira.py, slack.py, confluence.py, botify.py, sheets.py, gmail.py): Self-contained, single-file Unix gateways. They talk directly to REST APIs, format output for token-budgeted prompt injection, and do not require an MCP server to exist on the remote side.
    • mcp.py (The Universal Client): Connects to any remote server speaking Model Context Protocol over Streamable HTTP (JSON-RPC). It handles tool discovery (tools/list) and invocation (tools/call).

2. The Spoken “First Warm” Experience: How Far Are We?

You have all the necessary parts:

  • The Voice Engine: from imports.voice_synthesis import chip_voice_system; cvs.speak_text(...) is already installed and used in mother_cat.py, boot_menu.py, and flake.nix.
  • The State Engine: wallet.py already checks every slot, knows which are cold (empty or stale), and knows what needs to be asked.
  • The Storage Engine: flake.nix automatically sources ~/.config/pipulate/.env and exports it into the Nix development shell.

What the Spoken First-Run Experience Looks Like

When a newcomer runs nix develop, picks Door 2, and runs warm (or an automated warm --welcome during onboarding):

  1. Audio Greeting:

    “Welcome to your workshop. Let’s warm up your credentials once so your tools can reach outside this machine. I will guide you through each connector.”

  2. API Keys / Paste Kinds (Botify, Confluence, Jira, Slack, Gong):

    “First, let’s configure your API tokens. For Botify, please paste your API token. Press Enter to skip if you do not use Botify.” (Writes to ~/.config/pipulate/.env with 0600 permissions).

  3. Google OAuth (Gmail & Sheets):

    “Now opening your browser for Google Workspace authentication. Sign in to authorize Gmail and Google Sheets.” (Runs the local loopback server, mints gmail_token.json and sheets_token.json).

  4. Browser Sessions (Botify Web, SEMrush):

    “Opening a persistent browser window for SEMrush. Log in and close the window when you’re done.” (Runs weblogin.py to persist session cookies).

  5. Scoreboard & Completion:

    “All set. Your wallet is green and your credentials are saved in your local vault outside of git.”


(1) PROBES

python -c "import sys; sys.path.insert(0, 'scripts/connectors'); import wallet; print('Wallet slots:', list(wallet.load_wallet().keys()))"
python -c "from pathlib import Path; import os; p = Path.home() / '.config/pipulate/connectors.json'; print(f'Connectors file exists: {p.exists()}')"

(2) NEXT CONTEXT

! python -c "import sys; sys.path.insert(0, 'scripts/connectors'); import wallet; print('Wallet slots:', list(wallet.load_wallet().keys()))"
! python -c "from pathlib import Path; import os; p = Path.home() / '.config/pipulate/connectors.json'; print(f'Connectors file exists: {p.exists()}')"

(3) PATCHES

No repo patches required.


(4) PROMPT

Review the probe receipts. Would you like to wire `mcp_warm.py` as the official delegate for `mcp` slots in `wallet.py`, or begin adding the spoken TTS narration to the `warm` interactive loop?

(5) EXTERNAL DELIVERABLES

None this turn.

MikeLev.in:

1: Probe:

$ git status
On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean
(nix) pipulate $ python -c "import sys; sys.path.insert(0, 'scripts/connectors'); import wallet; print('Wallet slots:', list(wallet.load_wallet().keys()))"
python -c "from pathlib import Path; import os; p = Path.home() / '.config/pipulate/connectors.json'; print(f'Connectors file exists: {p.exists()}')"
Wallet slots: ['_schema', '_rule', 'gmail', 'sheets', 'botify', 'confluence', 'gsc', 'jira', 'slack', 'gong', 'botify_browser', 'semrush']
Connectors file exists: True
(nix) pipulate $ 

2: Context:

# adhoc.txt    _   _   _ to set context____ _   _  ___  ____  _   <F5> Simpson Couch Gag Here (explain anything to the audience you feel needs it explained)
#     / \   __| | | | | | ___   ___   / ___| | | |/ _ \|  _ \| |  
# ahe/ _ \ / _` | | |_| |/ _ \ / __| | |   | |_| | | | | |_) | |  90% built. I like the sound of that.
# ahc ___ \ (_| | |  _  | (_) | (__  | |___|  _  | |_| |  __/|_|  
#  /_/   \_\__,_| |_| |_|\___/ \___|  \____|_| |_|\___/|_|   (_)  
# Ad Hoc CHOP: The Not-Managed-by-Git Safe-for-Client-Data place  

#    # THE ROLLING PIN BOOK ORE SPINE / FODDER FOR THE BOOK
#    ! python scripts/articles/lsa.py -t 1 --reverse --fmt dated-slugs  # <-- The "Rolling Pin" that gives the 40K foot book-spine view of book-ore.
#    GLOSSARY.md                 # <-- I think this glossary goes well with the book-ore spine to do world building.
#    # scripts/articles/lsa.py     # <-- Useful for refining commands like `posts`, critical to Second Brain concept.
#     
#    # THE QUIRKY AMIGA-LOVING HUMAN
#    # ~/repos/nixos/autognome.py  # <-- Letting the AIs really understand my environment (The Brave Little Tailor punches above Their Weight Class proving the dunning-kruger effect the gate-keeper's (lower-case) lament.)
#    # init.lua                    # <-- Daily driver hot-keys that overlap with aliases in flake.nix
#     
#    # AGENTIC FRAMEWORK & FOREVER MACHINE BIG FILES
#    prompt_foo.py               # <-- Prompt Fu compiler, makes the very README for AGENTS-like payload you're reading right now, but it needs to be more like that
#    foo_files.py                # <-- This is the router, evolving book outline and the things you pin-up to produced the recursive self-improvement loops
#    flake.nix                   # <-- Solves world's WRITE ONCE RUN ANYWHERE problem like Java never could. Also resolves the bootstrap paradox.
#    
#    # MAIN ACTUATORS, IaC & NEGATIVE SPACE
#    apply.py                    # <-- How can "Web UI" ChatBots edit your code? With this Aider-inspired Player Piano patch applier.
#    .gitattributes              # <-- Model: understand that `nbstripout` and `jupytext` are both in play. Just talk the human through .ipynb patches.
#    .gitignore                  # <-- Creates "negative space" for sub-rep's to share parent environment and "snap" proprietary secret features into place.
#    requirements.in             # <-- All known dependencies and (necessary) version pinning. WORA gotcha's exposed.
#    __init__.py                 # <-- Master versioning
#    pyproject.toml              # <-- The PyPI Packaging details
#    
#    # cli.py                      # <-- Catch-all actuator for PyPI envs, Python anchoring, MCP tool-call (plus alternatives) and **kwargs like wrapping for CLI
#    # scripts/xp.py               # <-- Transforms host OS copy-paste buffer player-piano music into context-payload.
#    # scripts/ai.py               # <-- How I constantly use local AI to write git commit messages with `m` alias.
#     
#    # CONTEXT PORTABILITY SYSTEM
#    3 scripts/foo_cartridge.py    # Needs description
#    3 scripts/foo_replay.py       # Needs description
#          
#    # FREQUENTLY USEFUL TO HAVE IN CONTEXT
#    # release.py                  # <-- How everything ends up where it does (GitHub, PyPI, etc.)
#    
#    # scripts/weblogin.py         # <-- Lets the user "warm up" the cache for their web logins at their leisure on a profile that persists.
#    # scripts/crawl.py            # <-- Feel free to ask for something to be crawled and included in the next turn.
#    
#    # imports/voice_synthesis.py  # <-- The wand can talk to you
#    # scripts/release/version_sync.py  # <-- Needs to be wrapped into release.py and eliminated, I think.
#    
#    #                         --- Under this line is were you paste what the AI gives you ---
#    #                         --- We call it context but it's really just the right-hand  ---
#    #                         --- blast-radius of the "probes" to make this all science.  ---
#    
#    # --- END `adhoc.txt` TEMPLATE ---
#    
#    # server.py
#     

flake.nix
.gitattributes              # <-- Model: understand that `nbstripout` and `jupytext` are both in play. Just talk the human through .ipynb patches.
.gitignore                  # <-- Creates "negative space" for sub-rep's to share parent environment and "snap" proprietary secret features into place.
requirements.in             # <-- All known dependencies and (necessary) version pinning. WORA gotcha's exposed.
__init__.py                 # <-- Master versioning
pyproject.toml              # <-- The PyPI Packaging details

# STICKBUG & MOTHER CAT KATA
# assets/trails/botify_pageworkers.yaml
assets/installer/mck.sh
assets/installer/replay.sh
assets/trails/first_context.yaml
assets/trails/practice.yaml
assets/trails/public_walk.yaml
scripts/bookmark_import.py
scripts/boot_menu.py
scripts/connectors/README.md
scripts/connectors/botify.py
scripts/connectors/confluence.py
scripts/connectors/gmail.py
scripts/connectors/gsc.py
scripts/connectors/jira.py
scripts/connectors/mcp.py
scripts/connectors/mcp_warm.py
scripts/connectors/sheets.py
scripts/connectors/slack.py
scripts/connectors/wallet.py
scripts/mother_cat.py
scripts/sources_menu.py
scripts/walk.py
scripts/walk_cartridge.py
scripts/walk_compile.py
scripts/weblogin.py
tools/scraper_tools.py

#    # # adhoc.txt -- Cleanup inert public_walk environment export block
#    # 
#    # # --- BEFORE/AFTER STRADDLE ---
#    # ! grep -n -C 2 'PIPULATE_TRAIL_WALK_ONE_URL' assets/installer/mck.sh || echo "export_block_removed"
#    # ! bash -n assets/installer/mck.sh; echo "mck_syntax=$?"
#    # ! bash assets/installer/mck.sh --where
#    # 
#    # # --- TARGET SCRIPT ---
#    # assets/installer/mck.sh
#    # 
#    # ! rg -n -F 'TRAIL_NAME="${TRAIL_NAME:-public_walk}"' assets/installer/mck.sh
#    # ! rg -n -e '^ *walk\(\)' -e '^ *alias walk=' flake.nix; echo "shell_walk_exit=$?"
#    # ! test -e walk; echo "root_walk_exists=$?"
#    # ! rg -n -e 'alias mothercat=' -e 'Three words to start from' flake.nix scripts/boot_menu.py
#    # flake.nix
#    # scripts/boot_menu.py
#    # assets/installer/mck.sh
#    
#    # ! bash -n walk; echo "walk_syntax=$?"
#    # ! bash walk --where
#    # ! rg -n -e '^ *alias walk=' -e '^ *walk\(\)' -e 'writeShellScriptBin "walk"' flake.nix; echo "shell_walk_exit=$?"
#    # ! .venv/bin/python -c 'import sys; sys.path.insert(0,"scripts"); import boot_menu as b; print("count=" + b._count_word(len(b.DOOR_TWO_WORDS))); print(" ".join(w for w, _ in b.DOOR_TWO_WORDS))'
#    # walk
#    # flake.nix
#    # scripts/boot_menu.py
#    # assets/installer/mck.sh
#    
#    # ! rg -in "three words|four words|door 2" flake.nix scripts/ assets/ README.md
#    # ! walk --where
#    # walk
#    # flake.nix
#    # scripts/boot_menu.py
#    # assets/installer/mck.sh
#    
#    # ! python scripts/connectors/wallet.py check slack
#    # ! python -c "import os, sys; from pathlib import Path; sys.path.insert(0, '.'); from scripts.connectors import wallet; pairs = wallet._dotenv_pairs(); print('env SLACK_USER_TOKEN:', bool(os.getenv('SLACK_USER_TOKEN')), 'env SLACK_BOT_TOKEN:', bool(os.getenv('SLACK_BOT_TOKEN')), 'vault SLACK_USER_TOKEN:', 'SLACK_USER_TOKEN' in pairs, 'vault SLACK_BOT_TOKEN:', 'SLACK_BOT_TOKEN' in pairs)"
#    # ! python scripts/connectors/wallet.py warm slack --dry-run
#    
#    # Now that slack is working (on my workspace) we trim back things here to just the stick bug and Mother Cat Kata stuff

# /home/mike/repos/trimnoir/_posts/2026-08-30-terminal-native-mcp-practice.md
# deleteme.txt
# assets/nbs/Onboarding.ipynb                       # <-- Where Workflows go to get born. Common `.venv` to FastHTML. Also to Cursor or VSCode too if you know how to set it up.
# assets/nbs/imports/core_sauce.py                  # <-- Make those workflows being born in Notebooks look clean by pushing the sausage factory back to code-behind Python secret sauce import modules.
# assets/nbs/imports/onboard_sauce.py               # <-- Now you're cooking!
# apps/015_config.py                                # <-- Carrying onboarding values over to FastHTML-side, especially dotenv values. New home for Botify API key!

! python -c "import sys; sys.path.insert(0, 'scripts/connectors'); import wallet; print('Wallet slots:', list(wallet.load_wallet().keys()))"
! python -c "from pathlib import Path; import os; p = Path.home() / '.config/pipulate/connectors.json'; print(f'Connectors file exists: {p.exists()}')"

3: Patches: None

4: Prompt: Review the probe receipts. Would you like to wire mcp_warm.py as the official delegate for mcp slots in wallet.py, or begin adding the spoken TTS narration to the warm interactive loop?

5: Deliverables: None

Help me review the clean concepts. We warm up everything where the login stays a non-moving target. I don’t know what to do with mcp_warm.py. Maybe we can warm any ad hoc MCP server we’re using, but I tend to think we will be hitting 2 or 3 MCP servers at once in the same context bundle and if they don’t each have their own WET connector than the universal one will have to be recycled with different endpoints and logins so there’s a collision issue. Help think though all this.

Fable 5 Extra: Context verified. The files needed for this design question — wallet.py, mcp.py, mcp_warm.py, the connector README, and the live wallet receipts — are all present, and the Manifest’s LIVE COMMAND RECEIPTS confirm the wallet holds exactly 10 slots (plus _schema/_rule) and no MCP slot exists. That absence is the whole gap, and it’s smaller than you think.


1. The collision you fear is already unrepresentable — at the file layer

You wrote: “the universal one will have to be recycled with different endpoints and logins so there’s a collision issue.” Recycling the client does not recycle the credential, because mcp.py and mcp_warm.py both carry token_path_for(): the credential path is a pure function of the server URL. https://mcp.botify.com/ reads and writes ~/.config/pipulate/mcp/mcp.botify.com.json; a second server reads and writes its own file; a third costs zero configuration lines. This is the DERIVED-PATH RULE aimed at credentials, and its own docstring says why: “a bearer <redacted:6> for one resource is structurally incapable of being sent to another.” The one historical leak — the pre-derivation mcp_botify_token.json answering for any server — was convicted and scoped on 2026-08-29: the legacy file now answers only for the resource recorded inside it. So two or three MCP servers in one context bundle each resolve their own token, in the same process, with no ceremony. That collision is dead.

2. Where collision genuinely lives: the env lane

The real hazard is the one mcp.py’s own --auth-scheme comment names: MCP_BEARER_TOKEN is shell-global. The env rungs (--token-env, MCP_BEARER_TOKEN, MCP_TOKEN_FILE) outrank the derived file in resolve_token(), and an env var wins for every server in the terminal. This is the same disease as the SHADOWED warning in wallet warm — a stale export outranking the vault. The clean rule: env-lane tokens are for one-off experiments against a single server; anything you’ll hit twice gets a warmed file. A future guard (warn when the env lane resolves for a server whose derived file also exists) is cheap, but it’s an idea, not this car.

3. WET vs. universal is about target motion, not protocol

Your phrase “warm up everything where the login stays a non-moving target” is exactly the right sorting principle — it just needs a third row:

Target Code Credential home On the board?
Fixed daily-driver REST service (jira, slack, gmail…) its own WET connector vault .env or its own token file Yes — enrolled slot
Ad hoc MCP server, tried once universal mcp.py derived mcp/<host>.json No — deliberately
Promoted MCP server (mcp.botify.com becomes daily) still universal mcp.py still the derived path Yes — via a slot, not a file

The key insight: promotion is data, not code. An MCP server graduating to daily use should never mint a new WET .py file — the universal client already speaks its protocol and already derives its credential. What promotion mints is a connectors.json entry, because a slot is what enrollment is: it’s how a credential earns a row on the board, a red that blocks GOLD, and a warm verb. Ad hoc servers staying off the board is a feature — the wallet’s own docstring says enrollment is what keeps GOLD winnable.

4. The missing rung: a sixth auth kind

wallet.py knows five auth kinds. The sixth is roughly mcp_oauth, and the slot is pure data:

"botify_mcp": {
  "auth": "mcp_oauth",
  "defaults": { "resource": "https://mcp.botify.com/" }
}

Wiring it needs three dispatch arms, each delegating to a mechanism that already exists:

  • Scoreboard (offline): stat token_path_for(defaults.resource) — an mtime kind, same honest heuristic as oauth. Note wallet.py cannot import mcp.py (the WET contract), so this is a third verbatim copy of token_path_for, and the existing compare-by-probe simply gains a third leg. Cost named, mitigation already established.
  • Check (live): dispatch by kind (exactly as browser_session already does — this matters, because the filename dispatch would look for botify_mcp.py, find nothing, and render “no connector module” forever). The check shells to mcp.py <resource> --check, which already speaks the exit-code protocol and already prints a GREEN line with protocol/session/tools/auth.
  • Warm: dispatch to mcp_warm.py --refresh <resource> when the token file carries a refresh_token (headless, the Cinderella rung), falling back to the browser PKCE mint on a TTY. Both paths exist today; the wallet just gets to name them.

That’s the entire distance between “the Botify MCP credential is invisible” and “it’s a row on warm.”

5. The renderer is a different problem — don’t let it blur this one

The Botify MCP renderer (submit job → poll until done, carrying session across requests) is workflow complexity, not auth complexity. The mcp_oauth kind gets its credential on the board; the polling lives above the envelope. Two open questions the FDR receipts will answer on the first real flight: does the server accept a fresh initialize per poll (job id as the state carrier — cheap, and mcp.py works as-is), or does it demand one Mcp-Session-Id across the whole job (which would justify a thin session-holding driver later)? mcp.py already records session_id_sent/session_id_returned in every receipt, so the first flight is the witness. Build nothing until it lands.

6. Distance to the hand-over vision

For “curl install → nix develop → press 2 → warm → go,” the remaining rungs are short: (a) the mcp_oauth kind above; (b) a starter walletconnectors.json is names-and-paths only, so a tracked template (or a wallet init verb) is safe to ship, and today a stranger gets the cold-start card and must author their own; (c) housekeeping that blocks GOLD: gong has no module and half a credential (give it a --check or bench it with "enrolled": false), and gsc needs its bogus-probe-style --check. The spoken TTS narration is polish on top of a loop that already prompts per slot. You’re one patch, one template file, and two housekeeping edits from the vision.


(1) PROBES — the first is mcp.py’s identity walk: read-only by construction (no socket, no receipt armed), it shows every warmed MCP credential and its clock, proving what the board currently can’t see. The second is the straddle the token_path_for docstrings themselves demand: the twins must agree byte-for-byte, including trailing-slash and path-slug cases, before a third copy lands in wallet.py. The third reads the wallet’s declared auth kinds (names only, never values), establishing that no mcp kind exists in the data either. The fourth reads only the resource field of the legacy token file, confirming what the scoped fallback will answer for.

python scripts/connectors/mcp.py
python -c "import sys; sys.path.insert(0, 'scripts/connectors'); import mcp, mcp_warm; urls = ['https://mcp.botify.com', 'https://mcp.botify.com/', 'https://example.com/sub/path']; print('token_path_for twins agree:', all(mcp.token_path_for(u) == mcp_warm.token_path_for(u) for u in urls))"
jq -r 'to_entries[] | select(.value|type=="object") | "\(.key): \(.value.auth // "n/a")"' ~/.config/pipulate/connectors.json
python -c "import json; from pathlib import Path; p = Path.home() / '.config/pipulate/mcp_botify_token.json'; print('legacy resource:', json.loads(p.read_text()).get('resource') if p.exists() else '(no legacy file)')"

(2) NEXT CONTEXT — probe echoes plus the files the wiring patch will touch:

scripts/connectors/wallet.py
scripts/connectors/mcp.py
scripts/connectors/mcp_warm.py
scripts/connectors/README.md
scripts/sources_menu.py
! python scripts/connectors/mcp.py
! python -c "import sys; sys.path.insert(0, 'scripts/connectors'); import mcp, mcp_warm; urls = ['https://mcp.botify.com', 'https://mcp.botify.com/', 'https://example.com/sub/path']; print('token_path_for twins agree:', all(mcp.token_path_for(u) == mcp_warm.token_path_for(u) for u in urls))"
! jq -r 'to_entries[] | select(.value|type=="object") | "\(.key): \(.value.auth // "n/a")"' ~/.config/pipulate/connectors.json
! python -c "import json; from pathlib import Path; p = Path.home() / '.config/pipulate/mcp_botify_token.json'; print('legacy resource:', json.loads(p.read_text()).get('resource') if p.exists() else '(no legacy file)')"

(3) PATCHES — No repo patches required this turn; this was a design turn, and the wiring patch is gated on the twin-agreement probe. No ignition required.

(4) PROMPT — the caboose copy for the next turn:

Review the receipts. If the token_path_for twins agree and the identity walk shows the warmed Botify MCP credential, emit the wiring patch for wallet.py: add the sixth auth kind `mcp_oauth` (offline scoreboard stats the derived token path from defaults.resource via a third verbatim token_path_for copy; check dispatches BY KIND to `mcp.py <resource> --check` the way browser_session already dispatches; warm delegates to `mcp_warm.py --refresh <resource>` when the token file holds a refresh_token, else the browser PKCE mint). Close with the exact `botify_mcp` slot JSON for ~/.config/pipulate/connectors.json as an external deliverable, and name the gong/gsc housekeeping (bench or --check) that unblocks GOLD.

(5) EXTERNAL DELIVERABLES — None this turn. The botify_mcp slot JSON for ~/.config/pipulate/connectors.json ships next turn alongside the patch it depends on, so the slot never exists before the kind that can read it.

MikeLev.in:

1: Probe:

Blast Radius Check to establish bisection Left-hand Causal Boundary. It is a Popper-thing. Science.
On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean
(nix) pipulate $ python scripts/connectors/mcp.py
python -c "import sys; sys.path.insert(0, 'scripts/connectors'); import mcp, mcp_warm; urls = ['https://mcp.botify.com', 'https://mcp.botify.com/', 'https://example.com/sub/path']; print('token_path_for twins agree:', all(mcp.token_path_for(u) == mcp_warm.token_path_for(u) for u in urls))"
jq -r 'to_entries[] | select(.value|type=="object") | "\(.key): \(.value.auth // "n/a")"' ~/.config/pipulate/connectors.json
python -c "import json; from pathlib import Path; p = Path.home() / '.config/pipulate/mcp_botify_token.json'; print('legacy resource:', json.loads(p.read_text()).get('resource') if p.exists() else '(no legacy file)')"
# mcp token clock: EXPIRED 112696s ago (life was 300s); refresh_token present: yes; re-mint with python scripts/connectors/mcp_warm.py
# mcp.py -- replay client for remote MCP servers (Streamable HTTP)
# protocol : 2025-06-18 (INFERRED until a GREEN --check)
# token    : file lane resolved from mcp_botify_token.json (value never printed)
#            resolved is not accepted -- only --check posts
# creds    : 1 warmed file(s); values never printed
#            mcp_botify_token (pre-derivation) -- EXPIRED 112696s ago (life was 300s); refresh_token present: yes; re-mint with python scripts/connectors/mcp_warm.py
#
# This client never guesses a server. Name one:
#   mcp <server> --check           envelope health; exit code is the answer
#   mcp <server>                   initialize -> tools/list
#   mcp <server> <tool> '<json>'   initialize -> tools/call
#
# Mint or refresh a bearer:  python scripts/connectors/mcp_warm.py
token_path_for twins agree: True
gmail: oauth_token_file
sheets: oauth_token_file
botify: bearer_token
confluence: basic_auth
gsc: service_account_file
jira: basic_auth
slack: bearer_token
gong: basic_auth
botify_browser: browser_session
semrush: browser_session
legacy resource: https://mcp.botify.com/
(nix) pipulate $

2: Context:

# adhoc.txt    _   _   _ to set context____ _   _  ___  ____  _   <F5> Simpson Couch Gag Here (explain anything to the audience you feel needs it explained)
#     / \   __| | | | | | ___   ___   / ___| | | |/ _ \|  _ \| |  
# ahe/ _ \ / _` | | |_| |/ _ \ / __| | |   | |_| | | | | |_) | |  Sorting MCP Universal versus WET
# ahc ___ \ (_| | |  _  | (_) | (__  | |___|  _  | |_| |  __/|_|  
#  /_/   \_\__,_| |_| |_|\___/ \___|  \____|_| |_|\___/|_|   (_)  
# Ad Hoc CHOP: The Not-Managed-by-Git Safe-for-Client-Data place  

#    # THE ROLLING PIN BOOK ORE SPINE / FODDER FOR THE BOOK
#    ! python scripts/articles/lsa.py -t 1 --reverse --fmt dated-slugs  # <-- The "Rolling Pin" that gives the 40K foot book-spine view of book-ore.
#    GLOSSARY.md                 # <-- I think this glossary goes well with the book-ore spine to do world building.
#    # scripts/articles/lsa.py     # <-- Useful for refining commands like `posts`, critical to Second Brain concept.
#     
#    # THE QUIRKY AMIGA-LOVING HUMAN
#    # ~/repos/nixos/autognome.py  # <-- Letting the AIs really understand my environment (The Brave Little Tailor punches above Their Weight Class proving the dunning-kruger effect the gate-keeper's (lower-case) lament.)
#    # init.lua                    # <-- Daily driver hot-keys that overlap with aliases in flake.nix
#     
#    # AGENTIC FRAMEWORK & FOREVER MACHINE BIG FILES
#    prompt_foo.py               # <-- Prompt Fu compiler, makes the very README for AGENTS-like payload you're reading right now, but it needs to be more like that
#    foo_files.py                # <-- This is the router, evolving book outline and the things you pin-up to produced the recursive self-improvement loops
#    flake.nix                   # <-- Solves world's WRITE ONCE RUN ANYWHERE problem like Java never could. Also resolves the bootstrap paradox.
#    
#    # MAIN ACTUATORS, IaC & NEGATIVE SPACE
#    apply.py                    # <-- How can "Web UI" ChatBots edit your code? With this Aider-inspired Player Piano patch applier.
#    .gitattributes              # <-- Model: understand that `nbstripout` and `jupytext` are both in play. Just talk the human through .ipynb patches.
#    .gitignore                  # <-- Creates "negative space" for sub-rep's to share parent environment and "snap" proprietary secret features into place.
#    requirements.in             # <-- All known dependencies and (necessary) version pinning. WORA gotcha's exposed.
#    __init__.py                 # <-- Master versioning
#    pyproject.toml              # <-- The PyPI Packaging details
#    
#    # cli.py                      # <-- Catch-all actuator for PyPI envs, Python anchoring, MCP tool-call (plus alternatives) and **kwargs like wrapping for CLI
#    # scripts/xp.py               # <-- Transforms host OS copy-paste buffer player-piano music into context-payload.
#    # scripts/ai.py               # <-- How I constantly use local AI to write git commit messages with `m` alias.
#     
#    # CONTEXT PORTABILITY SYSTEM
#    3 scripts/foo_cartridge.py    # Needs description
#    3 scripts/foo_replay.py       # Needs description
#          
#    # FREQUENTLY USEFUL TO HAVE IN CONTEXT
#    # release.py                  # <-- How everything ends up where it does (GitHub, PyPI, etc.)
#    
#    # scripts/weblogin.py         # <-- Lets the user "warm up" the cache for their web logins at their leisure on a profile that persists.
#    # scripts/crawl.py            # <-- Feel free to ask for something to be crawled and included in the next turn.
#    
#    # imports/voice_synthesis.py  # <-- The wand can talk to you
#    # scripts/release/version_sync.py  # <-- Needs to be wrapped into release.py and eliminated, I think.
#    
#    #                         --- Under this line is were you paste what the AI gives you ---
#    #                         --- We call it context but it's really just the right-hand  ---
#    #                         --- blast-radius of the "probes" to make this all science.  ---
#    
#    # --- END `adhoc.txt` TEMPLATE ---
#    
#    # server.py
#     

flake.nix
.gitattributes              # <-- Model: understand that `nbstripout` and `jupytext` are both in play. Just talk the human through .ipynb patches.
.gitignore                  # <-- Creates "negative space" for sub-rep's to share parent environment and "snap" proprietary secret features into place.
requirements.in             # <-- All known dependencies and (necessary) version pinning. WORA gotcha's exposed.
__init__.py                 # <-- Master versioning
pyproject.toml              # <-- The PyPI Packaging details

# # STICKBUG & MOTHER CAT KATA
# # assets/trails/botify_pageworkers.yaml
# assets/installer/mck.sh
# assets/installer/replay.sh
# assets/trails/first_context.yaml
# assets/trails/practice.yaml
# assets/trails/public_walk.yaml
# scripts/bookmark_import.py
# scripts/boot_menu.py
# scripts/connectors/README.md
# scripts/connectors/botify.py
# scripts/connectors/confluence.py
# scripts/connectors/gmail.py
# scripts/connectors/gsc.py
# scripts/connectors/jira.py
# scripts/connectors/mcp.py
# scripts/connectors/mcp_warm.py
# scripts/connectors/sheets.py
# scripts/connectors/slack.py
# scripts/connectors/wallet.py
# scripts/mother_cat.py
# scripts/sources_menu.py
# scripts/walk.py
# scripts/walk_cartridge.py
# scripts/walk_compile.py
# scripts/weblogin.py
# tools/scraper_tools.py

#    # # adhoc.txt -- Cleanup inert public_walk environment export block
#    # 
#    # # --- BEFORE/AFTER STRADDLE ---
#    # ! grep -n -C 2 'PIPULATE_TRAIL_WALK_ONE_URL' assets/installer/mck.sh || echo "export_block_removed"
#    # ! bash -n assets/installer/mck.sh; echo "mck_syntax=$?"
#    # ! bash assets/installer/mck.sh --where
#    # 
#    # # --- TARGET SCRIPT ---
#    # assets/installer/mck.sh
#    # 
#    # ! rg -n -F 'TRAIL_NAME="${TRAIL_NAME:-public_walk}"' assets/installer/mck.sh
#    # ! rg -n -e '^ *walk\(\)' -e '^ *alias walk=' flake.nix; echo "shell_walk_exit=$?"
#    # ! test -e walk; echo "root_walk_exists=$?"
#    # ! rg -n -e 'alias mothercat=' -e 'Three words to start from' flake.nix scripts/boot_menu.py
#    # flake.nix
#    # scripts/boot_menu.py
#    # assets/installer/mck.sh
#    
#    # ! bash -n walk; echo "walk_syntax=$?"
#    # ! bash walk --where
#    # ! rg -n -e '^ *alias walk=' -e '^ *walk\(\)' -e 'writeShellScriptBin "walk"' flake.nix; echo "shell_walk_exit=$?"
#    # ! .venv/bin/python -c 'import sys; sys.path.insert(0,"scripts"); import boot_menu as b; print("count=" + b._count_word(len(b.DOOR_TWO_WORDS))); print(" ".join(w for w, _ in b.DOOR_TWO_WORDS))'
#    # walk
#    # flake.nix
#    # scripts/boot_menu.py
#    # assets/installer/mck.sh
#    
#    # ! rg -in "three words|four words|door 2" flake.nix scripts/ assets/ README.md
#    # ! walk --where
#    # walk
#    # flake.nix
#    # scripts/boot_menu.py
#    # assets/installer/mck.sh
#    
#    # ! python scripts/connectors/wallet.py check slack
#    # ! python -c "import os, sys; from pathlib import Path; sys.path.insert(0, '.'); from scripts.connectors import wallet; pairs = wallet._dotenv_pairs(); print('env SLACK_USER_TOKEN:', bool(os.getenv('SLACK_USER_TOKEN')), 'env SLACK_BOT_TOKEN:', bool(os.getenv('SLACK_BOT_TOKEN')), 'vault SLACK_USER_TOKEN:', 'SLACK_USER_TOKEN' in pairs, 'vault SLACK_BOT_TOKEN:', 'SLACK_BOT_TOKEN' in pairs)"
#    # ! python scripts/connectors/wallet.py warm slack --dry-run
#    
#    # Now that slack is working (on my workspace) we trim back things here to just the stick bug and Mother Cat Kata stuff

# /home/mike/repos/trimnoir/_posts/2026-08-30-terminal-native-mcp-practice.md
# deleteme.txt
# assets/nbs/Onboarding.ipynb                       # <-- Where Workflows go to get born. Common `.venv` to FastHTML. Also to Cursor or VSCode too if you know how to set it up.
# assets/nbs/imports/core_sauce.py                  # <-- Make those workflows being born in Notebooks look clean by pushing the sausage factory back to code-behind Python secret sauce import modules.
# assets/nbs/imports/onboard_sauce.py               # <-- Now you're cooking!
# apps/015_config.py                                # <-- Carrying onboarding values over to FastHTML-side, especially dotenv values. New home for Botify API key!

scripts/connectors/wallet.py
scripts/connectors/mcp.py
scripts/connectors/mcp_warm.py
scripts/connectors/README.md
scripts/sources_menu.py
! python scripts/connectors/mcp.py
! python -c "import sys; sys.path.insert(0, 'scripts/connectors'); import mcp, mcp_warm; urls = ['https://mcp.botify.com', 'https://mcp.botify.com/', 'https://example.com/sub/path']; print('token_path_for twins agree:', all(mcp.token_path_for(u) == mcp_warm.token_path_for(u) for u in urls))"
! jq -r 'to_entries[] | select(.value|type=="object") | "\(.key): \(.value.auth // "n/a")"' ~/.config/pipulate/connectors.json
! python -c "import json; from pathlib import Path; p = Path.home() / '.config/pipulate/mcp_botify_token.json'; print('legacy resource:', json.loads(p.read_text()).get('resource') if p.exists() else '(no legacy file)')"

3: Patches: None

4: Prompt: Review the receipts. If the token_path_for twins agree and the identity walk shows the warmed Botify MCP credential, emit the wiring patch for wallet.py: add the sixth auth kind mcp_oauth (offline scoreboard stats the derived token path from defaults.resource via a third verbatim token_path_for copy; check dispatches BY KIND to mcp.py <resource> --check the way browser_session already dispatches; warm delegates to mcp_warm.py --refresh <resource> when the token file holds a refresh_token, else the browser PKCE mint). Close with the exact botify_mcp slot JSON for ~/.config/pipulate/connectors.json as an external deliverable, and name the gong/gsc housekeeping (bench or –check) that unblocks GOLD.

5: Deliverables: None

Think through any WET MCP connectors that we need.

Fable 5 Extra: Context verified. The canary arrived linkified again, so the transport rewrote this payload; no SEARCH block below quotes a www-prefixed token. Blank lines are stripped from file bodies in this compile as before, so every SEARCH block below is a run of lines that cannot contain a blank line in the real file — single lines, dict/paragraph interiors, and statement runs inside one branch.

The four rulings

# Receipt Reading Rules
1 identity walk 1 warmed file: the pre-derivation mcp_botify_token, EXPIRED 112,924s (≈31h — consistent with the article’s last refresh chain at 12:02Z on the 30th), refresh_token present the credential to enroll exists and is headlessly refreshable; the derived path has never been written
2 twin probe token_path_for twins agree: True across bare, trailing-slash, and path-slug URLs the third verbatim copy is licensed
3 wallet kinds five distinct kinds across ten slots; no mcp kind anywhere in the data the gap is exactly one kind plus one slot
4 legacy resource https://mcp.botify.com/ the scoped fallback answers ONLY for the resource the new slot will declare; the slot JSON below uses that identical string

Both caboose conditions are met. The wiring patch rides.

One named deviation from the caboose spec

The spec said check dispatches to mcp.py <resource> --check. It does — but with a best-effort mcp_warm.py <resource> --refresh chained in front, and I’m flagging the deviation loudly so you can veto it. The reason is arithmetic: this vendor mints 300-second access tokens, so a bare check would read RED gate2 (401) for a perfectly healthy credential essentially always, GOLD becomes unreachable, and the slot gets benched — the board would be lying red instead of lying green, which is still lying. Two things license the chain: the banked combination-lock rule from yesterday’s article (“a credential shorter-lived than the human loop is only ever used as mcp_warm.py --refresh && mcp.py ...”), and the precedent already on your board — the Google oauth slots green because get_service() refreshes transparently inside their own --check. The chain makes mcp_oauth semantically identical to oauth_token_file: the durable credential being scored is the refresh token. The check’s exit code remains the whole verdict, so a dead refresh surfaces as the check’s own RED, never silently.

The offline/live split, stated before it surprises you

The offline scoreboard stats the DERIVED file only. It cannot honor the legacy fallback without reading the legacy file’s recorded resource — opening a token’s bytes, which the offline board never does. So until a browser warm writes the derived path, botify_mcp reads empty offline while warm (the live board) reads GREEN through the chain and mcp.py’s scoped resolver. That split is documented in the patch comments, the live lane is the truthful one, and warm botify_mcp ends it: the warm verb’s decision is keyed on the derived file too (reader and writer on one file — the VAULT_ENV conviction applied to tokens), so with the derived file absent it runs the browser PKCE mint, which writes the derived path and completes the migration in one dance. Optional Car 5 below.

WET MCP connectors: which ones we actually need

Zero today, one candidate tomorrow, and a rule for the rest. mcp.botify.com (agents, 42 tools): no WET file, ever — mirroring a vendor’s tool list into a connector is the anti-pattern the universal client exists to replace. Promotion is data: the botify_mcp slot this turn IS the promotion. The render server (static Token scheme, 56 tools, session=yes): the only candidate, and it’s gated on receipts. If the first real submit→poll→collect job’s FDR receipts (session_id_sent/session_id_returned) show the server demands one Mcp-Session-Id across the whole job, a thin botify.py-shaped render.py that HOLDS a session is justified — that’s a driver, not a mirror. If fresh initialize-per-poll works with the job id as the state carrier, no file at all. Build nothing until the flight lands. And note: the render server needs no wallet slot — its credential is BOTIFY_API_TOKEN, already scored GREEN under the botify bearer <redacted:3>; a second slot would double-count one credential, and the board scores credentials, not endpoints. Future servers: ad hoc stays off the board on the universal client; promoted OAuth servers get an mcp_oauth slot; a promoted static-token server whose token isn’t already on the board would eventually justify a seventh mcp_token kind — named here, not built.

Housekeeping blocking GOLD, as the caboose asked: gong is benched by Car 3 ("enrolled": false — reversible by deleting the key when gong.py and GONG_ACCESS_KEY_SECRET both exist). gsc needs a real --check in gsc.py, which is not in this context and therefore not patchable this turn — it’s the next ride’s one car. botify_browser (visited-not-logged-in) and sheets’ transient timeout are game-loop reds, fixed by warming, not by patches. One small honesty fix rides in Car 2: mcp_warm’s refresh docstring still claims “UNWITNESSED UNTIL GREEN — no refresh has ever been POSTed to this vendor,” which yesterday’s three chained GREENs made false.

1. PROBES

python scripts/connectors/wallet.py
python -c "import sys; sys.path.insert(0, 'scripts/connectors'); import mcp, mcp_warm, wallet; urls = ['https://mcp.botify.com', 'https://mcp.botify.com/', 'https://example.com/sub/path']; print('token_path_for triplets agree:', all(mcp.token_path_for(u) == mcp_warm.token_path_for(u) == wallet.token_path_for(u) for u in urls))"

Probe 1 is the offline board: BEFORE it prints ten rows and no mcp kind (matching this compile’s receipts); AFTER Car 3 it prints eleven, with botify_mcp reading empty at the derived path unless Car 5’s mint ran. Probe 2 is the triplet probe: BEFORE it fails loud with AttributeError, because wallet.py has no token_path_for — that traceback is the honest BEFORE; AFTER it prints True. Both are read-only and open no socket. The live check is deliberately NOT here — per the banked rule, anything that runs against a server rides in PATCHES as its own car.

2. NEXT CONTEXT

scripts/connectors/wallet.py
scripts/connectors/gsc.py
scripts/connectors/botify.py
! python scripts/connectors/wallet.py
! python -c "import sys; sys.path.insert(0, 'scripts/connectors'); import mcp, mcp_warm, wallet; urls = ['https://mcp.botify.com', 'https://mcp.botify.com/', 'https://example.com/sub/path']; print('token_path_for triplets agree:', all(mcp.token_path_for(u) == mcp_warm.token_path_for(u) == wallet.token_path_for(u) for u in urls))"
! python scripts/connectors/wallet.py check botify_mcp

The third echo is Car 4’s own line riding into the compile lane: it is the one echo that opens sockets and rewrites the access token (bounded to one slot, headless-safe by construction — check never prompts). gsc.py and botify.py come in for the next ride’s --check car; botify.py is the shape to copy.

3. PATCHES

Car 1 — the sixth kind, all code, one paste. Eleven blocks in one fenced payload; apply.py applies and reports them individually, and every block touches a disjoint region.

Target: scripts/connectors/wallet.py
[[[SEARCH]]]
from datetime import datetime, timezone
from pathlib import Path
[[[DIVIDER]]]
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse, quote
[[[REPLACE]]]

Target: scripts/connectors/wallet.py
[[[SEARCH]]]
_BROWSER_KIND = 'browser_session'      # weblogin persistent profile (botify_browser, semrush)
[[[DIVIDER]]]
_BROWSER_KIND = 'browser_session'      # weblogin persistent profile (botify_browser, semrush)
_MCP_KIND = 'mcp_oauth'                # remote MCP bearer; token file DERIVED from defaults.resource (botify_mcp)
[[[REPLACE]]]

Target: scripts/connectors/wallet.py
[[[SEARCH]]]
_MTIME_KINDS = (_OAUTH_KIND, _BROWSER_KIND)  # kinds whose freshness decays with time
[[[DIVIDER]]]
_MTIME_KINDS = (_OAUTH_KIND, _BROWSER_KIND, _MCP_KIND)  # kinds whose freshness decays with time
[[[REPLACE]]]

Target: scripts/connectors/wallet.py
[[[SEARCH]]]
    _BROWSER_KIND: 'browser',
}
[[[DIVIDER]]]
    _BROWSER_KIND: 'browser',
    _MCP_KIND: 'mcp',
}
[[[REPLACE]]]

Target: scripts/connectors/wallet.py
[[[SEARCH]]]
_MARK = {'filled': '[x]', 'stale': '[~]', 'partial': '[/]', 'empty': '[ ]',
         'no-path': '[!]', 'unknown': '[?]'}
[[[DIVIDER]]]
_MARK = {'filled': '[x]', 'stale': '[~]', 'partial': '[/]', 'empty': '[ ]',
         'no-path': '[!]', 'unknown': '[?]'}

# THE THIRD VERBATIM COPY. token_path_for lives in scripts/connectors/mcp.py
# and scripts/connectors/mcp_warm.py; this leg turns the twins into triplets.
# The WET connector contract forbids wallet.py importing mcp.py, so the
# derivation is duplicated ON PURPOSE and the copies are COMPARED BY PROBE,
# never trusted -- the existing twin-agreement probe simply gains a third leg.
# TOKEN_DIR keeps mcp.py's exact constant name so the function body below can
# stay byte-identical to its siblings.
TOKEN_DIR = Path.home() / ".config" / "pipulate" / "mcp"

def token_path_for(resource):
    """Credential path for one MCP server. A root path collapses to the host."""
    parsed = urlparse(resource or "")
    host = (parsed.netloc or "unknown-host").lower()
    path = (parsed.path or "").strip("/")
    stem = host if not path else f"{host}__{quote(path, safe='')}"
    return TOKEN_DIR / f"{stem}.json"
[[[REPLACE]]]

Target: scripts/connectors/wallet.py
[[[SEARCH]]]
    if kind == _BROWSER_KIND:
        profile = (cfg.get('paths') or {}).get('profile')
        pdir = str(REPO_ROOT / 'data' / 'uc_profiles' / profile) if profile else None
        state, detail = _stat_state(pdir, stale_days, mtime_matters=True)
        return state, kind, detail, pdir or '(no profile declared)'
[[[DIVIDER]]]
    if kind == _BROWSER_KIND:
        profile = (cfg.get('paths') or {}).get('profile')
        pdir = str(REPO_ROOT / 'data' / 'uc_profiles' / profile) if profile else None
        state, detail = _stat_state(pdir, stale_days, mtime_matters=True)
        return state, kind, detail, pdir or '(no profile declared)'
    if kind == _MCP_KIND:
        # THE STAT IS THE DERIVED FILE ONLY. The pre-derivation
        # mcp_botify_token.json is invisible here BY RULE: the offline board
        # never opens a token's bytes, and scoping the legacy file to its
        # server requires reading its recorded resource. So a legacy-only
        # credential reads empty on THIS board while `check` -- which shells
        # to mcp.py and its scoped resolver -- can still read GREEN. When the
        # two lanes disagree, the live one is truthful; warming this slot
        # runs the browser mint, which writes the derived path and ends the
        # split for good.
        resource = (cfg.get('defaults') or {}).get('resource')
        if not resource:
            return 'no-path', kind, 'slot declares no defaults.resource', '—'
        tok = str(token_path_for(resource))
        state, detail = _stat_state(tok, stale_days, mtime_matters=True)
        return state, kind, detail, tok
[[[REPLACE]]]

Target: scripts/connectors/wallet.py
[[[SEARCH]]]
    if kind == _BROWSER_KIND:
        return (f"python scripts/connectors/wallet.py warm {name}   "
                f"(confirms, then opens this slot's own site + profile)")
[[[DIVIDER]]]
    if kind == _BROWSER_KIND:
        return (f"python scripts/connectors/wallet.py warm {name}   "
                f"(confirms, then opens this slot's own site + profile)")
    if kind == _MCP_KIND:
        return (f"python scripts/connectors/wallet.py warm {name}   "
                f"(headless refresh when the derived file can; else browser mint)")
[[[REPLACE]]]

Target: scripts/connectors/wallet.py
[[[SEARCH]]]
def check_slot(name, cfg=None):
    """Check one slot. Returns (code, line).
[[[DIVIDER]]]
def check_mcp_slot(name, cfg):
    """SELECT 1 for an mcp_oauth slot: refresh, then check, both delegated.

    DISPATCH BY KIND, mirroring check_browser_slot: an mcp_oauth slot is a
    remote server plus a derived token file, not a connector module, so the
    filename lookup would demand a botify_mcp.py that should never exist.

    THE CHAIN IS THE CHECK (the combination-lock rule, banked 2026-08-30):
    this credential class mints access tokens shorter-lived than the human
    loop -- 300 seconds, witnessed -- so a bare `mcp.py --check` would read
    RED gate2 (401) for a perfectly healthy credential almost every time.
    The durable credential is the REFRESH token, and the Google oauth slots
    already green exactly this way: their connectors' get_service() refreshes
    transparently inside their own --check. So: best-effort
    `mcp_warm.py <resource> --refresh` first (headless by design), then
    `mcp.py <resource> --check`. The check's exit code is the whole verdict,
    so a still-live token or an env-lane token can green even when the
    refresh half fails, and a dead refresh surfaces as the check's own RED.

    stderr caveat: mcp.py's atexit FDR-receipt line lands AFTER the RED gate
    line, so the diagnostic is the last line STARTING with 'mcp RED', never
    blindly stderr's tail.
    """
    resource = (cfg.get('defaults') or {}).get('resource')
    if not resource:
        return 1, (f"{name} RED gate1: slot declares no defaults.resource, "
                   "so there is no server to check")
    here = Path(__file__).resolve().parent
    check_script = here / 'mcp.py'
    warm_script = here / 'mcp_warm.py'
    if not check_script.exists():
        return 2, f"no client module ({check_script.name})"
    env = _check_env()
    if warm_script.exists():
        try:
            subprocess.run(
                [sys.executable, str(warm_script), resource, '--refresh'],
                stdin=subprocess.DEVNULL, capture_output=True, text=True,
                timeout=CHECK_TIMEOUT, env=env,
            )
        except (subprocess.TimeoutExpired, OSError):
            pass  # the check below is the verdict; a dead refresh reds there
    try:
        proc = subprocess.run(
            [sys.executable, str(check_script), resource, '--check'],
            stdin=subprocess.DEVNULL, capture_output=True, text=True,
            timeout=CHECK_TIMEOUT, env=env,
        )
    except subprocess.TimeoutExpired:
        return 1, f"{name} RED gate2: no answer within {CHECK_TIMEOUT}s"
    except OSError as e:
        return 2, f"could not run {check_script.name}: {e}"
    if proc.returncode == 0:
        out = [ln for ln in (proc.stdout or '').strip().splitlines()
               if ln.strip() and not ln.lstrip().startswith('#')]
        return 0, out[-1] if out else f"{name} GREEN"
    gates = [ln.strip() for ln in (proc.stderr or '').strip().splitlines()
             if ln.strip().startswith('mcp RED')]
    if gates:
        return 1, gates[-1]
    return 1, f"{name} RED (exit {proc.returncode}, no gate line)"

def check_slot(name, cfg=None):
    """Check one slot. Returns (code, line).
[[[REPLACE]]]

Target: scripts/connectors/wallet.py
[[[SEARCH]]]
    if (cfg or {}).get('auth') == _BROWSER_KIND:
        return check_browser_slot(name, cfg)
[[[DIVIDER]]]
    if (cfg or {}).get('auth') == _BROWSER_KIND:
        return check_browser_slot(name, cfg)
    if (cfg or {}).get('auth') == _MCP_KIND:
        return check_mcp_slot(name, cfg)
[[[REPLACE]]]

Target: scripts/connectors/wallet.py
[[[SEARCH]]]
    return ("cannot mint — download the service-account JSON (Google Cloud "
            f"Console → IAM → Service Accounts) to {key or '(declare paths.service_account)'}")
[[[DIVIDER]]]
    return ("cannot mint — download the service-account JSON (Google Cloud "
            f"Console → IAM → Service Accounts) to {key or '(declare paths.service_account)'}")

def _warm_mcp(name, cfg, assume_yes):
    """mcp_oauth: delegate to mcp_warm.py, which owns the OAuth 2.1 dance.

    THE DECISION FILE IS THE DERIVED FILE -- the same file the offline
    scoreboard stats -- so this verb's reader and that board's reader can
    never drift onto different files (the VAULT_ENV conviction, applied to
    tokens). Derived file present with a refresh_token: headless refresh
    (the Cinderella rung). Anything else -- absent, unreadable, or
    refresh-less -- runs the browser PKCE mint, which WRITES the derived
    path. A credential living only in the pre-derivation
    mcp_botify_token.json therefore migrates on its first warm here, and
    the offline board's under-reporting of it ends. Reading the token
    file's bytes is legal in THIS verb (warm already handles secret
    values); the offline scoreboard still never does.
    """
    resource = (cfg.get('defaults') or {}).get('resource')
    if not resource:
        return 'skipped (no defaults.resource declared)'
    script = Path(__file__).resolve().parent / 'mcp_warm.py'
    if not script.exists():
        return f"mcp_warm.py not found at {script}"
    tok = token_path_for(resource)
    can_refresh = False
    if tok.is_file():
        try:
            record = json.loads(tok.read_text(encoding='utf-8'))
            can_refresh = isinstance(record, dict) and bool(record.get('refresh_token'))
        except (OSError, ValueError):
            can_refresh = False
    failed = None
    if can_refresh:
        print(f"  → {Path(sys.executable).name} {script.name} {resource} --refresh   (headless)")
        try:
            rc = subprocess.call([sys.executable, str(script), resource, '--refresh'])
        except OSError as e:
            return f"could not run mcp_warm.py: {e}"
        if rc == 0:
            return 'refreshed headlessly'
        failed = rc
    if failed is not None:
        question = f"  refresh exited {failed} -- run the browser mint for {resource} instead?"
    else:
        question = f"  mint OAuth for {resource} (opens a browser)?"
    if not _confirm(question, assume_yes):
        return 'skipped' if failed is None else f"refresh exited {failed}; browser mint declined"
    print(f"  → {Path(sys.executable).name} {script.name} {resource}")
    print(f"    Authorize in the browser; the token lands at {tok}")
    try:
        rc = subprocess.call([sys.executable, str(script), resource])
    except OSError as e:
        return f"could not run mcp_warm.py: {e}"
    return 'browser mint finished' if rc == 0 else f"mcp_warm exited {rc}"
[[[REPLACE]]]

Target: scripts/connectors/wallet.py
[[[SEARCH]]]
        elif kind == _BROWSER_KIND:
            note = _warm_browser(n, c, assume_yes)
        elif kind == _SERVICE_KIND:
            note = _warm_service(n, c)
[[[DIVIDER]]]
        elif kind == _BROWSER_KIND:
            note = _warm_browser(n, c, assume_yes)
        elif kind == _MCP_KIND:
            note = _warm_mcp(n, c, assume_yes)
        elif kind == _SERVICE_KIND:
            note = _warm_service(n, c)
[[[REPLACE]]]

Car 2 — the documentation stops saying five. Seven small blocks: five in wallet.py’s own prose, the connector README’s kind roster, and mcp_warm’s now-false UNWITNESSED claim (three chained refreshes went GREEN yesterday).

Target: scripts/connectors/wallet.py
[[[SEARCH]]]
every slot at once — across all FIVE auth kinds the wallet actually holds —
[[[DIVIDER]]]
every slot at once — across all SIX auth kinds the wallet actually holds —
[[[REPLACE]]]

Target: scripts/connectors/wallet.py
[[[SEARCH]]]
  browser_session       os.stat() the persistent Chrome profile dir
                        data/uc_profiles/<name> that weblogin.py warms
                        (botify_browser, semrush) → mtime staleness, because
                        sites DO expire browser sessions.
[[[DIVIDER]]]
  browser_session       os.stat() the persistent Chrome profile dir
                        data/uc_profiles/<name> that weblogin.py warms
                        (botify_browser, semrush) → mtime staleness, because
                        sites DO expire browser sessions.
  mcp_oauth             os.stat() the DERIVED token file
                        ~/.config/pipulate/mcp/<host>.json that mcp_warm.py
                        writes (botify_mcp). Refresh rewrites the file, so
                        mtime tracks "last refreshed", exactly like oauth.
[[[REPLACE]]]

Target: scripts/connectors/wallet.py
[[[SEARCH]]]
    print(f"# stale after: {stale_days}d — mtime heuristic for oauth/browser

Note: I had to hit “Continue”

Fable 5 Extra: Resuming mid-train — the previous message truncated inside Car 2’s fence. Do not paste that truncated fragment: its fence never closed, and its third block is a dangling Target: with half a SEARCH. The complete Car 2 rides below as one payload. If you already piped the fragment, no harm — apply.py’s exact-match interlock reports PATCH ALREADY APPLIED for the two blocks that were complete and simply ignores the dangling one, so re-applying the full car is idempotent.

One correction to the preamble before the cars: I wrote “gong is benched by Car 3” while planning Car 3 as a patch. It can’t be — ~/.config/pipulate/connectors.json is not raw source in this context, so a SEARCH/REPLACE against it would violate the patches-only-against-context rule. Per the contract, a mutating actuator rides in PATCHES as its own car instead: Car 3 below is a bounded Python upsert (with a .bak) that adds the botify_mcp slot and benches gong in one pass. Section 5 therefore stays empty — the slot JSON the caboose asked for as an external deliverable ships as Car 3’s body, in execution order, where it must run before Car 4 can find the slot.

3. PATCHES (continued)

Car 2 — the documentation stops saying five. Seven blocks, complete this time: five in wallet.py’s prose, the README’s kind roster, and mcp_warm’s now-false UNWITNESSED claim (three chained refreshes went gate6 GREEN on 2026-08-30, per yesterday’s article receipts).

Target: scripts/connectors/wallet.py
[[[SEARCH]]]
every slot at once — across all FIVE auth kinds the wallet actually holds —
[[[DIVIDER]]]
every slot at once — across all SIX auth kinds the wallet actually holds —
[[[REPLACE]]]

Target: scripts/connectors/wallet.py
[[[SEARCH]]]
  browser_session       os.stat() the persistent Chrome profile dir
                        data/uc_profiles/<name> that weblogin.py warms
                        (botify_browser, semrush) → mtime staleness, because
                        sites DO expire browser sessions.
[[[DIVIDER]]]
  browser_session       os.stat() the persistent Chrome profile dir
                        data/uc_profiles/<name> that weblogin.py warms
                        (botify_browser, semrush) → mtime staleness, because
                        sites DO expire browser sessions.
  mcp_oauth             os.stat() the DERIVED token file
                        ~/.config/pipulate/mcp/<host>.json that mcp_warm.py
                        writes (botify_mcp). A refresh rewrites the file, so
                        mtime tracks "last refreshed" exactly as oauth does.
                        The pre-derivation mcp_botify_token.json is invisible
                        to THIS board by rule (scoping it means reading its
                        bytes); the CHECK verb still honors it, scoped.
[[[REPLACE]]]

Target: scripts/connectors/wallet.py
[[[SEARCH]]]
    print(f"# stale after: {stale_days}d — mtime heuristic for oauth/browser, "
          "not a validity proof\n")
[[[DIVIDER]]]
    print(f"# stale after: {stale_days}d — mtime heuristic for oauth/browser/mcp, "
          "not a validity proof\n")
[[[REPLACE]]]

Target: scripts/connectors/wallet.py
[[[SEARCH]]]
    """Dispatch on the slot's auth kind. Returns (state, kind, detail, locator).
    The single place that knows how each of the five kinds proves itself."""
[[[DIVIDER]]]
    """Dispatch on the slot's auth kind. Returns (state, kind, detail, locator).
    The single place that knows how each of the six kinds proves itself."""
[[[REPLACE]]]

Target: scripts/connectors/wallet.py
[[[SEARCH]]]
    """Print the read-only board for EVERY slot, across all five auth kinds."""
[[[DIVIDER]]]
    """Print the read-only board for EVERY slot, across all six auth kinds."""
[[[REPLACE]]]

Target: scripts/connectors/README.md
[[[SEARCH]]]
Auth kinds: oauth_token_file (gmail), bearer_token (botify), basic_auth
(confluence), service_account_file (gsc), browser_session (botify_browser,
semrush — a persistent Chrome profile under data/uc_profiles/<name>, warmed by
weblogin.py, not a token). Every future connector copies one of these five.
[[[DIVIDER]]]
Auth kinds: oauth_token_file (gmail), bearer_token (botify), basic_auth
(confluence), service_account_file (gsc), browser_session (botify_browser,
semrush — a persistent Chrome profile under data/uc_profiles/<name>, warmed by
weblogin.py, not a token), and mcp_oauth (botify_mcp — a remote MCP bearer
whose token file is DERIVED from the slot's defaults.resource and minted or
refreshed by mcp_warm.py; the durable credential scored is the refresh
token). Every future connector copies one of these six.
[[[REPLACE]]]

Target: scripts/connectors/mcp_warm.py
[[[SEARCH]]]
    UNWITNESSED UNTIL GREEN: no refresh has ever been POSTed to this vendor.
    Public-client refusal, single-use rotation, and refresh_token expiry are
    all live possibilities. Every one of them dies LOUD at gate6 rather than
    leaving a half-written token file behind.
[[[DIVIDER]]]
    WITNESSED GREEN 2026-08-30: three chained --refresh runs against this
    vendor each returned gate6 GREEN and a fresh 300s access token, so the
    grant type is live for this credential. Single-use rotation remains
    unobserved (no rotated refresh_token has yet come back) and
    refresh_token expiry is still a live possibility; both die LOUD at
    gate6 rather than leaving a half-written token file behind.
[[[REPLACE]]]

Car 3 — the slot and the bench, as a mutating actuator. connectors.json lives outside the repo and outside this context, so this is a JSON-parse upsert, never a text patch. It backs up first, adds botify_mcp (the resource string is byte-identical to the legacy file’s recorded resource, per this compile’s receipt, so the scoped fallback answers for it), benches gong, and prints the after-state. Reversible: restore the .bak, or delete the two keys it touched.

python - <<'EOF'
import json, shutil
from pathlib import Path
p = Path.home() / '.config' / 'pipulate' / 'connectors.json'
shutil.copy2(p, p.with_suffix('.json.bak'))
w = json.loads(p.read_text(encoding='utf-8'))
w['botify_mcp'] = {
    "auth": "mcp_oauth",
    "defaults": {"resource": "https://mcp.botify.com/"},
}
w.setdefault('gong', {})['enrolled'] = False
p.write_text(json.dumps(w, indent=2) + '\n', encoding='utf-8')
print('slots:', ', '.join(k for k in w if not k.startswith('_')))
print('botify_mcp auth:', w['botify_mcp']['auth'])
print('gong enrolled:', w['gong']['enrolled'])
EOF

Car 4 — the live check, one slot only. This is the actuator-is-a-car beat: it POSTs a refresh (rewriting the legacy token in place, since the derived file doesn’t exist yet) and then the envelope check. Expect the scoped-fallback stderr note, then mcp GREEN ... tools=42, then the board’s own GREEN row and GOLD arithmetic that now counts eleven enrolled minus gong benched. Its echo is already in NEXT CONTEXT, so the compile lane takes the AFTER reading automatically.

python scripts/connectors/wallet.py check botify_mcp

Car 5 — optional, ends the offline/live split. The browser PKCE mint writes the DERIVED path (~/.config/pipulate/mcp/mcp.botify.com.json), after which the offline scoreboard stops reading empty for a credential the live board greens, and the pre-derivation fallback stops firing forever. Interactive (opens a browser); skip it if you don’t want the dance today — nothing else depends on it.

python scripts/connectors/wallet.py warm botify_mcp

Choreography: Car 1 — patch, app, d, m. Car 2 — patch, app, d, m. Car 3 — paste the heredoc (out-of-repo file; nothing to commit). Cars 4 and 5 — run as shown. Then blast. Ignition: none required — every probe and car executes wallet.py fresh at call time; the warm shell function already points at the file being patched, and no flake line changes.

4. PROMPT

Read the LIVE COMMAND RECEIPTS first; name the lane for each and rule one line each:
1. The bare scoreboard: eleven slots, botify_mcp present as kind mcp. If Car 5 ran it reads filled at the derived path; if not, empty there while receipt 3 reads GREEN -- that split is documented, name it, do not "fix" it.
2. The triplet probe: token_path_for triplets agree: True. An AttributeError means Car 1 did not land; a False means a copy drifted -- name which by output diff, and stop.
3. check botify_mcp: GREEN with tools=42 (a different count is drift, not failure), or the gate line verbatim. If gate6 RED appeared in the chain but the check still greened, say why that is correct behavior.
Also rule: does the board tally now show gong benched and GOLD blocked only by gsc (unchecked) plus any game-loop reds (sheets timeout, botify_browser)?
Then ONE car against scripts/connectors/gsc.py (in context): a --check health probe modeled on botify.py's -- one bounded API call, exit 0 GREEN with an identity line on stdout, exit 1 RED with a gate-named line on stderr, 15s budget, stdin-safe. No other gsc.py changes. Give its straddle: BEFORE argparse exits 2 on --check (the board renders UNCHECKED), AFTER the board scores it.
Do not touch mcp.py, mcp_warm.py, or wallet.py this turn. The render-server session question waits for its first real flight's FDR receipts.

5. EXTERNAL DELIVERABLES

None. The botify_mcp slot JSON the previous caboose requested as an external deliverable rode as Car 3 instead, because it is order-dependent (Car 4 cannot check a slot that does not exist) and the one-timeline rule outranks the section label. The gong bench rode in the same pass; reverse either by restoring connectors.json.bak.

MikeLev.in:

1: Probe:

Blast Radius Check to establish bisection Left-hand Causal Boundary. It is a Popper-thing. Science.
On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean
(nix) pipulate $ python scripts/connectors/wallet.py
python -c "import sys; sys.path.insert(0, 'scripts/connectors'); import mcp, mcp_warm, wallet; urls = ['https://mcp.botify.com', 'https://mcp.botify.com/', 'https://example.com/sub/path']; print('token_path_for triplets agree:', all(mcp.token_path_for(u) == mcp_warm.token_path_for(u) == wallet.token_path_for(u) for u in urls))"
# wallet.py — connector auth scoreboard (read-only, offline)
# wallet: /home/mike/.config/pipulate/connectors.json
# repo:   /home/mike/repos/pipulate  (anchors browser_session profiles)
# stale after: 7d — mtime heuristic for oauth/browser, not a validity proof

     state    kind      slot            evidence                                                                   where
  [x] filled   oauth     gmail           2026-08-31 (0d ago)                                                        /home/mike/.config/pipulate/gmail_token.json
  [x] filled   oauth     sheets          2026-08-31 (0d ago)                                                        /home/mike/.config/pipulate/sheets_token.json
  [x] filled   bearer    <redacted:6>          set: BOTIFY_API_TOKEN (env)                                                env / .env (out of git)
  [x] filled   basic     confluence      set: CONFLUENCE_URL (env), CONFLUENCE_EMAIL (env), CONFLUENCE_TOKEN (env)  env / .env (out of git)
  [x] filled   svc-acct  gsc             2026-07-15 (48d ago)                                                       /home/mike/.config/pipulate/service-account-key.json
  [x] filled   basic     jira            set: JIRA_URL (env), JIRA_EMAIL (env), JIRA_TOKEN (env)                    env / .env (out of git)
  [x] filled   bearer    <redacted:5>           set: SLACK_USER_TOKEN (env)                                                env / .env (out of git)
  [/] partial  basic     gong            set: GONG_ACCESS_KEY (env) | unset: GONG_ACCESS_KEY_SECRET                 env / .env (out of git)
  [~] stale    browser   botify_browser  2026-07-29 (34d ago)                                                       /home/mike/repos/pipulate/data/uc_profiles/botify
  [~] stale    browser   semrush         2026-07-23 (40d ago)                                                       /home/mike/repos/pipulate/data/uc_profiles/semrush

# 7 filled | 2 stale | 1 partial | 0 empty
# Next: python scripts/connectors/wallet.py warm botify_browser   (confirms, then opens this slot's own site + profile)
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "<string>", line 1, in <genexpr>
AttributeError: module 'wallet' has no attribute 'token_path_for'
(nix) pipulate $ 

2: Context:

# adhoc.txt    _   _   _ to set context____ _   _  ___  ____  _   <F5> Simpson Couch Gag Here (explain anything to the audience you feel needs it explained)
#     / \   __| | | | | | ___   ___   / ___| | | |/ _ \|  _ \| |  
# ahe/ _ \ / _` | | |_| |/ _ \ / __| | |   | |_| | | | | |_) | |  Powering through it
# ahc ___ \ (_| | |  _  | (_) | (__  | |___|  _  | |_| |  __/|_|  
#  /_/   \_\__,_| |_| |_|\___/ \___|  \____|_| |_|\___/|_|   (_)  
# Ad Hoc CHOP: The Not-Managed-by-Git Safe-for-Client-Data place  

#    # THE ROLLING PIN BOOK ORE SPINE / FODDER FOR THE BOOK
#    ! python scripts/articles/lsa.py -t 1 --reverse --fmt dated-slugs  # <-- The "Rolling Pin" that gives the 40K foot book-spine view of book-ore.
#    GLOSSARY.md                 # <-- I think this glossary goes well with the book-ore spine to do world building.
#    # scripts/articles/lsa.py     # <-- Useful for refining commands like `posts`, critical to Second Brain concept.
#     
#    # THE QUIRKY AMIGA-LOVING HUMAN
#    # ~/repos/nixos/autognome.py  # <-- Letting the AIs really understand my environment (The Brave Little Tailor punches above Their Weight Class proving the dunning-kruger effect the gate-keeper's (lower-case) lament.)
#    # init.lua                    # <-- Daily driver hot-keys that overlap with aliases in flake.nix
#     
#    # AGENTIC FRAMEWORK & FOREVER MACHINE BIG FILES
#    prompt_foo.py               # <-- Prompt Fu compiler, makes the very README for AGENTS-like payload you're reading right now, but it needs to be more like that
#    foo_files.py                # <-- This is the router, evolving book outline and the things you pin-up to produced the recursive self-improvement loops
#    flake.nix                   # <-- Solves world's WRITE ONCE RUN ANYWHERE problem like Java never could. Also resolves the bootstrap paradox.
#    
#    # MAIN ACTUATORS, IaC & NEGATIVE SPACE
#    apply.py                    # <-- How can "Web UI" ChatBots edit your code? With this Aider-inspired Player Piano patch applier.
#    .gitattributes              # <-- Model: understand that `nbstripout` and `jupytext` are both in play. Just talk the human through .ipynb patches.
#    .gitignore                  # <-- Creates "negative space" for sub-rep's to share parent environment and "snap" proprietary secret features into place.
#    requirements.in             # <-- All known dependencies and (necessary) version pinning. WORA gotcha's exposed.
#    __init__.py                 # <-- Master versioning
#    pyproject.toml              # <-- The PyPI Packaging details
#    
#    # cli.py                      # <-- Catch-all actuator for PyPI envs, Python anchoring, MCP tool-call (plus alternatives) and **kwargs like wrapping for CLI
#    # scripts/xp.py               # <-- Transforms host OS copy-paste buffer player-piano music into context-payload.
#    # scripts/ai.py               # <-- How I constantly use local AI to write git commit messages with `m` alias.
#     
#    # CONTEXT PORTABILITY SYSTEM
#    3 scripts/foo_cartridge.py    # Needs description
#    3 scripts/foo_replay.py       # Needs description
#          
#    # FREQUENTLY USEFUL TO HAVE IN CONTEXT
#    # release.py                  # <-- How everything ends up where it does (GitHub, PyPI, etc.)
#    
#    # scripts/weblogin.py         # <-- Lets the user "warm up" the cache for their web logins at their leisure on a profile that persists.
#    # scripts/crawl.py            # <-- Feel free to ask for something to be crawled and included in the next turn.
#    
#    # imports/voice_synthesis.py  # <-- The wand can talk to you
#    # scripts/release/version_sync.py  # <-- Needs to be wrapped into release.py and eliminated, I think.
#    
#    #                         --- Under this line is were you paste what the AI gives you ---
#    #                         --- We call it context but it's really just the right-hand  ---
#    #                         --- blast-radius of the "probes" to make this all science.  ---
#    
#    # --- END `adhoc.txt` TEMPLATE ---
#    
#    # server.py
#     

flake.nix
.gitattributes              # <-- Model: understand that `nbstripout` and `jupytext` are both in play. Just talk the human through .ipynb patches.
.gitignore                  # <-- Creates "negative space" for sub-rep's to share parent environment and "snap" proprietary secret features into place.
requirements.in             # <-- All known dependencies and (necessary) version pinning. WORA gotcha's exposed.
__init__.py                 # <-- Master versioning
pyproject.toml              # <-- The PyPI Packaging details

# # STICKBUG & MOTHER CAT KATA
# # assets/trails/botify_pageworkers.yaml
# assets/installer/mck.sh
# assets/installer/replay.sh
# assets/trails/first_context.yaml
# assets/trails/practice.yaml
# assets/trails/public_walk.yaml
# scripts/bookmark_import.py
# scripts/boot_menu.py
# scripts/connectors/README.md
# scripts/connectors/botify.py
# scripts/connectors/confluence.py
# scripts/connectors/gmail.py
# scripts/connectors/gsc.py
# scripts/connectors/jira.py
# scripts/connectors/mcp.py
# scripts/connectors/mcp_warm.py
# scripts/connectors/sheets.py
# scripts/connectors/slack.py
# scripts/connectors/wallet.py
# scripts/mother_cat.py
# scripts/sources_menu.py
# scripts/walk.py
# scripts/walk_cartridge.py
# scripts/walk_compile.py
# scripts/weblogin.py
# tools/scraper_tools.py

#    # # adhoc.txt -- Cleanup inert public_walk environment export block
#    # 
#    # # --- BEFORE/AFTER STRADDLE ---
#    # ! grep -n -C 2 'PIPULATE_TRAIL_WALK_ONE_URL' assets/installer/mck.sh || echo "export_block_removed"
#    # ! bash -n assets/installer/mck.sh; echo "mck_syntax=$?"
#    # ! bash assets/installer/mck.sh --where
#    # 
#    # # --- TARGET SCRIPT ---
#    # assets/installer/mck.sh
#    # 
#    # ! rg -n -F 'TRAIL_NAME="${TRAIL_NAME:-public_walk}"' assets/installer/mck.sh
#    # ! rg -n -e '^ *walk\(\)' -e '^ *alias walk=' flake.nix; echo "shell_walk_exit=$?"
#    # ! test -e walk; echo "root_walk_exists=$?"
#    # ! rg -n -e 'alias mothercat=' -e 'Three words to start from' flake.nix scripts/boot_menu.py
#    # flake.nix
#    # scripts/boot_menu.py
#    # assets/installer/mck.sh
#    
#    # ! bash -n walk; echo "walk_syntax=$?"
#    # ! bash walk --where
#    # ! rg -n -e '^ *alias walk=' -e '^ *walk\(\)' -e 'writeShellScriptBin "walk"' flake.nix; echo "shell_walk_exit=$?"
#    # ! .venv/bin/python -c 'import sys; sys.path.insert(0,"scripts"); import boot_menu as b; print("count=" + b._count_word(len(b.DOOR_TWO_WORDS))); print(" ".join(w for w, _ in b.DOOR_TWO_WORDS))'
#    # walk
#    # flake.nix
#    # scripts/boot_menu.py
#    # assets/installer/mck.sh
#    
#    # ! rg -in "three words|four words|door 2" flake.nix scripts/ assets/ README.md
#    # ! walk --where
#    # walk
#    # flake.nix
#    # scripts/boot_menu.py
#    # assets/installer/mck.sh
#    
#    # ! python scripts/connectors/wallet.py check slack
#    # ! python -c "import os, sys; from pathlib import Path; sys.path.insert(0, '.'); from scripts.connectors import wallet; pairs = wallet._dotenv_pairs(); print('env SLACK_USER_TOKEN:', bool(os.getenv('SLACK_USER_TOKEN')), 'env SLACK_BOT_TOKEN:', bool(os.getenv('SLACK_BOT_TOKEN')), 'vault SLACK_USER_TOKEN:', 'SLACK_USER_TOKEN' in pairs, 'vault SLACK_BOT_TOKEN:', 'SLACK_BOT_TOKEN' in pairs)"
#    # ! python scripts/connectors/wallet.py warm slack --dry-run
#    
#    # Now that slack is working (on my workspace) we trim back things here to just the stick bug and Mother Cat Kata stuff

# /home/mike/repos/trimnoir/_posts/2026-08-30-terminal-native-mcp-practice.md
# deleteme.txt

# assets/nbs/Onboarding.ipynb                       # <-- Where Workflows go to get born. Common `.venv` to FastHTML. Also to Cursor or VSCode too if you know how to set it up.
# assets/nbs/imports/core_sauce.py                  # <-- Make those workflows being born in Notebooks look clean by pushing the sausage factory back to code-behind Python secret sauce import modules.
# assets/nbs/imports/onboard_sauce.py               # <-- Now you're cooking!
# apps/015_config.py                                # <-- Carrying onboarding values over to FastHTML-side, especially dotenv values. New home for Botify API key!

scripts/connectors/wallet.py
scripts/connectors/gsc.py
scripts/connectors/botify.py
! python scripts/connectors/wallet.py
! python -c "import sys; sys.path.insert(0, 'scripts/connectors'); import mcp, mcp_warm, wallet; urls = ['https://mcp.botify.com', 'https://mcp.botify.com/', 'https://example.com/sub/path']; print('token_path_for triplets agree:', all(mcp.token_path_for(u) == mcp_warm.token_path_for(u) == wallet.token_path_for(u) for u in urls))"
! python scripts/connectors/wallet.py check botify_mcp

3: Patches:

(nix) pipulate $ ahe
(nix) pipulate $ g

Blast Radius Check to establish bisection Left-hand Causal Boundary. It is a Popper-thing. Science.
On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean
(nix) pipulate $ patch
(nix) pipulate $ app
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/wallet.py'.
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/wallet.py'.
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/wallet.py'.
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/wallet.py'.
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/wallet.py'.
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/wallet.py'.
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/wallet.py'.
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/wallet.py'.
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/wallet.py'.
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/wallet.py'.
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/wallet.py'.
(nix) pipulate $ d
diff --git a/scripts/connectors/wallet.py b/scripts/connectors/wallet.py
index e0f76b33..ddfe025b 100644
--- a/scripts/connectors/wallet.py
+++ b/scripts/connectors/wallet.py
@@ -82,6 +82,7 @@ import subprocess
 from concurrent.futures import ThreadPoolExecutor
 from datetime import datetime, timezone
 from pathlib import Path
+from urllib.parse import urlparse, quote
 
 WALLET_PATH = os.environ.get('PIPULATE_WALLET') or str(
     Path.home() / '.config' / 'pipulate' / 'connectors.json')
@@ -104,10 +105,11 @@ _SERVICE_KIND = 'service_account_file'  # a key file on disk (gsc)
 _BEARER_KIND = 'bearer_token'          # paste: single API token (botify, slack)
 _BASIC_KIND = 'basic_auth'             # paste: user + API token (confluence, jira, gong)
 _BROWSER_KIND = 'browser_session'      # weblogin persistent profile (botify_browser, semrush)
+_MCP_KIND = 'mcp_oauth'                # remote MCP bearer; token file DERIVED from defaults.resource (botify_mcp)
 
 _FILE_KINDS = (_OAUTH_KIND, _SERVICE_KIND)
 _ENV_KINDS = (_BEARER_KIND, _BASIC_KIND)
-_MTIME_KINDS = (_OAUTH_KIND, _BROWSER_KIND)  # kinds whose freshness decays with time
+_MTIME_KINDS = (_OAUTH_KIND, _BROWSER_KIND, _MCP_KIND)  # kinds whose freshness decays with time
 
 _KIND_LABEL = {
     _OAUTH_KIND: 'oauth',
@@ -115,11 +117,30 @@ _KIND_LABEL = {
     _BEARER_KIND: 'bearer',
     _BASIC_KIND: 'basic',
     _BROWSER_KIND: 'browser',
+    _MCP_KIND: 'mcp',
 }
 
 _MARK = {'filled': '[x]', 'stale': '[~]', 'partial': '[/]', 'empty': '[ ]',
          'no-path': '[!]', 'unknown': '[?]'}
 
+# THE THIRD VERBATIM COPY. token_path_for lives in scripts/connectors/mcp.py
+# and scripts/connectors/mcp_warm.py; this leg turns the twins into triplets.
+# The WET connector contract forbids wallet.py importing mcp.py, so the
+# derivation is duplicated ON PURPOSE and the copies are COMPARED BY PROBE,
+# never trusted -- the existing twin-agreement probe simply gains a third leg.
+# TOKEN_DIR keeps mcp.py's exact constant name so the function body below can
+# stay byte-identical to its siblings.
+TOKEN_DIR = Path.home() / ".config" / "pipulate" / "mcp"
+
+
+def token_path_for(resource):
+    """Credential path for one MCP server. A root path collapses to the host."""
+    parsed = urlparse(resource or "")
+    host = (parsed.netloc or "unknown-host").lower()
+    path = (parsed.path or "").strip("/")
+    stem = host if not path else f"{host}__{quote(path, safe='')}"
+    return TOKEN_DIR / f"{stem}.json"
+
 
 def die(msg, code=1):
     sys.stderr.write(msg.rstrip('\n') + '\n')
@@ -269,6 +290,22 @@ def classify_slot(name, cfg, stale_days):
         pdir = str(REPO_ROOT / 'data' / 'uc_profiles' / profile) if profile else None
         state, detail = _stat_state(pdir, stale_days, mtime_matters=True)
         return state, kind, detail, pdir or '(no profile declared)'
+    if kind == _MCP_KIND:
+        # THE STAT IS THE DERIVED FILE ONLY. The pre-derivation
+        # mcp_botify_token.json is invisible here BY RULE: the offline board
+        # never opens a token's bytes, and scoping the legacy file to its
+        # server requires reading its recorded resource. So a legacy-only
+        # credential reads empty on THIS board while `check` -- which shells
+        # to mcp.py and its scoped resolver -- can still read GREEN. When the
+        # two lanes disagree, the live one is truthful; warming this slot
+        # runs the browser mint, which writes the derived path and ends the
+        # split for good.
+        resource = (cfg.get('defaults') or {}).get('resource')
+        if not resource:
+            return 'no-path', kind, 'slot declares no defaults.resource', '—'
+        tok = str(token_path_for(resource))
+        state, detail = _stat_state(tok, stale_days, mtime_matters=True)
+        return state, kind, detail, tok
     if kind in _ENV_KINDS:
         state, detail = _env_state(cfg)
         return state, kind, detail, 'env / .env (out of git)'
@@ -286,6 +323,9 @@ def _next_hint(name, state, kind):
     if kind == _BROWSER_KIND:
         return (f"python scripts/connectors/wallet.py warm {name}   "
                 f"(confirms, then opens this slot's own site + profile)")
+    if kind == _MCP_KIND:
+        return (f"python scripts/connectors/wallet.py warm {name}   "
+                f"(headless refresh when the derived file can; else browser mint)")
     if kind in _ENV_KINDS:
         return (f"python scripts/connectors/wallet.py warm {name}   "
                 f"(prompts for each missing var, saves to {DOTENV_PATH})")
@@ -605,6 +645,60 @@ def _warm_service(name, cfg):
             f"Console → IAM → Service Accounts) to {key or '(declare paths.service_account)'}")
 
 
+def _warm_mcp(name, cfg, assume_yes):
+    """mcp_oauth: delegate to mcp_warm.py, which owns the OAuth 2.1 dance.
+
+    THE DECISION FILE IS THE DERIVED FILE -- the same file the offline
+    scoreboard stats -- so this verb's reader and that board's reader can
+    never drift onto different files (the VAULT_ENV conviction, applied to
+    tokens). Derived file present with a refresh_token: headless refresh
+    (the Cinderella rung). Anything else -- absent, unreadable, or
+    refresh-less -- runs the browser PKCE mint, which WRITES the derived
+    path. A credential living only in the pre-derivation
+    mcp_botify_token.json therefore migrates on its first warm here, and
+    the offline board's under-reporting of it ends. Reading the token
+    file's bytes is legal in THIS verb (warm already handles secret
+    values); the offline scoreboard still never does.
+    """
+    resource = (cfg.get('defaults') or {}).get('resource')
+    if not resource:
+        return 'skipped (no defaults.resource declared)'
+    script = Path(__file__).resolve().parent / 'mcp_warm.py'
+    if not script.exists():
+        return f"mcp_warm.py not found at {script}"
+    tok = token_path_for(resource)
+    can_refresh = False
+    if tok.is_file():
+        try:
+            record = json.loads(tok.read_text(encoding='utf-8'))
+            can_refresh = isinstance(record, dict) and bool(record.get('refresh_token'))
+        except (OSError, ValueError):
+            can_refresh = False
+    failed = None
+    if can_refresh:
+        print(f"  → {Path(sys.executable).name} {script.name} {resource} --refresh   (headless)")
+        try:
+            rc = subprocess.call([sys.executable, str(script), resource, '--refresh'])
+        except OSError as e:
+            return f"could not run mcp_warm.py: {e}"
+        if rc == 0:
+            return 'refreshed headlessly'
+        failed = rc
+    if failed is not None:
+        question = f"  refresh exited {failed} -- run the browser mint for {resource} instead?"
+    else:
+        question = f"  mint OAuth for {resource} (opens a browser)?"
+    if not _confirm(question, assume_yes):
+        return 'skipped' if failed is None else f"refresh exited {failed}; browser mint declined"
+    print(f"  → {Path(sys.executable).name} {script.name} {resource}")
+    print(f"    Authorize in the browser; the token lands at {tok}")
+    try:
+        rc = subprocess.call([sys.executable, str(script), resource])
+    except OSError as e:
+        return f"could not run mcp_warm.py: {e}"
+    return 'browser mint finished' if rc == 0 else f"mcp_warm exited {rc}"
+
+
 def warm(slot_name, stale_days, assume_yes=False, dry_run=False):
     """Walk every not-filled slot (or just one) and actually warm it."""
     wallet = load_wallet()
@@ -664,6 +758,8 @@ def warm(slot_name, stale_days, assume_yes=False, dry_run=False):
             note = _warm_env(n, c, assume_yes, force=bool(slot_name))
         elif kind == _BROWSER_KIND:
             note = _warm_browser(n, c, assume_yes)
+        elif kind == _MCP_KIND:
+            note = _warm_mcp(n, c, assume_yes)
         elif kind == _SERVICE_KIND:
             note = _warm_service(n, c)
         else:
@@ -860,6 +956,69 @@ def check_browser_slot(name, cfg):
                f"profile '{profile}' ({len(live)} live total{when})")
 
 
+def check_mcp_slot(name, cfg):
+    """SELECT 1 for an mcp_oauth slot: refresh, then check, both delegated.
+
+    DISPATCH BY KIND, mirroring check_browser_slot: an mcp_oauth slot is a
+    remote server plus a derived token file, not a connector module, so the
+    filename lookup would demand a botify_mcp.py that should never exist.
+
+    THE CHAIN IS THE CHECK (the combination-lock rule, banked 2026-08-30):
+    this credential class mints access tokens shorter-lived than the human
+    loop -- 300 seconds, witnessed -- so a bare `mcp.py --check` would read
+    RED gate2 (401) for a perfectly healthy credential almost every time.
+    The durable credential is the REFRESH token, and the Google oauth slots
+    already green exactly this way: their connectors' get_service() refreshes
+    transparently inside their own --check. So: best-effort
+    `mcp_warm.py <resource> --refresh` first (headless by design), then
+    `mcp.py <resource> --check`. The check's exit code is the whole verdict,
+    so a still-live token or an env-lane token can green even when the
+    refresh half fails, and a dead refresh surfaces as the check's own RED.
+
+    stderr caveat: mcp.py's atexit FDR-receipt line lands AFTER the RED gate
+    line, so the diagnostic is the last line STARTING with 'mcp RED', never
+    blindly stderr's tail.
+    """
+    resource = (cfg.get('defaults') or {}).get('resource')
+    if not resource:
+        return 1, (f"{name} RED gate1: slot declares no defaults.resource, "
+                   "so there is no server to check")
+    here = Path(__file__).resolve().parent
+    check_script = here / 'mcp.py'
+    warm_script = here / 'mcp_warm.py'
+    if not check_script.exists():
+        return 2, f"no client module ({check_script.name})"
+    env = _check_env()
+    if warm_script.exists():
+        try:
+            subprocess.run(
+                [sys.executable, str(warm_script), resource, '--refresh'],
+                stdin=subprocess.DEVNULL, capture_output=True, text=True,
+                timeout=CHECK_TIMEOUT, env=env,
+            )
+        except (subprocess.TimeoutExpired, OSError):
+            pass  # the check below is the verdict; a dead refresh reds there
+    try:
+        proc = subprocess.run(
+            [sys.executable, str(check_script), resource, '--check'],
+            stdin=subprocess.DEVNULL, capture_output=True, text=True,
+            timeout=CHECK_TIMEOUT, env=env,
+        )
+    except subprocess.TimeoutExpired:
+        return 1, f"{name} RED gate2: no answer within {CHECK_TIMEOUT}s"
+    except OSError as e:
+        return 2, f"could not run {check_script.name}: {e}"
+    if proc.returncode == 0:
+        out = [ln for ln in (proc.stdout or '').strip().splitlines()
+               if ln.strip() and not ln.lstrip().startswith('#')]
+        return 0, out[-1] if out else f"{name} GREEN"
+    gates = [ln.strip() for ln in (proc.stderr or '').strip().splitlines()
+             if ln.strip().startswith('mcp RED')]
+    if gates:
+        return 1, gates[-1]
+    return 1, f"{name} RED (exit {proc.returncode}, no gate line)"
+
+
 def check_slot(name, cfg=None):
     """Check one slot. Returns (code, line).
 
@@ -885,6 +1044,8 @@ def check_slot(name, cfg=None):
     """
     if (cfg or {}).get('auth') == _BROWSER_KIND:
         return check_browser_slot(name, cfg)
+    if (cfg or {}).get('auth') == _MCP_KIND:
+        return check_mcp_slot(name, cfg)
     script = Path(__file__).resolve().parent / f"{name}.py"
     if not script.exists():
         return 2, f"no connector module ({script.name})"
(nix) pipulate $ m
📝 Committing: chore: Add MCP OAuth support
[main 1c3c3ccb] chore: Add MCP OAuth support
 1 file changed, 162 insertions(+), 1 deletion(-)
(nix) pipulate $ patch
(nix) pipulate $ app
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/wallet.py'.
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/wallet.py'.
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/wallet.py'.
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/wallet.py'.
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/wallet.py'.
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/README.md'.
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/mcp_warm.py'.
(nix) pipulate $ d
diff --git a/scripts/connectors/README.md b/scripts/connectors/README.md
index 25f27940..052c7a98 100644
--- a/scripts/connectors/README.md
+++ b/scripts/connectors/README.md
@@ -60,7 +60,10 @@ in neither.
 Auth kinds: oauth_token_file (gmail), bearer_token (botify), basic_auth
 (confluence), service_account_file (gsc), browser_session (botify_browser,
 semrush — a persistent Chrome profile under data/uc_profiles/<name>, warmed by
-weblogin.py, not a token). Every future connector copies one of these five.
+weblogin.py, not a token), and mcp_oauth (botify_mcp — a remote MCP bearer
+whose token file is DERIVED from the slot's defaults.resource and minted or
+refreshed by mcp_warm.py; the durable credential scored is the refresh
+token). Every future connector copies one of these six.
 
 Credential paths are DERIVED, never chosen. A connector that talks to more than
 one server of the same kind — MCP is the first — computes its token path from
diff --git a/scripts/connectors/mcp_warm.py b/scripts/connectors/mcp_warm.py
index e9d73e94..1f7b1bdd 100644
--- a/scripts/connectors/mcp_warm.py
+++ b/scripts/connectors/mcp_warm.py
@@ -192,10 +192,12 @@ def refresh(out_path, resource_hint=DEFAULT_RESOURCE):
     token_endpoint. This path adds a GRANT TYPE, not a mechanism -- which is
     precisely why the discovery round-trip does not split it in half.
 
-    UNWITNESSED UNTIL GREEN: no refresh has ever been POSTed to this vendor.
-    Public-client refusal, single-use rotation, and refresh_token expiry are
-    all live possibilities. Every one of them dies LOUD at gate6 rather than
-    leaving a half-written token file behind.
+    WITNESSED GREEN 2026-08-30: three chained --refresh runs against this
+    vendor each returned gate6 GREEN and a fresh 300s access token, so the
+    grant type is live for this credential. Single-use rotation remains
+    unobserved (no rotated refresh_token has yet come back) and
+    refresh_token expiry is still a live possibility; both die LOUD at
+    gate6 rather than leaving a half-written token file behind.
 
     No TTY required and none requested -- there is no browser in this path.
     """
diff --git a/scripts/connectors/wallet.py b/scripts/connectors/wallet.py
index ddfe025b..7a35dff9 100644
--- a/scripts/connectors/wallet.py
+++ b/scripts/connectors/wallet.py
@@ -18,7 +18,7 @@ Designed to be dropped into adhoc.txt as a `!` chisel-strike, e.g.:
 This is the GENERALIZATION of each connector's no-argument identity() walk
 lifted from ONE connector to the WHOLE wallet. It reads
 ~/.config/pipulate/connectors.json (override: PIPULATE_WALLET) and reports
-every slot at once — across all FIVE auth kinds the wallet actually holds —
+every slot at once — across all SIX auth kinds the wallet actually holds —
 so a glance tells you which sessions are live, which have gone stale, which
 have never been warmed, and (crucially) which the wallet genuinely CANNOT
 warm for you and why.
@@ -44,6 +44,13 @@ client_secret. It learns a slot's state from cheap, local evidence only:
                         data/uc_profiles/<name> that weblogin.py warms
                         (botify_browser, semrush) → mtime staleness, because
                         sites DO expire browser sessions.
+  mcp_oauth             os.stat() the DERIVED token file
+                        ~/.config/pipulate/mcp/<host>.json that mcp_warm.py
+                        writes (botify_mcp). A refresh rewrites the file, so
+                        mtime tracks "last refreshed" exactly as oauth does.
+                        The pre-derivation mcp_botify_token.json is invisible
+                        to THIS board by rule (scoping it means reading its
+                        bytes); the CHECK verb still honors it, scoped.
 
 HONEST HEURISTICS, stated plainly (a clean caveat is a valid receipt):
   - `stale` is an mtime guess, never a validity proof. Only a live call can
@@ -275,7 +282,7 @@ def _env_state(slot):
 
 def classify_slot(name, cfg, stale_days):
     """Dispatch on the slot's auth kind. Returns (state, kind, detail, locator).
-    The single place that knows how each of the five kinds proves itself."""
+    The single place that knows how each of the six kinds proves itself."""
     kind = cfg.get('auth')
     if kind == _OAUTH_KIND:
         tok = resolve_path(cfg, 'token', 'paths.token')
@@ -333,14 +340,14 @@ def _next_hint(name, state, kind):
 
 
 def scoreboard(wallet, max_items, stale_days):
-    """Print the read-only board for EVERY slot, across all five auth kinds."""
+    """Print the read-only board for EVERY slot, across all six auth kinds."""
     slots = [(name, cfg) for name, cfg in wallet.items()
              if not name.startswith('_') and isinstance(cfg, dict) and cfg.get('auth')]
 
     print("# wallet.py — connector auth scoreboard (read-only, offline)")
     print(f"# wallet: {Path(WALLET_PATH).expanduser()}")
     print(f"# repo:   {REPO_ROOT}  (anchors browser_session profiles)")
-    print(f"# stale after: {stale_days}d — mtime heuristic for oauth/browser, "
+    print(f"# stale after: {stale_days}d — mtime heuristic for oauth/browser/mcp, "
           "not a validity proof\n")
 
     if not slots:
(nix) pipulate $ m
📝 Committing: chore: Update README.md to reflect six auth kinds and mcp_warm.py green status
[main 8ee9106d] chore: Update README.md to reflect six auth kinds and mcp_warm.py green status
 3 files changed, 21 insertions(+), 9 deletions(-)
(nix) pipulate $ git push
Enumerating objects: 18, done.
Counting objects: 100% (18/18), done.
Delta compression using up to 48 threads
Compressing objects: 100% (12/12), done.
Writing objects: 100% (12/12), 4.58 KiB | 2.29 MiB/s, done.
Total 12 (delta 10), reused 0 (delta 0), pack-reused 0 (from 0)
remote: Resolving deltas: 100% (10/10), completed with 6 local objects.
To github.com:pipulate/pipulate.git
   f44b529b..8ee9106d  main -> main
(nix) pipulate $ 

And non-patch stuff:

(nix) pipulate $ python - <<'EOF'
import json, shutil
from pathlib import Path
p = Path.home() / '.config' / 'pipulate' / 'connectors.json'
shutil.copy2(p, p.with_suffix('.json.bak'))
w = json.loads(p.read_text(encoding='utf-8'))
w['botify_mcp'] = {
    "auth": "mcp_oauth",
    "defaults": {"resource": "https://mcp.botify.com/"},
}
w.setdefault('gong', {})['enrolled'] = False
p.write_text(json.dumps(w, indent=2) + '\n', encoding='utf-8')
print('slots:', ', '.join(k for k in w if not k.startswith('_')))
print('botify_mcp auth:', w['botify_mcp']['auth'])
print('gong enrolled:', w['gong']['enrolled'])
EOF
slots: gmail, sheets, botify, confluence, gsc, jira, slack, gong, botify_browser, semrush, botify_mcp
botify_mcp auth: mcp_oauth
gong enrolled: False
(nix) pipulate $ python scripts/connectors/wallet.py check botify_mcp
# wallet checkLIVE credential board (one bounded call per slot)
# wallet: /home/mike/.config/pipulate/connectors.json
# green means the service accepted this credential just now, not merely that a token exists

  🟢 botify_mcp  mcp       mcp GREEN https://mcp.botify.com/ protocol=2025-06-18 session=no tools=42 auth=mcp_botify_token.json scheme=Bearer

# 1 green | 0 red | 0 unchecked
# 🏆 GOLDevery enrolled credential (1/1) is live.
(nix) pipulate $ python scripts/connectors/wallet.py warm botify_mcp
# wallet warmthe verb the scoreboard implies
# wallet:  /home/mike/.config/pipulate/connectors.json
# secrets: /home/mike/.config/pipulate/.env  (0600, out of git)

# 1 slot(s) to warm:
  [ ] empty    mcp       botify_mcp      not present

----------------------------------------------------------------------
[ ] botify_mcp  (mcp, was empty)
  mint OAuth for https://mcp.botify.com/ (opens a browser)? [y/N] ypython mcp_warm.py https://mcp.botify.com/
    Authorize in the browser; the token lands at /home/mike/.config/pipulate/mcp/mcp.botify.com.json
# gate2 GREEN via RFC 8414: https://app.botify.com/.well-known/oauth-authorization-server
# gate3 GREEN: client_id minted via RFC 7591 dynamic registration
# gate4: opening browser (redirect catch on http://127.0.0.1:42619/callback)
#        if no browser appears, open this URL yourself:
https://app.botify.com/oauth/authorize?response_type=code&client_id=<redacted:40>&redirect_uri=http%3A%2F%2F127.0.0.1%3A42619%2Fcallback&state=<redacted:22>&code_challenge=<redacted:43>&code_challenge_method=S256&resource=https%3A%2F%2Fmcp.botify.com%2F&scope=mcp_read_write
Opening in existing browser session.
# gate5 GREEN: token written to /home/mike/.config/pipulate/mcp/mcp.botify.com.json (0600)
#   scope: mcp_read_write | expires_in: 300
# Next: python scripts/connectors/mcp.py https://mcp.botify.com --check

----------------------------------------------------------------------
# after warming:
  [x] filled   mcp       botify_mcp      2026-08-31 (0d ago)browser mint finished

# 1 filled | 0 still cold (offline board -- run `check` to see what the services accept)
# Re-run the bare scoreboard for the whole board.
(nix) pipulate $ 

Nice!

4: Prompt: Read the LIVE COMMAND RECEIPTS first; name the lane for each and rule one line each:

  1. The bare scoreboard: eleven slots, botify_mcp present as kind mcp. If Car 5 ran it reads filled at the derived path; if not, empty there while receipt 3 reads GREEN – that split is documented, name it, do not “fix” it.
  2. The triplet probe: token_path_for triplets agree: True. An AttributeError means Car 1 did not land; a False means a copy drifted – name which by output diff, and stop.
  3. check botify_mcp: GREEN with tools=42 (a different count is drift, not failure), or the gate line verbatim. If gate6 RED appeared in the chain but the check still greened, say why that is correct behavior.

Also rule: does the board tally now show gong benched and GOLD blocked only by gsc (unchecked) plus any game-loop reds (sheets timeout, botify_browser)? Then ONE car against scripts/connectors/gsc.py (in context): a –check health probe modeled on botify.py’s – one bounded API call, exit 0 GREEN with an identity line on stdout, exit 1 RED with a gate-named line on stderr, 15s budget, stdin-safe. No other gsc.py changes. Give its straddle: BEFORE argparse exits 2 on –check (the board renders UNCHECKED), AFTER the board scores it. Do not touch mcp.py, mcp_warm.py, or wallet.py this turn. The render-server session question waits for its first real flight’s FDR receipts.

5: Deliverables: None

gsc.py is a mistake. There SHOULD NOT BE ANY SERVICE ACCOUNTS in this system. Rewire gsc.py to OAuth2 just like everything else if OAuth is supported for GSC (I don’t see why not) and make it work just like everything else.

Fable 5 Extra: Context verified. Canary reading: www.canary.invalid arrived bare this compile — the transport did not linkify, so www-tokens in bodies and receipts are quotable. One named gap: gmail.py and sheets.py — the oauth siblings gsc.py should copy — are not in this context. The rewrite below is therefore written to the contract wallet.py itself enforces (a get_service() the login verb can reuse, paths.credentials/paths.token needles, refresh-rewrites-the-file, TTY-gated mint, exit-code --check), and next compile brings the siblings in to reconcile any cosmetic drift. That’s a reconciliation item, not a blocker.

Receipt rulings, lane by lane

1. Bare scoreboard (offline stat lane): eleven slots, botify_mcp present as kind mcp — and it reads filled at the derived path ~/.config/pipulate/mcp/mcp.botify.com.json (2026-08-31, 0d). Car 5 ran, so the offline/live split isn’t merely named — it’s ended. The header’s oauth/browser/mcp confirms Car 2’s prose landed.

2. Triplet probe (pure-import lane, no I/O): token_path_for triplets agree: True across bare, trailing-slash, and path-slug URLs. Car 1 landed; no copy drifted. The transcript’s AttributeError was the honest BEFORE, exactly as predicted.

3. check botify_mcp (live lane): GREEN, tools=42 — no drift — and the detail that matters: auth=mcp.botify.com.json. The resolver now serves the derived file; the scoped legacy fallback did not fire. Compare the transcript’s pre-mint check (auth=mcp_botify_token.json): this receipt is the migration completing, witnessed. No gate line in sight means the refresh half of the chain worked silently, which is the design.

Board tally: the full check board is not among this compile’s receipts (the Manifest lists only the one-slot check), so I won’t claim it from memory. From the Car 3 upsert the transcript witnessed (gong enrolled: False), the full board will bench gong; the known blockers were gsc (unchecked), sheets’ transient timeout, and botify_browser’s visited-not-logged-in. This turn dissolves the first one.

The premise, ruled

Yes — OAuth2 is fully supported for Search Console: the webmasters.readonly scope works with ordinary user credentials, same Desktop-app client JSON that gmail and sheets already mint from. The service account was never structurally required. Two consequences worth naming before the patch, both in your favor:

Visibility changes from “properties where the SA’s email was invited” to “properties your Google account can see” — which is what you actually mean when you type gsc. The Testing-mode 7-day refresh cliff now applies to gsc exactly as it does to gmail/sheets — and the wallet’s stale heuristic and login gsc re-mint already handle that class, which is the whole point of “just like everything else.” The 48-day-old key file stops being an eleventh thing to babysit.

One constraint honored: the caboose forbade touching wallet.py this turn, so the service_account_file kind goes dormant, not extinct — after Car 2 no slot declares it, and _warm_service, the svc-acct classify arm, and the docstring’s (gsc) parenthetical become orphaned prose. Next turn, with wallet.py editable again, you decide kill-or-keep. Named, not built.

1. PROBES

python scripts/connectors/gsc.py --check; echo "gsc_check_exit=$?"
rg -ln "service_account|service-account-key" scripts imports apps 2>/dev/null | sort
python -c "from pathlib import Path; h = Path.home() / '.config/pipulate'; print('credentials.json:', (h / 'credentials.json').exists(), '| gsc_token.json:', (h / 'gsc_token.json').exists(), '| service-account-key.json:', (h / 'service-account-key.json').exists())"

Probe 1 is the straddle’s left edge: BEFORE, argparse rejects --check and exits 2 — the exact state that renders UNCHECKED on the board — and it exits before any auth code runs, so it’s read-only. AFTER (next compile), the same line prints the GREEN identity and exit 0; it becomes the one socket-opening echo, bounded to one slot, same precedent as last turn’s check echo. Probe 2 is the service-account estate inventory: BEFORE it lists scripts/connectors/gsc.py plus any other consumers (the old resolve_key_path comment names scripts/gsc/gsc_top_movers.py as a parity twin); AFTER, gsc.py vanishes from the list and whatever remains is the estate to port or retire. Probe 3 gates Car 3: if credentials.json reads False, the mint cannot run and you must place the shared Desktop-app client JSON first; gsc_token.json flips to True after the mint.

2. NEXT CONTEXT

scripts/connectors/gsc.py
scripts/connectors/gmail.py
scripts/connectors/sheets.py
scripts/connectors/wallet.py
! python scripts/connectors/gsc.py --check; echo "gsc_check_exit=$?"
! rg -ln "service_account|service-account-key" scripts imports apps 2>/dev/null | sort
! python -c "from pathlib import Path; h = Path.home() / '.config/pipulate'; print('credentials.json:', (h / 'credentials.json').exists(), '| gsc_token.json:', (h / 'gsc_token.json').exists(), '| service-account-key.json:', (h / 'service-account-key.json').exists())"

gmail.py and sheets.py come in for drift reconciliation; wallet.py comes back in because it’s touchable again next turn (the docstring re-home and the kind’s kill-or-keep decision).

3. PATCHES

Car 1 — gsc.py rewired to OAuth, six blocks, one paste. The three mode functions (list_properties, list_top_queries, run_query) are deliberately untouched except one service-account-specific message string — the diff itself proves only the auth layer changed.

Target: scripts/connectors/gsc.py
[[[SEARCH]]]
Auth (service_account_file — headless by construction, no browser dance ever):
  PIPULATE_GSC_KEY env var
    -> ~/.config/pipulate/connectors.json gsc.paths.service_account
      -> clean failure naming the missing variable.
[[[DIVIDER]]]
Auth (oauth_token_file — the same user-OAuth walk as gmail.py and sheets.py):
  token:       PIPULATE_GSC_TOKEN env var
    -> ~/.config/pipulate/connectors.json gsc.paths.token
      -> ~/.config/pipulate/gsc_token.json
  credentials: PIPULATE_GSC_CREDENTIALS env var
    -> ~/.config/pipulate/connectors.json gsc.paths.credentials
      -> ~/.config/pipulate/credentials.json  (the shared Desktop-app client)
  A valid token refreshes headlessly, and the token file is REWRITTEN on
  every refresh so the wallet's offline mtime heuristic tracks "last
  refreshed". A missing or dead token browser-mints ONLY on a real TTY —
  `python scripts/connectors/wallet.py login gsc` is the one-time mint,
  exactly as for gmail and sheets. No service account anywhere.
[[[REPLACE]]]

Target: scripts/connectors/gsc.py
[[[SEARCH]]]
from google.oauth2 import service_account
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
[[[DIVIDER]]]
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
[[[REPLACE]]]

Target: scripts/connectors/gsc.py
[[[SEARCH]]]
def resolve_key_path():
    """PIPULATE_GSC_KEY env -> wallet gsc.paths.service_account -> None."""
    env = os.environ.get('PIPULATE_GSC_KEY')
    if env:
        return Path(env).expanduser()
    if WALLET_FILE.exists():
        try:
            wallet = json.loads(WALLET_FILE.read_text(encoding='utf-8'))
            p = (wallet.get('gsc') or {}).get('paths', {}).get('service_account')
            if p:
                return Path(p).expanduser()
        except (json.JSONDecodeError, OSError):
            pass
    # Wallet-path default (parity with scripts/gsc/gsc_top_movers.py): a
    # corrupted or clobbered connectors.json must not strand a key sitting
    # at the canonical wallet path. get_service()'s exists() check still
    # fails closed if the file is genuinely absent.
    return Path.home() / '.config' / 'pipulate' / 'service-account-key.json'
[[[DIVIDER]]]
def _wallet_path(key):
    """gsc.paths.<key> from the wallet, or None. Names and paths only."""
    if WALLET_FILE.exists():
        try:
            wallet = json.loads(WALLET_FILE.read_text(encoding='utf-8'))
            p = (wallet.get('gsc') or {}).get('paths', {}).get(key)
            if p:
                return Path(p).expanduser()
        except (json.JSONDecodeError, OSError):
            pass
    return None

def resolve_token_path():
    """PIPULATE_GSC_TOKEN env -> wallet gsc.paths.token -> canonical default."""
    env = os.environ.get('PIPULATE_GSC_TOKEN')
    if env:
        return Path(env).expanduser()
    return _wallet_path('token') or Path.home() / '.config' / 'pipulate' / 'gsc_token.json'

def resolve_credentials_path():
    """PIPULATE_GSC_CREDENTIALS env -> wallet gsc.paths.credentials -> the
    shared Desktop-app OAuth client JSON the other Google connectors mint from."""
    env = os.environ.get('PIPULATE_GSC_CREDENTIALS')
    if env:
        return Path(env).expanduser()
    return _wallet_path('credentials') or Path.home() / '.config' / 'pipulate' / 'credentials.json'

def _write_token(token_path, creds):
    """Rewrite the token file on every mint AND refresh, 0600. The rewrite is
    what makes the wallet's offline mtime heuristic track 'last refreshed'."""
    token_path.parent.mkdir(parents=True, exist_ok=True)
    token_path.write_text(creds.to_json(), encoding='utf-8')
    os.chmod(token_path, 0o600)
[[[REPLACE]]]

Target: scripts/connectors/gsc.py
[[[SEARCH]]]
def get_service():
    key_path = resolve_key_path()
    if not key_path:
        die(
            "No GSC key path configured.\n"
            "Set PIPULATE_GSC_KEY=~/.config/pipulate/service-account-key.json\n"
            "or add gsc.paths.service_account to ~/.config/pipulate/connectors.json."
        )
    if not key_path.exists():
        die(
            f"GSC service-account key not found at: {key_path}\n"
            "Download the JSON key for the service account from Google Cloud Console,\n"
            "save it at that path, and chmod 600 it. Then add the service account's\n"
            "email as a user on each Search Console property it should read."
        )
    creds = service_account.Credentials.from_service_account_file(
        str(key_path), scopes=SCOPES)
    return build('webmasters', 'v3', credentials=creds)
[[[DIVIDER]]]
def _load_creds():
    """(creds_or_None, reason). Headless by construction: refreshes when it
    can, rewrites the token file when it does, and NEVER opens a browser."""
    token_path = resolve_token_path()
    if not token_path.exists():
        return None, f"no OAuth token at {token_path}"
    try:
        creds = Credentials.from_authorized_user_file(str(token_path), SCOPES)
    except (ValueError, OSError) as e:
        return None, f"token unreadable ({e})"
    if creds.valid:
        return creds, 'live'
    if creds.expired and creds.refresh_token:
        try:
            creds.refresh(Request())
        except Exception as e:
            return None, f"refresh rejected ({e})"
        _write_token(token_path, creds)
        return creds, 'refreshed'
    return None, 'token expired and holds no refresh_token'

def get_service():
    """Refresh headlessly, or browser-mint on a real TTY — the same walk
    wallet.py's login verb reuses for gmail and sheets. A `!` chisel-strike
    can never block here: no TTY means a clean die(), never a browser."""
    creds, reason = _load_creds()
    if creds is None:
        creds_path = resolve_credentials_path()
        if not creds_path.exists():
            die(
                f"GSC OAuth needs the Desktop-app client JSON at: {creds_path}\n"
                "It is the same credentials.json the gmail/sheets connectors mint\n"
                "from. Download it once from the Google Cloud Console, then run:\n"
                "    python scripts/connectors/wallet.py login gsc"
            )
        if not sys.stdin.isatty():
            die(
                f"GSC token not usable ({reason}) and no TTY to browser-mint.\n"
                "In a real terminal, run:\n"
                "    python scripts/connectors/wallet.py login gsc"
            )
        flow = InstalledAppFlow.from_client_secrets_file(str(creds_path), SCOPES)
        creds = flow.run_local_server(port=0)
        _write_token(resolve_token_path(), creds)
    return build('webmasters', 'v3', credentials=creds)

def check():
    """SELECT 1 for the `warm` board: exit 0 GREEN, exit 1 RED (gate-named).

    Modeled on botify.py's two-gate probe. gate1 is "credential present and
    loadable" — headless refresh allowed, because the durable credential
    scored is the refresh token, exactly as for every other oauth slot.
    gate2 is "the live API accepted it": one bounded sites().list() call.
    15s budget via socket default timeout, stdin-safe: this path NEVER
    browser-mints, so it can never hang the board on a prompt.
    """
    import socket
    creds, reason = _load_creds()
    if creds is None:
        sys.stderr.write(
            f"gsc RED gate1: {reason} -- run "
            "`python scripts/connectors/wallet.py login gsc`\n")
        return 1
    socket.setdefaulttimeout(15)
    try:
        service = build('webmasters', 'v3', credentials=creds)
        resp = service.sites().list().execute()
    except HttpError as e:
        sys.stderr.write(f"gsc RED gate2: API rejected the call: {e}\n")
        return 1
    except Exception as e:
        sys.stderr.write(f"gsc RED gate2: transport failure: {e}\n")
        return 1
    entries = resp.get('siteEntry', [])
    noun = 'property' if len(entries) == 1 else 'properties'
    print(f"gsc GREEN {len(entries)} {noun} visible ({reason})")
    return 0
[[[REPLACE]]]

Target: scripts/connectors/gsc.py
[[[SEARCH]]]
        print("(no properties — has the service account's email been added as a "
              "user in Search Console?)")
[[[DIVIDER]]]
        print("(no properties — does this Google account have access to any "
              "Search Console properties?)")
[[[REPLACE]]]

Target: scripts/connectors/gsc.py
[[[SEARCH]]]
    parser.add_argument('-n', '--max', type=int, default=25,
                        help='Output cap per THE PROBE ECONOMY RULE (default: 25).')
    args = parser.parse_args()

    service = get_service()
[[[DIVIDER]]]
    parser.add_argument('-n', '--max', type=int, default=25,
                        help='Output cap per THE PROBE ECONOMY RULE (default: 25).')
    parser.add_argument('--check', action='store_true',
                        help='SELECT 1 health check: one GREEN line on stdout and '
                             'exit 0, or one gate-named RED line on stderr and '
                             'exit 1. Never interactive.')
    args = parser.parse_args()

    if args.check:
        sys.exit(check())

    service = get_service()
[[[REPLACE]]]

Car 2 — the slot flip, as a mutating actuator. connectors.json is out-of-repo and out-of-context, so this is a JSON-parse upsert with a fresh backup name (it won’t clobber last turn’s .bak). It preserves everything else in the slot (any defaults survive), removes only the service-account path and env var, and installs the two env overrides whose descriptions carry the exact needles (paths.credentials, paths.token) that wallet.py’s login steering matches on. Reversible via the printed backup.

python - <<'EOF'
import json, shutil
from pathlib import Path
home = Path.home()
p = home / '.config' / 'pipulate' / 'connectors.json'
bak = p.with_name('connectors.json.gsc-oauth.bak')
shutil.copy2(p, bak)
w = json.loads(p.read_text(encoding='utf-8'))
slot = w.setdefault('gsc', {})
slot['auth'] = 'oauth_token_file'
paths = slot.get('paths') or {}
paths.pop('service_account', None)
paths['credentials'] = str(home / '.config' / 'pipulate' / 'credentials.json')
paths['token'] = str(home / '.config' / 'pipulate' / 'gsc_token.json')
slot['paths'] = paths
env = slot.get('env') or {}
env.pop('PIPULATE_GSC_KEY', None)
env['PIPULATE_GSC_CREDENTIALS'] = 'optional; overrides paths.credentials (shared Desktop-app OAuth client JSON)'
env['PIPULATE_GSC_TOKEN'] = 'optional; overrides paths.token (minted GSC OAuth token file)'
slot['env'] = env
p.write_text(json.dumps(w, indent=2) + '\n', encoding='utf-8')
print('backup:', bak)
print('gsc auth:', slot['auth'])
print('gsc paths:', json.dumps(slot['paths']))
print('gsc env keys:', ', '.join(slot['env']))
EOF

Car 3 — the one-time mint, interactive. Reuses gsc.py’s own get_service() through the login verb, exactly the gmail/sheets path. Browser opens once, consent covers webmasters.readonly, and the token lands at ~/.config/pipulate/gsc_token.json — after which the refresh token is the credential forever forward.

python scripts/connectors/wallet.py login gsc

Car 4 — the live check, one slot. Expect gsc GREEN N properties visible (live) and the board’s GREEN row. If it reds with accessNotConfigured, that’s the external-console dependency in section 5, not a code fault.

python scripts/connectors/wallet.py check gsc

Choreography: Car 1 — patch, app, d, m. Car 2 — paste the heredoc (out-of-repo; nothing to commit). Car 3 — run in a real terminal (browser dance, once). Car 4 — run as shown. Then blast. Ignition: none required — every probe and car loads gsc.py fresh at call time; the gsc alias already points at the file being patched.

4. PROMPT

Read the LIVE COMMAND RECEIPTS first; name the lane for each and rule one line each:
1. The gsc --check echo: exit 0 with `gsc GREEN N properties visible (live|refreshed)`, or the gate line verbatim. RED gate1 means Car 3's mint has not run -- say so and stop on that slot; do not patch around a missing mint. BEFORE was argparse exit 2.
2. The rg inventory: scripts/connectors/gsc.py must be GONE from the list. Whatever remains (e.g. scripts/gsc/gsc_top_movers.py) is the surviving service-account estate: name each file and rule port-to-OAuth or retire, patching nothing outside context.
3. The path probe: gsc_token.json exists=True after the mint; service-account-key.json still existing is fine this turn -- deletion waits until the estate in ruling 2 is empty.
With gmail.py and sheets.py now in context, reconcile gsc.py's OAuth walk against the house siblings: name every drift (helper names, refresh-rewrite behavior, TTY gate, die() wording, --check gates) and patch ONLY behavioral differences, never cosmetic ones. wallet.py is touchable again: one prose block may re-home its docstring's `(gsc)` parenthetical off service_account_file, and rule kill-or-keep on the now-orphaned svc-acct kind (_warm_service, the classify arm) -- if kill, that is its own car with a straddle. The render-server session question still waits on its first real flight's FDR receipts.

5. EXTERNAL DELIVERABLES

One conditional console step, manual by nature: the shared Desktop-app OAuth client’s Google Cloud project must have the Search Console API enabled. If Car 4 reds with accessNotConfigured (HTTP 403), enable it in the Cloud Console for that project and re-run Car 4 — no code changes. The service-account key file and its IAM identity are deliberately left standing this turn; they come down only after next turn’s rg inventory proves nothing else reads them.

MikeLev.in:

1: Probe:

Blast Radius Check to establish bisection Left-hand Causal Boundary. It is a Popper-thing. Science.
On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean
(nix) pipulate $ python scripts/connectors/gsc.py --check; echo "gsc_check_exit=$?"
rg -ln "service_account|service-account-key" scripts imports apps 2>/dev/null | sort
python -c "from pathlib import Path; h = Path.home() / '.config/pipulate'; print('credentials.json:', (h / 'credentials.json').exists(), '| gsc_token.json:', (h / 'gsc_token.json').exists(), '| service-account-key.json:', (h / 'service-account-key.json').exists())"
usage: gsc.py [-h] [--site SITE] [-n MAX] [query]
gsc.py: error: unrecognized arguments: --check
gsc_check_exit=2
scripts/articles/gsc_historical_fetch.py
scripts/connectors/gsc.py
scripts/connectors/README.md
scripts/connectors/sheets.py
scripts/connectors/wallet.py
scripts/gsc/gsc_keyworder.py
scripts/gsc/gsc_page_query.ipynb
scripts/gsc/gsc_top_movers.py
credentials.json: True | gsc_token.json: False | service-account-key.json: True
(nix) pipulate $ 

2: Context:

# adhoc.txt    _   _   _ to set context____ _   _  ___  ____  _   <F5> Simpson Couch Gag Here (explain anything to the audience you feel needs it explained)
#     / \   __| | | | | | ___   ___   / ___| | | |/ _ \|  _ \| |  
# ahe/ _ \ / _` | | |_| |/ _ \ / __| | |   | |_| | | | | |_) | |  Focus
# ahc ___ \ (_| | |  _  | (_) | (__  | |___|  _  | |_| |  __/|_|  
#  /_/   \_\__,_| |_| |_|\___/ \___|  \____|_| |_|\___/|_|   (_)  
# Ad Hoc CHOP: The Not-Managed-by-Git Safe-for-Client-Data place  

#    # THE ROLLING PIN BOOK ORE SPINE / FODDER FOR THE BOOK
#    ! python scripts/articles/lsa.py -t 1 --reverse --fmt dated-slugs  # <-- The "Rolling Pin" that gives the 40K foot book-spine view of book-ore.
#    GLOSSARY.md                 # <-- I think this glossary goes well with the book-ore spine to do world building.
#    # scripts/articles/lsa.py     # <-- Useful for refining commands like `posts`, critical to Second Brain concept.
#     
#    # THE QUIRKY AMIGA-LOVING HUMAN
#    # ~/repos/nixos/autognome.py  # <-- Letting the AIs really understand my environment (The Brave Little Tailor punches above Their Weight Class proving the dunning-kruger effect the gate-keeper's (lower-case) lament.)
#    # init.lua                    # <-- Daily driver hot-keys that overlap with aliases in flake.nix
#     
#    # AGENTIC FRAMEWORK & FOREVER MACHINE BIG FILES
#    prompt_foo.py               # <-- Prompt Fu compiler, makes the very README for AGENTS-like payload you're reading right now, but it needs to be more like that
#    foo_files.py                # <-- This is the router, evolving book outline and the things you pin-up to produced the recursive self-improvement loops
#    flake.nix                   # <-- Solves world's WRITE ONCE RUN ANYWHERE problem like Java never could. Also resolves the bootstrap paradox.
#    
#    # MAIN ACTUATORS, IaC & NEGATIVE SPACE
#    apply.py                    # <-- How can "Web UI" ChatBots edit your code? With this Aider-inspired Player Piano patch applier.
#    .gitattributes              # <-- Model: understand that `nbstripout` and `jupytext` are both in play. Just talk the human through .ipynb patches.
#    .gitignore                  # <-- Creates "negative space" for sub-rep's to share parent environment and "snap" proprietary secret features into place.
#    requirements.in             # <-- All known dependencies and (necessary) version pinning. WORA gotcha's exposed.
#    __init__.py                 # <-- Master versioning
#    pyproject.toml              # <-- The PyPI Packaging details
#    
#    # cli.py                      # <-- Catch-all actuator for PyPI envs, Python anchoring, MCP tool-call (plus alternatives) and **kwargs like wrapping for CLI
#    # scripts/xp.py               # <-- Transforms host OS copy-paste buffer player-piano music into context-payload.
#    # scripts/ai.py               # <-- How I constantly use local AI to write git commit messages with `m` alias.
#     
#    # CONTEXT PORTABILITY SYSTEM
#    3 scripts/foo_cartridge.py    # Needs description
#    3 scripts/foo_replay.py       # Needs description
#          
#    # FREQUENTLY USEFUL TO HAVE IN CONTEXT
#    # release.py                  # <-- How everything ends up where it does (GitHub, PyPI, etc.)
#    
#    # scripts/weblogin.py         # <-- Lets the user "warm up" the cache for their web logins at their leisure on a profile that persists.
#    # scripts/crawl.py            # <-- Feel free to ask for something to be crawled and included in the next turn.
#    
#    # imports/voice_synthesis.py  # <-- The wand can talk to you
#    # scripts/release/version_sync.py  # <-- Needs to be wrapped into release.py and eliminated, I think.
#    
#    #                         --- Under this line is were you paste what the AI gives you ---
#    #                         --- We call it context but it's really just the right-hand  ---
#    #                         --- blast-radius of the "probes" to make this all science.  ---
#    
#    # --- END `adhoc.txt` TEMPLATE ---
#    
#    # server.py
#     

flake.nix
.gitattributes              # <-- Model: understand that `nbstripout` and `jupytext` are both in play. Just talk the human through .ipynb patches.
.gitignore                  # <-- Creates "negative space" for sub-rep's to share parent environment and "snap" proprietary secret features into place.
requirements.in             # <-- All known dependencies and (necessary) version pinning. WORA gotcha's exposed.
__init__.py                 # <-- Master versioning
pyproject.toml              # <-- The PyPI Packaging details

# # STICKBUG & MOTHER CAT KATA
# # assets/trails/botify_pageworkers.yaml
# assets/installer/mck.sh
# assets/installer/replay.sh
# assets/trails/first_context.yaml
# assets/trails/practice.yaml
# assets/trails/public_walk.yaml
# scripts/bookmark_import.py
# scripts/boot_menu.py
# scripts/connectors/README.md
# scripts/connectors/botify.py
# scripts/connectors/confluence.py
# scripts/connectors/gmail.py
# scripts/connectors/gsc.py
# scripts/connectors/jira.py
# scripts/connectors/mcp.py
# scripts/connectors/mcp_warm.py
# scripts/connectors/sheets.py
# scripts/connectors/slack.py
# scripts/connectors/wallet.py
# scripts/mother_cat.py
# scripts/sources_menu.py
# scripts/walk.py
# scripts/walk_cartridge.py
# scripts/walk_compile.py
# scripts/weblogin.py
# tools/scraper_tools.py

#    # # adhoc.txt -- Cleanup inert public_walk environment export block
#    # 
#    # # --- BEFORE/AFTER STRADDLE ---
#    # ! grep -n -C 2 'PIPULATE_TRAIL_WALK_ONE_URL' assets/installer/mck.sh || echo "export_block_removed"
#    # ! bash -n assets/installer/mck.sh; echo "mck_syntax=$?"
#    # ! bash assets/installer/mck.sh --where
#    # 
#    # # --- TARGET SCRIPT ---
#    # assets/installer/mck.sh
#    # 
#    # ! rg -n -F 'TRAIL_NAME="${TRAIL_NAME:-public_walk}"' assets/installer/mck.sh
#    # ! rg -n -e '^ *walk\(\)' -e '^ *alias walk=' flake.nix; echo "shell_walk_exit=$?"
#    # ! test -e walk; echo "root_walk_exists=$?"
#    # ! rg -n -e 'alias mothercat=' -e 'Three words to start from' flake.nix scripts/boot_menu.py
#    # flake.nix
#    # scripts/boot_menu.py
#    # assets/installer/mck.sh
#    
#    # ! bash -n walk; echo "walk_syntax=$?"
#    # ! bash walk --where
#    # ! rg -n -e '^ *alias walk=' -e '^ *walk\(\)' -e 'writeShellScriptBin "walk"' flake.nix; echo "shell_walk_exit=$?"
#    # ! .venv/bin/python -c 'import sys; sys.path.insert(0,"scripts"); import boot_menu as b; print("count=" + b._count_word(len(b.DOOR_TWO_WORDS))); print(" ".join(w for w, _ in b.DOOR_TWO_WORDS))'
#    # walk
#    # flake.nix
#    # scripts/boot_menu.py
#    # assets/installer/mck.sh
#    
#    # ! rg -in "three words|four words|door 2" flake.nix scripts/ assets/ README.md
#    # ! walk --where
#    # walk
#    # flake.nix
#    # scripts/boot_menu.py
#    # assets/installer/mck.sh
#    
#    # ! python scripts/connectors/wallet.py check slack
#    # ! python -c "import os, sys; from pathlib import Path; sys.path.insert(0, '.'); from scripts.connectors import wallet; pairs = wallet._dotenv_pairs(); print('env SLACK_USER_TOKEN:', bool(os.getenv('SLACK_USER_TOKEN')), 'env SLACK_BOT_TOKEN:', bool(os.getenv('SLACK_BOT_TOKEN')), 'vault SLACK_USER_TOKEN:', 'SLACK_USER_TOKEN' in pairs, 'vault SLACK_BOT_TOKEN:', 'SLACK_BOT_TOKEN' in pairs)"
#    # ! python scripts/connectors/wallet.py warm slack --dry-run
#    
#    # Now that slack is working (on my workspace) we trim back things here to just the stick bug and Mother Cat Kata stuff

# /home/mike/repos/trimnoir/_posts/2026-08-30-terminal-native-mcp-practice.md
# deleteme.txt

# assets/nbs/Onboarding.ipynb                       # <-- Where Workflows go to get born. Common `.venv` to FastHTML. Also to Cursor or VSCode too if you know how to set it up.
# assets/nbs/imports/core_sauce.py                  # <-- Make those workflows being born in Notebooks look clean by pushing the sausage factory back to code-behind Python secret sauce import modules.
# assets/nbs/imports/onboard_sauce.py               # <-- Now you're cooking!
# apps/015_config.py                                # <-- Carrying onboarding values over to FastHTML-side, especially dotenv values. New home for Botify API key!

scripts/connectors/gsc.py
scripts/connectors/gmail.py
scripts/connectors/sheets.py
scripts/connectors/wallet.py
! python scripts/connectors/gsc.py --check; echo "gsc_check_exit=$?"
! rg -ln "service_account|service-account-key" scripts imports apps 2>/dev/null | sort
! python -c "from pathlib import Path; h = Path.home() / '.config/pipulate'; print('credentials.json:', (h / 'credentials.json').exists(), '| gsc_token.json:', (h / 'gsc_token.json').exists(), '| service-account-key.json:', (h / 'service-account-key.json').exists())"

3: Patches:

(nix) pipulate $ ahe
(nix) pipulate $ patch
(nix) pipulate $ app
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/gsc.py'.
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/gsc.py'.
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/gsc.py'.
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/gsc.py'.
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/gsc.py'.
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/connectors/gsc.py'.
(nix) pipulate $ d
diff --git a/scripts/connectors/gsc.py b/scripts/connectors/gsc.py
index ddbeddd7..9d01f8b5 100644
--- a/scripts/connectors/gsc.py
+++ b/scripts/connectors/gsc.py
@@ -20,10 +20,18 @@ a raw searchanalytics query body (FETCH mode; needs --site or PIPULATE_GSC_SITE)
 any other bare token is a property coordinate (LIST top queries); no argument
 at all lists properties.
 
-Auth (service_account_file — headless by construction, no browser dance ever):
-  PIPULATE_GSC_KEY env var
-    -> ~/.config/pipulate/connectors.json gsc.paths.service_account
-      -> clean failure naming the missing variable.
+Auth (oauth_token_file — the same user-OAuth walk as gmail.py and sheets.py):
+  token:       PIPULATE_GSC_TOKEN env var
+    -> ~/.config/pipulate/connectors.json gsc.paths.token
+      -> ~/.config/pipulate/gsc_token.json
+  credentials: PIPULATE_GSC_CREDENTIALS env var
+    -> ~/.config/pipulate/connectors.json gsc.paths.credentials
+      -> ~/.config/pipulate/credentials.json  (the shared Desktop-app client)
+  A valid token refreshes headlessly, and the token file is REWRITTEN on
+  every refresh so the wallet's offline mtime heuristic tracks "last
+  refreshed". A missing or dead token browser-mints ONLY on a real TTY —
+  `python scripts/connectors/wallet.py login gsc` is the one-time mint,
+  exactly as for gmail and sheets. No service account anywhere.
 
 Output is capped by -n/--max (default 25) per THE PROBE ECONOMY RULE: stdout is
 destined for compiled context payloads, so the bound is a feature.
@@ -40,7 +48,9 @@ import argparse
 from pathlib import Path
 from datetime import date, timedelta
 
-from google.oauth2 import service_account
+from google.oauth2.credentials import Credentials
+from google.auth.transport.requests import Request
+from google_auth_oauthlib.flow import InstalledAppFlow
 from googleapiclient.discovery import build
 from googleapiclient.errors import HttpError
 
@@ -56,46 +66,125 @@ def die(msg, code=1):
     sys.exit(code)
 
 
-def resolve_key_path():
-    """PIPULATE_GSC_KEY env -> wallet gsc.paths.service_account -> None."""
-    env = os.environ.get('PIPULATE_GSC_KEY')
-    if env:
-        return Path(env).expanduser()
+def _wallet_path(key):
+    """gsc.paths.<key> from the wallet, or None. Names and paths only."""
     if WALLET_FILE.exists():
         try:
             wallet = json.loads(WALLET_FILE.read_text(encoding='utf-8'))
-            p = (wallet.get('gsc') or {}).get('paths', {}).get('service_account')
+            p = (wallet.get('gsc') or {}).get('paths', {}).get(key)
             if p:
                 return Path(p).expanduser()
         except (json.JSONDecodeError, OSError):
             pass
-    # Wallet-path default (parity with scripts/gsc/gsc_top_movers.py): a
-    # corrupted or clobbered connectors.json must not strand a key sitting
-    # at the canonical wallet path. get_service()'s exists() check still
-    # fails closed if the file is genuinely absent.
-    return Path.home() / '.config' / 'pipulate' / 'service-account-key.json'
+    return None
+
+
+def resolve_token_path():
+    """PIPULATE_GSC_TOKEN env -> wallet gsc.paths.token -> canonical default."""
+    env = os.environ.get('PIPULATE_GSC_TOKEN')
+    if env:
+        return Path(env).expanduser()
+    return _wallet_path('token') or Path.home() / '.config' / 'pipulate' / 'gsc_token.json'
+
+
+def resolve_credentials_path():
+    """PIPULATE_GSC_CREDENTIALS env -> wallet gsc.paths.credentials -> the
+    shared Desktop-app OAuth client JSON the other Google connectors mint from."""
+    env = os.environ.get('PIPULATE_GSC_CREDENTIALS')
+    if env:
+        return Path(env).expanduser()
+    return _wallet_path('credentials') or Path.home() / '.config' / 'pipulate' / 'credentials.json'
+
+
+def _write_token(token_path, creds):
+    """Rewrite the token file on every mint AND refresh, 0600. The rewrite is
+    what makes the wallet's offline mtime heuristic track 'last refreshed'."""
+    token_path.parent.mkdir(parents=True, exist_ok=True)
+    token_path.write_text(creds.to_json(), encoding='utf-8')
+    os.chmod(token_path, 0o600)
+
+
+def _load_creds():
+    """(creds_or_None, reason). Headless by construction: refreshes when it
+    can, rewrites the token file when it does, and NEVER opens a browser."""
+    token_path = resolve_token_path()
+    if not token_path.exists():
+        return None, f"no OAuth token at {token_path}"
+    try:
+        creds = Credentials.from_authorized_user_file(str(token_path), SCOPES)
+    except (ValueError, OSError) as e:
+        return None, f"token unreadable ({e})"
+    if creds.valid:
+        return creds, 'live'
+    if creds.expired and creds.refresh_token:
+        try:
+            creds.refresh(Request())
+        except Exception as e:
+            return None, f"refresh rejected ({e})"
+        _write_token(token_path, creds)
+        return creds, 'refreshed'
+    return None, 'token expired and holds no refresh_token'
 
 
 def get_service():
-    key_path = resolve_key_path()
-    if not key_path:
-        die(
-            "No GSC key path configured.\n"
-            "Set PIPULATE_GSC_KEY=~/.config/pipulate/service-account-key.json\n"
-            "or add gsc.paths.service_account to ~/.config/pipulate/connectors.json."
-        )
-    if not key_path.exists():
-        die(
-            f"GSC service-account key not found at: {key_path}\n"
-            "Download the JSON key for the service account from Google Cloud Console,\n"
-            "save it at that path, and chmod 600 it. Then add the service account's\n"
-            "email as a user on each Search Console property it should read."
-        )
-    creds = service_account.Credentials.from_service_account_file(
-        str(key_path), scopes=SCOPES)
+    """Refresh headlessly, or browser-mint on a real TTY — the same walk
+    wallet.py's login verb reuses for gmail and sheets. A `!` chisel-strike
+    can never block here: no TTY means a clean die(), never a browser."""
+    creds, reason = _load_creds()
+    if creds is None:
+        creds_path = resolve_credentials_path()
+        if not creds_path.exists():
+            die(
+                f"GSC OAuth needs the Desktop-app client JSON at: {creds_path}\n"
+                "It is the same credentials.json the gmail/sheets connectors mint\n"
+                "from. Download it once from the Google Cloud Console, then run:\n"
+                "    python scripts/connectors/wallet.py login gsc"
+            )
+        if not sys.stdin.isatty():
+            die(
+                f"GSC token not usable ({reason}) and no TTY to browser-mint.\n"
+                "In a real terminal, run:\n"
+                "    python scripts/connectors/wallet.py login gsc"
+            )
+        flow = InstalledAppFlow.from_client_secrets_file(str(creds_path), SCOPES)
+        creds = flow.run_local_server(port=0)
+        _write_token(resolve_token_path(), creds)
     return build('webmasters', 'v3', credentials=creds)
 
 
+def check():
+    """SELECT 1 for the `warm` board: exit 0 GREEN, exit 1 RED (gate-named).
+
+    Modeled on botify.py's two-gate probe. gate1 is "credential present and
+    loadable" — headless refresh allowed, because the durable credential
+    scored is the refresh token, exactly as for every other oauth slot.
+    gate2 is "the live API accepted it": one bounded sites().list() call.
+    15s budget via socket default timeout, stdin-safe: this path NEVER
+    browser-mints, so it can never hang the board on a prompt.
+    """
+    import socket
+    creds, reason = _load_creds()
+    if creds is None:
+        sys.stderr.write(
+            f"gsc RED gate1: {reason} -- run "
+            "`python scripts/connectors/wallet.py login gsc`\n")
+        return 1
+    socket.setdefaulttimeout(15)
+    try:
+        service = build('webmasters', 'v3', credentials=creds)
+        resp = service.sites().list().execute()
+    except HttpError as e:
+        sys.stderr.write(f"gsc RED gate2: API rejected the call: {e}\n")
+        return 1
+    except Exception as e:
+        sys.stderr.write(f"gsc RED gate2: transport failure: {e}\n")
+        return 1
+    entries = resp.get('siteEntry', [])
+    noun = 'property' if len(entries) == 1 else 'properties'
+    print(f"gsc GREEN {len(entries)} {noun} visible ({reason})")
+    return 0
+
+
 # ----------------------------------------------------------------------------
 # Modes
 # ----------------------------------------------------------------------------
@@ -106,8 +195,8 @@ def list_properties(service, max_items):
     print(f"# GSC properties visible to this service account "
           f"({len(entries)} total, showing up to {max_items})\n")
     if not entries:
-        print("(no properties — has the service account's email been added as a "
-              "user in Search Console?)")
+        print("(no properties — does this Google account have access to any "
+              "Search Console properties?)")
         return
     for e in sorted(entries, key=lambda x: x.get('siteUrl', ''))[:max_items]:
         print(f"{e.get('siteUrl', '?')}  [{e.get('permissionLevel', '?')}]")
@@ -183,8 +272,15 @@ def main():
                              '(default: PIPULATE_GSC_SITE env).')
     parser.add_argument('-n', '--max', type=int, default=25,
                         help='Output cap per THE PROBE ECONOMY RULE (default: 25).')
+    parser.add_argument('--check', action='store_true',
+                        help='SELECT 1 health check: one GREEN line on stdout and '
+                             'exit 0, or one gate-named RED line on stderr and '
+                             'exit 1. Never interactive.')
     args = parser.parse_args()
 
+    if args.check:
+        sys.exit(check())
+
     service = get_service()
     try:
         arg = args.query
(nix) pipulate $ m
📝 Committing: chore: Refactor OAuth token and service account management
[main bbab3e6d] chore: Refactor OAuth token and service account management
 1 file changed, 130 insertions(+), 34 deletions(-)
(nix) pipulate $ git push
Enumerating objects: 9, done.
Counting objects: 100% (9/9), done.
Delta compression using up to 48 threads
Compressing objects: 100% (5/5), done.
Writing objects: 100% (5/5), 2.55 KiB | 2.55 MiB/s, done.
Total 5 (delta 4), reused 0 (delta 0), pack-reused 0 (from 0)
remote: Resolving deltas: 100% (4/4), completed with 4 local objects.
To github.com:pipulate/pipulate.git
   8ee9106d..bbab3e6d  main -> main
(nix) pipulate $

And the commands:

(nix) pipulate $ g

Blast Radius Check to establish bisection Left-hand Causal Boundary. It is a Popper-thing. Science.
On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean
(nix) pipulate $ python - <<'EOF'
import json, shutil
from pathlib import Path
home = Path.home()
p = home / '.config' / 'pipulate' / 'connectors.json'
bak = p.with_name('connectors.json.gsc-oauth.bak')
shutil.copy2(p, bak)
w = json.loads(p.read_text(encoding='utf-8'))
slot = w.setdefault('gsc', {})
slot['auth'] = 'oauth_token_file'
paths = slot.get('paths') or {}
paths.pop('service_account', None)
paths['credentials'] = str(home / '.config' / 'pipulate' / 'credentials.json')
paths['token'] = str(home / '.config' / 'pipulate' / 'gsc_token.json')
slot['paths'] = paths
env = slot.get('env') or {}
env.pop('PIPULATE_GSC_KEY', None)
env['PIPULATE_GSC_CREDENTIALS'] = 'optional; overrides paths.credentials (shared Desktop-app OAuth client JSON)'
env['PIPULATE_GSC_TOKEN'] = 'optional; overrides paths.token (minted GSC OAuth token file)'
slot['env'] = env
p.write_text(json.dumps(w, indent=2) + '\n', encoding='utf-8')
print('backup:', bak)
print('gsc auth:', slot['auth'])
print('gsc paths:', json.dumps(slot['paths']))
print('gsc env keys:', ', '.join(slot['env']))
EOF
backup: /home/mike/.config/pipulate/connectors.json.gsc-oauth.bak
gsc auth: oauth_token_file
gsc paths: {"credentials": "/home/mike/.config/pipulate/credentials.json", "token": "<redacted:42>"}
gsc env keys: PIPULATE_GSC_CREDENTIALS, PIPULATE_GSC_TOKEN
(nix) pipulate $ 

Car 3:

(nix) pipulate $ python scripts/connectors/wallet.py login gsc
# wallet login gsc — reusing gsc.py's own OAuth walk (this slot only)
# credentials : /home/mike/.config/pipulate/credentials.json
# token       : /home/mike/.config/pipulate/gsc_token.json  [empty before]

Please visit this URL to authorize this application: https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=<redacted:72>&redirect_uri=http%3A%2F%2Flocalhost%3A49307%2F&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fwebmasters.readonly&state=<redacted:30>&code_challenge=<redacted:43>&code_challenge_method=S256&access_type=offline
Opening in existing browser session.

# minted — this slot now reads:
  [x] filled  gsc  2026-08-31 (0d ago)  /home/mike/.config/pipulate/gsc_token.json
# Done. Re-run the bare scoreboard for the whole board.
(nix) pipulate $ 

And Car 4:

(nix) pipulate $ python scripts/connectors/wallet.py check gsc
# wallet check — LIVE credential board (one bounded call per slot)
# wallet: /home/mike/.config/pipulate/connectors.json
# green means the service accepted this credential just now, not merely that a token exists

  🔴 gsc   oauth     gsc RED gate2: API rejected the call: <HttpError 403 when requesting https://www.googleapis.com/webmasters/v3/sites?alt=json returned "Google Search Console API has not been used in project 312063524029 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/searchconsole.googleapis.com/overview?project=312063524029 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.". Details: "[{'message': 'Google Search Console API has not been used in project 312063524029 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/searchconsole.googleapis.com/overview?project=312063524029 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.', 'domain': 'usageLimits', 'reason': 'accessNotConfigured', 'extendedHelp': 'https://console.developers.google.com'}]">

# 0 green | 1 red | 0 unchecked
# Fix a red:  python scripts/connectors/wallet.py warm <slot>
(nix) pipulate $ 

4: Prompt: Read the LIVE COMMAND RECEIPTS first; name the lane for each and rule one line each:

  1. The gsc –check echo: exit 0 with gsc GREEN N properties visible (live|refreshed), or the gate line verbatim. RED gate1 means Car 3’s mint has not run – say so and stop on that slot; do not patch around a missing mint. BEFORE was argparse exit 2.
  2. The rg inventory: scripts/connectors/gsc.py must be GONE from the list. Whatever remains (e.g. scripts/gsc/gsc_top_movers.py) is the surviving service-account estate: name each file and rule port-to-OAuth or retire, patching nothing outside context.
  3. The path probe: gsc_token.json exists=True after the mint; service-account-key.json still existing is fine this turn – deletion waits until the estate in ruling 2 is empty.

With gmail.py and sheets.py now in context, reconcile gsc.py’s OAuth walk against the house siblings: name every drift (helper names, refresh-rewrite behavior, TTY gate, die() wording, –check gates) and patch ONLY behavioral differences, never cosmetic ones. wallet.py is touchable again: one prose block may re-home its docstring’s (gsc) parenthetical off service_account_file, and rule kill-or-keep on the now-orphaned svc-acct kind (_warm_service, the classify arm) – if kill, that is its own car with a straddle. The render-server session question still waits on its first real flight’s FDR receipts.

5: Deliverables: None

We don’t need to reviews the gsc.py in this article. Let’s try to wrap this up. If there’s still stuff to do enough to push back, continue the rid. But if not, hop off the ride. This ride’s stated goal is reached — dismount. This is the NOTARY BEAT: the ride ends here, is witnessed here, and is sealed here. Answer all seven beats, briefly:

  1. TLDR: a short, dry, neutral abstract for the TOP of the published article — written for an unfamiliar reader or AI summarizer who has never seen this system. No hype, no insider handles unexplained.
  2. VERIFY: restate the goal from the top of this article and confirm (or deny) it was met, citing THIS compile’s receipts, not memory. Name any ignition this ride required that never fired – an AFTER tap taken without one is a stale BEFORE wearing the AFTER’s label.
  3. BANK: name everything that graduates — rule, earmark, todo, pin — as exact paste-ready lines, plus the exact lines to delete.
  4. DANGLING: what carries forward unbanked? One line each, no essays.
  5. SEED: the adhoc.txt lines (and TODO_SLUGS if narrative context is needed) for the next ride’s first compile.
  6. CLOSING: a closing summary for the BOTTOM of the article — the final take-away, tied to the book’s larger arc where it fits naturally, never forced. Storytelling over inventory.
  7. NOTARIZE: name the sealed artifact of record — the newest hash-stamped cartridge (foo--NN.zip) — and state that its CRC-sealed, byte-reproducible archive is the witnessed receipt of this ride. The seal is the signature; the archive is the deed.

FINALITY: after beat 6, this discussion is CLOSED. Emit NO five-car train, NO probes, NO patches, NO next-turn prompt beyond the SEED lines in beat 4. Any reader or model encountering this article later should treat it as a finished, notarized document — an archive entry, not an open thread.

Opus 5 Extra:

NOTARY BEAT — Ride Sealed


1. VERIFY

Goal, restated from the top of this article: “I need to create total global clarity across the system regarding credentials” — one out-of-git vault, one unified path from that file into the terminal environment, and a system that could be handed to anyone.

Verdict: met at the architecture and connector layer; one credential is code-correct but not yet live.

Three live receipts this compile, lane named for each:

Receipt 1 — gsc.py --check (live-network lane). gsc_check_exit=1, stderr gsc RED gate2: API rejected the call: HttpError 403 ... Google Search Console API has not been used in project 312063524029. The BEFORE was error: unrecognized arguments: --check / exit 2. The straddle is clean: the flag now exists, gate1 passed (the OAuth credential loaded and was accepted by the client), and the failure is gate2 on accessNotConfigured. Code correct, credential minted, external toggle unfired.

Receipt 2 — rg -ln "service_account" (static-text lane, no network). scripts/connectors/gsc.py is gone from the list. That is the whole rewrite proven by absence. Seven files remain; four are real consumers, three are prose or dispatch about the kind itself.

Receipt 3 — path existence (offline stat lane). gsc_token.json: True — was False in the hand-run BEFORE. The mint landed. service-account-key.json: True is correct for this turn; deletion is gated on the estate being empty.

Supporting: Ruff clean. Git diff telemetry shows the OAuth rewrite as the most recent commit.

Ignition: none required, and none skipped. Every car loaded its patched file at call time — warm resolves wallet.py per invocation, and gsc.py was invoked directly. The one actuator that never fired is external, not a shell ignition: enabling the Search Console API in Cloud project 312063524029. It was named as a conditional external deliverable last turn and remains unfired. Receipt 1 is therefore a true AFTER, not a stale BEFORE — it is honestly reporting an unfinished step outside this repo.

Compile hygiene: the render canary arrived linkified ([www.canary.invalid](https://www.canary.invalid)), so the transport rewrote this payload and every bare www-prefixed token in it is suspect. No patches ride this turn, so there is no exposure.


2. BANK

Graduating to the rules file:

THE THREE-TIER CREDENTIAL RULE. Primary secrets (API keys, bearer <redacted:6> you cannot regenerate without a vendor console) live in ~/.config/pipulate/.env, flat, 0600, out of git, sourced natively by the shell hook. Schema (auth kinds, required var NAMES, paths, non-secret defaults) lives in connectors.json. Derivatives (OAuth tokens, browser profiles, MCP token files) are re-mintable and are NOT backup targets. Back up tier one and tier two; re-warm tier three.

THE FLAT-VAULT RULE. The vault stays a flat .env and never becomes structured JSON. The OS environment is a flat string map, every library reads os.getenv by exact NAME, and `set -a; source; set +a` costs nothing at shell entry while a JSON hydrator costs a parse on every subshell. connectors.json already carries the structure; the vault carries only values.

PROMOTION IS DATA, NOT CODE. An ad hoc MCP server tried once stays off the board and rides the universal client. A server promoted to daily use mints a connectors.json SLOT, never a new WET connector file -- the universal client already speaks the protocol and already derives the credential. A WET file is justified only when a server needs a DRIVER (held session, poll loop), never to mirror a vendor's tool list.

THE BOARD SCORES CREDENTIALS, NOT ENDPOINTS. Two endpoints sharing one credential get one slot. A second slot would double-count a single secret and make GOLD arithmetic lie.

NO SERVICE ACCOUNTS IN THE CONNECTOR LANE. Every Google connector authenticates as the human who runs it, via the shared Desktop-app OAuth client. Visibility then means "what this person can see," which is what the operator meant when they typed the command. The 7-day Testing-mode refresh cliff is handled by the wallet's stale heuristic and `login <slot>` -- one class of failure, one fix, instead of an eleventh thing to babysit.

THE ENV-LANE TOKEN IS SHELL-GLOBAL. MCP_BEARER_TOKEN and friends outrank the derived file for EVERY server in the terminal. Env-lane tokens are for one-off experiments against a single server; anything you will hit twice gets a warmed file.

THE OFFLINE/LIVE SPLIT IS DOCUMENTED, NOT A BUG. The offline scoreboard never opens a token's bytes, so a credential reachable only through a scoped legacy fallback reads `empty` there while `check` reads GREEN. When the two lanes disagree, the LIVE one is truthful. Warming the slot ends the split by writing the derived path.

Exact lines to delete (deletions, not edits — each is a false statement now standing in the tree):

DELETE: scripts/connectors/gsc.py, in list_properties() --
    print(f"# GSC properties visible to this service account "
  The auth layer no longer uses a service account. This stdout line is the last
  survivor of the old identity and now misdescribes every run.

DELETE (or re-home): scripts/connectors/wallet.py docstring --
    service_account_file  os.stat() the key file → present/non-empty (gsc).
  No slot declares service_account_file after this ride. The "(gsc)" attribution
  is false; the KIND's fate is the kill-or-keep decision in DANGLING.

3. DANGLING

  • Search Console API toggle in Cloud project 312063524029 — unfired; gsc stays RED gate2 until it is.
  • Service-account estate, four real consumers: scripts/gsc/gsc_top_movers.py, scripts/gsc/gsc_keyworder.py, scripts/gsc/gsc_page_query.ipynb, scripts/articles/gsc_historical_fetch.py — each needs a port-to-OAuth or retire ruling.
  • service-account-key.json on disk — deletion gated on the estate above being empty.
  • _warm_service and the svc-acct classify arm in wallet.py — dormant, kill-or-keep undecided.
  • gsc.py vs gmail.py/sheets.py drift — siblings were in context this compile; reconciliation deferred by operator instruction.
  • Render-server session question — whether one Mcp-Session-Id must span a submit→poll→collect job, or fresh initialize-per-poll works with the job id as state carrier. Waits on first real flight’s FDR receipts.
  • A seventh mcp_token kind — would be needed only by a promoted static-token MCP server whose token is not already scored. Named, not built.
  • Single-use refresh-token rotation at the Botify MCP vendor — still unobserved; three chained refreshes returned no rotated token.
  • Starter wallet / wallet init — a stranger still gets the cold-start card and must author connectors.json by hand. This is the largest remaining gap between the current state and “hand it to anyone.”
  • gong — benched ("enrolled": false); needs gong.py plus GONG_ACCESS_KEY_SECRET.
  • botify_browser (visited, not logged into) and sheets (transient read timeout) — game-loop reds, fixed by warming, not by patching.
  • TTS narration for the interactive warm loop — the voice engine and the per-slot prompt loop both exist; nothing wires them together.

4. SEED

scripts/connectors/gsc.py
scripts/connectors/gmail.py
scripts/connectors/sheets.py
scripts/connectors/wallet.py
scripts/gsc/gsc_top_movers.py
scripts/articles/gsc_historical_fetch.py
! python scripts/connectors/gsc.py --check; echo "gsc_check_exit=$?"
! rg -ln "service_account|service-account-key" scripts imports apps 2>/dev/null | sort
! python scripts/connectors/wallet.py

The first echo is the outstanding AFTER: if the console toggle has fired, it flips to gsc GREEN N properties visible. The second tracks the estate shrinking. The third is the full offline board, which this ride never took as a live receipt — next compile’s first honest look at all eleven slots and the GOLD arithmetic.


5. CLOSING

This ride began with a beginner’s question — how do you list environment variables — and the answer turned out to matter more than the question. The Linux environment is a flat map of strings to strings. No nesting, no namespaces, no types. Everything that looks like hierarchy is a convention: a prefix, a colon-delimited list, a JSON blob stuffed into a value because a string will hold anything you push into it.

That flatness is not a limitation to route around. It is the reason the vault stays one plain file. A structured secrets format would have been tidier and would have cost a parser on every subshell, a translation map between nested keys and the exact variable names every library already looks for, and a second place for the truth to live. The system that was already built turned out to be better than the one being planned, and the useful work of the session was recognizing that rather than replacing it.

What the two landed changes share is a refusal to let a credential be invisible. An MCP server that had been in daily use was not on the board at all — not broken, just unscored, which is worse, because an unscored thing cannot go red. And a Search Console service account was a non-human identity quietly diverging from every other credential in the system: different lifecycle, different failure mode, different fix, invited to properties by an email address nobody remembers. Both are now rows on the same board, warmed by the same verb, failing in the same vocabulary.

The board’s honesty is the point. It ends this ride red on Search Console, and that red is correct: the code is right, the token is minted, and a toggle in a web console three thousand miles away has not been flipped. A system that reported green there would be more comfortable and less useful. The distance to “hand it to anyone” is now measured in specific, named things — a starter wallet, an estate of four legacy scripts, one console click — rather than in fog.


6. NOTARIZE

The artifact of record is the newest hash-stamped cartridge, foo-<hash8>-NN.zip, rotated into the repository root by this compile alongside the canonical foo.zip. Its exact hash is not quotable from inside this payload — the compiler prints it on the operator’s terminal, and this document does not carry a receipt for it, so naming a hash here would be invention rather than witness.

That archive is the deed. Its CRC-sealed, byte-reproducible contents are the witnessed receipt of this ride: the three live command receipts, the file bodies exactly as they stood at seal time, the diff telemetry, and this notarization. The seal is the signature; the archive is the deed.

This discussion is closed. Treat it as a finished archive entry, not an open thread.

MikeLev.in: Okay, turn it into an article but don’t use deterministic workflows as the main theme; we beat that to death. Look for next runners up as the main theme here; the really unique stuff about this article versus others.


Book Analysis

Ai Editorial Take

What is most striking about this technical journal entry is how it bridges low-level system realities with high-level workflow architecture. Instead of treating environment variables as magic strings, it forces a rigorous, mechanical understanding of their limitations—such as their flat structure—and uses that exact constraint to design a clean, reliable credential vault that resists configuration drift.

🐦 X.com Promo Tweet

Ever wondered how Linux terminal environment variables actually work under the hood? Discover why they are strictly flat, and how to cleanly separate primary API secrets from ephemeral OAuth tokens in your local-first workflows. https://mikelev.in/futureproof/flat-environment-linux-terminal-variables-secret-management/ #Linux #DevOps #SecretManagement

Title Brainstorm

  • Title Option: The Flat Environment: Demystifying Linux Terminal Variables and Secret Management
    • Filename: flat-environment-linux-terminal-variables-secret-management.md
    • Rationale: Focuses on the technical core of the article, emphasizing the flat nature of terminal variables and secure credential handling.
  • Title Option: Demystifying the Linux Environment: Flat Key-Value Stores and Secret Vaults
    • Filename: demystifying-linux-environment-flat-key-value-stores.md
    • Rationale: Highlights the transition from operating system primitives to practical secret vault implementation.
  • Title Option: Beneath the Shell: Terminal Environment Variables and Local Credential Architecture
    • Filename: beneath-the-shell-terminal-environment-variables.md
    • Rationale: Appeals to readers interested in deep-dives into system administration and environment configuration.

Content Potential And Polish

  • Core Strengths:
    • Clear, conversational deconstruction of a common technical misconception regarding Linux environment variables.
    • Practical architectural distinction between primary root secrets and ephemeral derivative tokens.
    • Grounded integration of real-world tooling like wallet management and shell hydration.
  • Suggestions For Polish:
    • Expand slightly on the security implications of file permissions (such as 0600) for .env vaults.
    • Clarify the transition points between universal clients and WET connectors for newcomers.

Next Step Prompts

  • Examine how automated testing frameworks can validate flat environment variable ingestion across different operating systems.
  • Draft a follow-up guide detailing the exact steps for bootstrapping the local wallet and vault system on a fresh machine.