Guides
Operate: doctor, heal, upgrade#
Installing is one command. Keeping an install correct across host updates, package releases and edited config is the day-two job, and doctor is its single entry point: it tells you whether the install is complete and whether it is current, and every finding names the command that clears it.
1. The day-two loop#
Four verbs cover the whole lifecycle. Each one is idempotent, prints a per-host diff, and works the same under a branded bin (acme-db doctor) or the framework CLI (agent-connector doctor).
| Verb | Question it answers | Exit code |
|---|---|---|
install | Render the connector into every detected host; register it; write the home binary. | non-zero on a write failure |
doctor | Is everything we wrote still there, intact, and rendered by the version now running? | non-zero only on [FAIL]; warns alone exit 0 |
doctor --heal / upgrade | Re-render what drifted. upgrade also refreshes the home binary and prints the npm update line; neither ever self-updates the package. | non-zero if a fixable finding still fails afterwards |
uninstall | Remove every entry, block and file we own; leave user edits alone. | non-zero on a removal failure |
2. Read doctor output#
The first group is always agent-connector: — the framework's own checks. Host groups follow, one per detected host, each check as [pass], [warn] or [FAIL] with a fix: line when there is something to run. --json emits the same groups as an array for scripts.
$ acme-db doctor
agent-connector:
[pass] agent-connector: home-bin — execs /…/node_modules/@ken-jo/agent-connector/dist/cli.js
[pass] agent-connector: home-bin version — 0.6.5
[pass] acme-db: framework version — 0.6.5
[pass] acme-db: connector version — 1.0.0
claude-code:
[pass] Claude Code: config present — /Users/me/.claude.json
[pass] Claude Code: settings.json present — /Users/me/.claude/settings.json
[pass] Claude Code: hook command registered — hook command present
[pass] Claude Code: statusline wired — statusLine command present
doctor: all checks passed.What each framework line proves
home-bin: the stable launcher every host hook and action execs exists and points at a CLI file that exists. A launcher pointing at a removed install is a [FAIL] — hooks, statusline and actions would silently stop on every host. home-bin version: that CLI is the same agent-connector release as the one running doctor. framework version: this connector's host config was rendered by the running release. connector version: the registered connector version equals what the source file declares now.3. Three versions doctor compares#
| Version | Recorded where | Drifts when |
|---|---|---|
| Connector version | connector.json under the framework state dir, at install | you bump version in defineConnector() / package.json and have not re-rendered |
| Framework version | connector.json → frameworkVersion (stamped since 0.6.5) | a newer @ken-jo/agent-connector is installed but hosts still carry the older rendering |
| Home-bin target | the launcher script itself (it execs an absolute dist/cli.js) | the package moved (global reinstall, different Node, removed node_modules) |
$ acme-db doctor
agent-connector:
[pass] agent-connector: home-bin — execs /…/node_modules/@ken-jo/agent-connector/dist/cli.js
[warn] agent-connector: home-bin version — launcher runs agent-connector 0.6.4, this CLI is 0.6.5
fix: run `upgrade` (or `doctor --heal`) to re-render and re-point the home binary
[warn] acme-db: framework version — rendered by agent-connector 0.6.4, running 0.6.5
fix: run `upgrade` (or `doctor --heal`) to re-render acme-db
[warn] acme-db: connector version — registered 0.9.0, source declares 1.0.0
fix: run `upgrade` (or `doctor --heal`) to re-render acme-db
…
doctor: all checks passed. # warns never fail doctor; only [FAIL] does
$ acme-db upgrade
…
Refreshed home binary pointer: /Users/me/.agent-connector/bin/agent-connectorEvery version finding is a [warn] except a launcher whose target file is gone, which is a [FAIL]. All of them are fixable: one upgrade re-renders every host, re-registers the connector with the running versions and re-points the launcher. Records written by releases before 0.6.5 carry no framework version; doctor says so and the same upgrade stamps it.
4. doctor --heal versus upgrade#
doctor --heal is a targeted sync: it re-renders only the connectors that have fixable findings and reports each finding as healed, still failing or deferred. Deferred means agent-connector will not overwrite it — a memory block or config value the user edited by hand stays as the user left it. upgrade is the broad form: re-render everything, refresh the launcher, and print the exact npm i -g line when the install looks npm-managed. Use --dry-run on either to see the plan first.
$ acme-db doctor --heal --dry-run
would heal via sync (acme-db): acme-db: connector version
$ acme-db doctor --heal
healed (1):
[pass] acme-db: connector version
doctor --heal: all fixable findings resolved.5. Prove the live server and the hook matrix#
Placement checks read files. --probe spawns the connector's real stdio server and runs the MCP handshake the hosts will run — initialize, ping, tools/list — so a broken build or a missing runtime dependency shows up here, not in a user's first chat turn. --explain is offline: for every declared hook event it prints whether each targeted host honors the reply, degrades it, or never fires it, so you know which hosts your hook logic can rely on before shipping.
$ acme-db doctor --probe
…
probe acme-db:
[pass] acme-db: MCP initialize — serverInfo [email protected], protocol 2025-11-25
[pass] acme-db: capabilities — tools
[pass] acme-db: ping — alive
[pass] acme-db: tools/list — 2 tool(s)
$ acme-db doctor --explain
acme-db — per-event hook honor:
[honored] claude-code / PreToolUse — claude-code asks on PreToolUse
[degraded] amp / SessionStart — amp drops context on SessionStart (no stdout path)
[dropped] antigravity-cli / SessionStart — antigravity-cli has no SessionStart equivalent — install skip-warns it; never fires6. Secrets: the OS keystore#
A stdio server that needs an API key declares it as env: { API_KEY: "${secret:api-key}" } and the user stores the value once with secrets set api-key: macOS Keychain, Linux Secret Service or Windows Credential Manager, plus an opt-in plaintext file backend for headless machines. Host configs only ever carry the serve wrapper's --secret-env API_KEY={secret:api-key} placeholder; the wrapper reads the keystore at launch, injects the value into the server process, and refuses to start the server while the secret is missing. install warns per unset name and doctor reports them under the connector's secrets check, so the day-two loop covers them too.
$ acme-db secrets set api-key # the value comes from a hidden prompt or --stdin, never a flag
Enter value for api-key (input hidden):
$ acme-db secrets list # name backend present updated — values are never printed
$ acme-db secrets check # backend availability + a write → read → delete self-test
$ acme-db doctor # framework check "acme-db: secrets" → 1 secret(s) present in keychain
# The only trace in a host config is the serve wrapper's placeholder:
# --secret-env API_KEY={secret:api-key}7. Logins: OAuth providers#
A server that talks to an OAuth 2.0 API declares the provider under oauth.<key> — a preset (google, microsoft, github, bing-webmaster, posthog or generic), the app's own client id and the scopes — and the user authorizes once with auth login <key>: a browser loopback flow, or a device-code prompt where no browser can be opened. The refresh token goes into the same OS keystore as secrets set; the server mints access tokens with getAccessToken from the SDK, which refreshes and caches them in process memory and, when nothing is stored yet, opens the browser itself when it can — so a connector installed as a host plugin works without install. Nothing but auth token ever prints a token, and agent-connector ships no client ids: the author registers the app with each provider. install warns per missing login and doctor reports them under the connector's logins check.
// agent-connector.config.ts — the author registered the app with Google and ships its client id
oauth: {
google: {
provider: "google",
clientId: "1234-abcd.apps.googleusercontent.com",
clientSecret: "${secret:google-client-secret}", // a reference; google alone also accepts a literal
scopes: ["https://www.googleapis.com/auth/webmasters.readonly"],
},
},
$ seo-mcp secrets set google-client-secret # once, from a hidden prompt
$ seo-mcp auth login google # Opening Google authorization in your browser…
# refresh token → OS keystore as oauth.google.refresh-token
$ seo-mcp auth status # key provider present obtained via
$ seo-mcp doctor # framework check "seo-mcp: logins" → 1 login(s) present
# In the server: mint tokens on demand — with no stored login it opens the browser itself
# const { accessToken } = await getAccessToken({ connectorId: "seo-mcp", key: "google" });8. Register the app with each provider#
Every login needs an app registered at the provider — agent-connector never ships a client id — and the connector config says who supplies it. Developer-provided: a literal clientId with no secret (a public client), a literal clientSecret only for google (Google documents an installed app's client secret as not confidential), or tokenExchangeUrl — the developer's own https token exchange service that holds the secret and forwards every token request to the provider. User-registered: clientId: "${secret:NAME}" plus clientSecret: "${secret:NAME}", so each user registers their own app and stores both with secrets set; install, doctor and auth login name the secrets set commands still to run. A ${env:VAR} client id is expanded from the process environment at login and refresh time, which a host-spawned server does not see. The runnable examples/seo-connector shows all three ways and ships a token exchange service sample.
| Mode | Config | What the user does | Fits |
|---|---|---|---|
| Developer-provided | clientId: "<literal>"; no clientSecret — or, for google only, clientSecret: "<literal>" | Nothing to register or store: auth login <key>. | A public client: microsoft, posthog (the metadata document URL), github's device flow, a public generic client; google with its literal secret. |
| Developer-hosted token exchange | clientId: "<literal>"; tokenExchangeUrl: "https://…" (no clientSecret in the config) | Nothing to register or store: auth login <key>; every token request goes through the developer's service, which holds the secret. | A provider that demands a client secret, without per-user registration: github's browser flow, bing-webmaster, a confidential generic client. |
| User-registered | clientId: "${secret:NAME}"; clientSecret: "${secret:NAME}" | Registers their own app at the provider, stores both values with secrets set <NAME>, then auth login <key>; install and doctor name the secrets set commands still to run. | Any provider; needed where a client secret is demanded and the developer runs no service: bing-webmaster, github's browser flow, a confidential generic client. |
One row per preset: where the registration lives, which application type to pick, the redirect URI to enter (the engine listens on 127.0.0.1), what the provider hands back and who supplies it, and what makes a refresh token appear.
| Preset | Register | Redirect URI | Credentials | Refresh token / notes |
|---|---|---|---|---|
googleGoogle (Search Console, Analytics, Drive, …) | Open the registration page Google Cloud console → APIs & Services: enable the API you call (e.g. Search Console API), configure the OAuth consent screen with the scopes and, while it is in Testing, your test users, then Credentials → Create credentials → OAuth client ID → application type Desktop app. | Nothing to register: a Desktop app client accepts any http://127.0.0.1:<port> loopback redirect. | Client id + client secret. Google documents a desktop client's secret as not confidential, so the connector accepts it as a literal clientSecret (a ${secret:…} reference works too). Developer-provided: literal clientId and literal clientSecret (Google documents the secret as not confidential) | The preset sends access_type=offline and prompt=consent, which is what yields a refresh token. A consent screen in Testing status expires refresh tokens after 7 days — publish the app for longer-lived logins. Google's device grant allows only sign-in, drive.file / drive.appdata and YouTube scopes, so the preset never uses it for API scopes. |
microsoftMicrosoft Entra ID (Microsoft Graph, Azure, Microsoft 365) | Open the registration page Microsoft Entra admin center → App registrations → New registration; choose the supported account types (options.tenant selects the authority: common, organizations, consumers or a tenant id). Add the platform Mobile and desktop applications. | http://127.0.0.1/callback — Entra ignores the port on loopback redirect URIs and prefers 127.0.0.1 over localhost. The admin center's redirect URI box does not take http://127.0.0.1; add it in the app manifest (replyUrlsWithType, type InstalledClient). | Application (client) id. A public client needs no secret; add one only for a confidential deployment (Certificates & secrets) and reference it as ${secret:…}. Developer-provided: literal clientId, no secret (public client) | Request the offline_access scope or no refresh token is returned. For the device grant set Allow public client flows to Yes under Authentication. |
githubGitHub (REST / GraphQL API) | Open the registration page Settings → Developer settings → GitHub Apps → New GitHub App (a classic OAuth App works for the browser flow but never returns a refresh token). Fill in the callback URL; tick Enable Device Flow if you want auth login --device. | http://127.0.0.1/callback — GitHub does not require the port of a loopback redirect_uri to match the registered callback URL. | Client id + client secret (Generate a new client secret). The secret is required for the browser flow's code exchange and not for device-flow polling. Browser flow: user-registered ${secret:…} or tokenExchangeUrl; device flow: literal clientId only | Turn on Expire user authorization tokens (GitHub App → General → Optional features) or request the offline_access scope; without either GitHub returns no refresh token and auth login stops with the preset's hint. |
bing-webmasterBing Webmaster Tools | Open the registration page Bing Webmaster Tools → Settings (top right) → API Access → accept the terms → OAuth Client → register with a Client Name and the Redirect URI. | http://127.0.0.1:<port>/callback.html with the exact port the connector pins in redirectPort and the path it sets in redirectPath (Bing matches the redirect URI exactly, port included, and its registration form rejects a redirect URI with no letters after a dot — 127.0.0.1 and localhost both fail its check — so the path carries the dot; redirectPort is required for this preset). | Client id + client secret (copy both from the created OAuth client; each user stores them with secrets set, or the developer's token exchange service holds the secret). User-registered (${secret:…} id and secret) or tokenExchangeUrl | Scopes webmaster.read or webmaster.manage; a refresh token comes with every code exchange. Bing's documented response carries no state, so the login is bound by the exact redirect URI alone. An API key from the same page is the alternative when a login is not wanted (use the secrets feature for it). |
posthogPostHog (Cloud US / EU) | Open the registration page No registration at PostHog: host a Client ID Metadata Document (JSON with client_id = the document's own https URL, client_name, redirect_uris, optional logo_uri) and use that URL as clientId. | List http://127.0.0.1/callback in redirect_uris — a loopback URI registered without a port matches any port, so the connector needs no redirectPort. | None: the metadata document is the identity (PKCE, token endpoint auth none, no client secret). Developer-provided: the metadata document URL, no secret | Scopes are <resource>:<read|write> pairs (e.g. project:read, query:read). options.region pins us or eu; omitted, the region-agnostic issuer routes by account. A refresh token comes with every code exchange. |
genericAny OAuth 2.0 provider | Open the registration page Register a native / desktop (public) client in the provider's developer console; note the issuer (for RFC 8414 / OpenID Connect discovery) or the authorization and token endpoint URLs. | http://127.0.0.1/callback if the provider follows RFC 8252 (any port), else http://127.0.0.1:<port>/callback with a fixed redirectPort. | Client id; a client secret only if the provider insists (reference it as ${secret:…}, or point tokenExchangeUrl at a service that holds it; a reference is sent in the form body by default, tokenEndpointAuth: "client_secret_basic" switches to the Authorization header). As the provider demands: public client, user-registered, or tokenExchangeUrl | Find what yields a refresh token (often the offline_access scope or an access_type parameter) and add it to scopes or extraAuthorizationParams; the device grant is used when the provider publishes a device_authorization_endpoint. |
Token exchange service — the wire contract. The connector sends POST <tokenExchangeUrl> with content-type: application/x-www-form-urlencoded and accept: application/json (plus the preset's token request headers) and one of these bodies, never a client_secret. The service accepts only its own client_id and only these three grant types, refuses a repeated parameter, adds the secret the way the provider wants it (client_secret form field or HTTP Basic), forwards only that grant's own parameters to the provider's token endpoint and returns the provider's status and body unchanged, logging neither. Neither side follows a redirect on a token request.
grant_type=authorization_code&code=…&redirect_uri=…&code_verifier=…&client_id=…
grant_type=refresh_token&refresh_token=…&client_id=…
grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=…&client_id=…The service sees the authorization code, the refresh token and every access token in transit — it is the developer's own service and carries the same trust as the connector's server code. PKCE stays on the client, so a code intercepted elsewhere is useless without the verifier, and the service must not be a public relay: one provider, one client id. The device authorization request and revocation on auth logout still go to the provider as a public client.
9. Reverse it cleanly#
uninstall is the exact inverse of install: every host entry, block and file agent-connector wrote is removed and files it does not own are left untouched. The connector's registry record stays, so a later install is a no-op diff; uninstall --purge removes that record too and, once no connector remains, the home binary. Run doctor afterwards — host groups that no longer mention the connector are the proof.
$ acme-db uninstall
✓ Removed acme-db from 1 host · 4 files cleaned.
Verify it's gone: agent-connector doctor
$ acme-db doctor # host groups no longer list acme-db; the framework rows
# still show its registry record until --purge
$ acme-db uninstall --purge # also drops the record and, when no connector remains, the home binaryNext: ship a UCP commerce MCP with the same loop, or go back to adding connector surfaces.