Add an AI provider to ClaudeBar as a JSON definition run by the one generic Provider and DataSource. Use this skill when: (1) Adding a new AI assistant provider (like Antigravity, Cursor, etc.) (2) A provider needs a setting, an added account, or an old saved key kept working (3) A provider's CLI, API or file format changed and its definition must follow (4) User asks "how do I add a new provider" or "create a provider for X"
71
89%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
A provider is data: one file, Modules/Providers/Resources/Providers/<id>.json.
The app finds it — ProviderCatalog.detect() reads every definition in the
bundle (and in ~/.claudebar/providers, ~/.claudebar/extensions) and makes
each on one shared Engine (TARGET_ARCHITECTURE §10).
There is no registration step and no Swift line that names it.
It is an abstraction layer that answers three questions, each once: who the
provider is (profile), how an account is signed in (settings, and
accounts when the vendor keeps a login in a folder), and how to get its
usage (dataSources: where the key is, how to fetch, how to read the
answer). One
Provider class and one DataSource type run every definition. You write
no Swift for a vendor: no XxxProvider, no XxxUsageProbe, no
XxxCredentialLoader.
Read first: TARGET_ARCHITECTURE.md (how a definition runs), MODULAR_DESIGN.md (which module a file goes in), and CANONICAL_MODEL.md (the words). Every built-in provider is a definition, so there is a worked example for most shapes: an API key with a region (
minimax.json), a CLI's login file renewed by OAuth (grok.json) or by the CLI itself (gemini.json), a TUI (kimi.json), a browser session over several requests (alibaba.json), an app's local server (antigravity.json), cloud metrics priced into money (bedrock.json). Their tests are inModules/Providers/Tests/.
The docs are the source of truth (AGENTS.md). A provider that fits the definition language needs no design change. One that needs the language to grow — a new case and worker, a new capability (CANONICAL §2.1), a new setting kind — is a design change: write it into TARGET_ARCHITECTURE / CANONICAL_MODEL first and ask the user to confirm before writing code.
| Piece | Where | You touch it when |
|---|---|---|
<id>.json | Modules/Providers/Resources/Providers/ | always |
<id>-*.js mapping script | beside the JSON | only when no mapping rule can read the format (a TUI screen) |
| golden tests | Modules/Providers/Tests/<Name>DefinitionTests.swift | always |
| a generic rule or worker | Modules/DataSources/ (+ DataSourcesTests) | when the definition language can't say what the provider needs |
| look (name, symbol, colour, icon) | profile.look in <id>.json; the icon image in the asset catalog | always |
| research | docs/providers/<id>/README.md (users), design.md (contributors) | always |
The rules (MODULAR_DESIGN §3–4):
JSONRPCFetcher, OAuth2Refresher), never a vendor.Probe names. The words are DataSource, Fetch, Mapping, Usage, Quota, Plan and Cost.Modules/* never import Domain. The usage model is Quotas; providers, settings and accounts are Providers.{
"profile": { // WHO IT IS
"id": "acme", // stable forever: settings and the menu bar key on it
"name": "Acme",
"links": { "dashboard": "https://…", "status": "https://…" },
"look": { "symbol": "bolt.fill", "icon": "AcmeIcon", // SF Symbol, asset name
"color": { "light": [0.2, 0.5, 0.9], "dark": [0.3, 0.6, 1.0] },
"gradientEnd": { "light": [0.1, 0.3, 0.7], "dark": [0.2, 0.4, 0.8] } }
},
"cli": "acme", // optional
"enabledByDefault": true,
"order": 280, // its default place in the lineup; none → after the rest, by name
"guestPasses": {}, // optional: a capability the engine runs (Claude's)
"defaultDataSource": "api",
"dataSources": [
{
"kind": "api", // what Settings' Data source picker saves as <id>.probeMode
"label": "API", "summary": "Calls Acme API directly",
"credential": { … }, // CredentialLookup — where the key is
"fetch": { … }, // Fetch — how to ask
"mapping": { … }, // Mapping — how to read the answer
"fallback": "cli" // optional: try this kind when this one fails
}
]
}| Sum | Cases |
|---|---|
credential | environment ("loginShell": true also asks the person's login shell) · browserStorage (a site's local storage, one browser profile) · setting (a key pasted into ClaudeBar, in the Keychain) · jsonFile (paths $.a.b, or a list — the first that answers; ~ and ${VAR:-default}; record, defaults) · keychain (account, encoding: "goKeyringBase64") · browserCookies (format: "value"|"header") · sqlite · firstOf — any of them refined with match (a value must fit a pattern, or no key), with (values added) and cookies (named cookies read out of a Cookie header); plus "refresh": { "oauth2": … } or "refresh": { "cli": … } (the CLI renews its own file) |
fetch | http ({{token}}, {{x#host}}, {{x#jwt.claim}}, {{system.timeZone}}) or "http": { "steps": [ … ] } (keep, optional, unless, attempts, dropEmpty) · jsonRpc · cli — a terminal, for a TUI (input, inputDelay, autoResponses, readyWhen, screen: "rendered"; Mac, Linux and Windows) · command — pipes, exit code reported (Mac and Windows, like jsonRpc and script) · file · directory · sqlite (rows of an app's own database, read-only) · localServer (an app's server on 127.0.0.1, found through its process, on every platform; a test hands the stubbed make its running: programs) · cloudWatch (cloud metrics through a port, priced from a PriceCatalog) |
mapping | json (below) · text (error phrases, then label + regex for % left/used) · script (a .js file in QuickJS, no I/O, no Intl; host humanDate(), jsonDecimal(), decimalCents(), decimalAdd(), decimalMultiply(); Date's local time is the person's zone on every platform; context.values from "values"; returns quotas (with group), notes, plan, cost (with lines), account, or error) |
Per data source, also: errors (http.<status>, http.default,
cli.missing, cli.nonzero, cli.failed → a reason; a 429 is always a rate
limit), fallbackOn (hand-off by failure tag), fallback, cache.ttl (also
the background-refresh floor), context (other JSON files the mapping may read),
recover.patchJSONFile, requiresFiles, verifyBeforeBackground. Per
provider: links.dashboardByPlan and accounts (below). Who is signed in is
never written per data source: it is accounts.folder.signedInAs, said once.
"settings": [
{ "id": "apiKey", "label": "API Key", "kind": "secret", "scope": "account", "for": ["api"] },
{ "id": "region", "label": "Region", "scope": "account", "default": "china",
"kind": { "choice": [ { "id": "china", "label": "China", "host": "api.acme.cn" },
{ "id": "intl", "label": "International", "host": "api.acme.com" } ] } },
{ "id": "home", "label": "Signed-in Folder", "scope": "account", "for": ["cli"], "kind": { "path": { "mustExist": true } } },
{ "id": "authEnvVar", "label": "Environment variable", "default": "ACME_API_KEY" }
]secret (to the Keychain, fills nothing), choice (its options carry values), path (mustExist), text (pattern).{{setting.region.host}} fills any string of the definition; a blank setting leaves its template unfilled (a script's values drop it).scope: "account" is what Add Account asks for; "for": [kind] asks only while that data source is active, and a login without such a value runs only the sources that don't need it.An account shows only what its own sign-in reaches (multi-account design). The default account is signed in on this Mac. An added account is signed in through what the person gave it: a key, a folder, or both. Write every read once, for any account:
{{account.<id>}}
(a folder setting, {{account.folder}}) or a { "setting": … } lookup.accounts.patch; forgetting a read can't leak.| The vendor keeps a login… | You write |
|---|---|
| as a key the person can paste (API key, session token, cookie) | an account-scope secret setting, and { "setting": "<id>" } somewhere in the lookup chain (append it; keep the default account's order). No accounts block |
| in a folder the person names on the form | an account-scope path setting with the vendor's default folder ("default": "~/.grok"), reads under {{account.<id>}}; accounts.cli only if a CLI must be pointed at it |
| in a folder ClaudeBar checks (Choose Signed-in Folder, Sign in with browser) | accounts.folder and accounts.signIn, reads under {{account.folder}}, and accounts.cli |
| as a key the CLI takes | two data sources: one with the account's key handed to the CLI ({{token}} in its environment), falling back on authenticationRequired to one that runs the CLI as its own login, which is this Mac's (ampcode.json) |
"accounts": {
"folder": {
"default": "${CODEX_HOME:-~/.codex}", // where the vendor keeps the default account's login
"signedInAs": { // "Signed in as …": read once, for every data source
"file": "{{account.folder}}/auth.json", // a list where the vendor keeps the default elsewhere
"id": "$.tokens.account_id", // checked before and after every fetch of an added account
"email": "$.tokens.id_token#jwt.email" // the pill's; a mapping reads any field as $signedInAs.<field>
},
"notSignedIn": "…" // optional
},
"signIn": { "args": ["login"] }, // the vendor's login; its program is "cli"
"cli": { // every CLI run of an added account: fetch, renewal, sign-in
"environment": { "set": { "CODEX_HOME": "{{account.folder}}" }, "unset": ["OPENAI_API_KEY"] },
"args": ["-c", "cli_auth_credentials_store=\"file\""]
}
}Where the vendor keeps the default account somewhere else, write both places
where they are read: a path list (["{{account.folder}}/.claude.json", "${CLAUDE_CONFIG_DIR:-~}/.claude.json"]) or a name worked out from the folder
("Claude Code-credentials-{{account.folder#sha256.8}}").
"json": {
"plan": { "path": "$credential.plan", "plans": { "max": "claudeMax" } }, // or "badges"
"email": ["$.account.email", "$credential.email"],
"quotas": [
{ "kind": "session", "at": "$.five_hour", "usedPercent": "utilization",
"resetsAt": { "iso8601": "resets_at" } }, // or epochSeconds / secondsFromNow
{ "kind": "model", "each": "$.limits", "where": { "path": "kind", "equals": "weekly" },
"name": { "firstOf": ["model.name"], "firstWord": true, "lowercase": true },
"usedPercent": "percent", "unique": true, "overLimit": true, "countdown": "hours",
"window": [{ "seconds": "window_seconds" }, { "days": 7 }] }, // the response's word, else the provider's
{ "kind": "time", "name": "Credits", // money, not a percentage:
"left": { "money": "$.data.remaining", "of": "$.data.limit", "currency": "USD" } } // no "of" = a balance
],
"cost": [ // the first shape that answers
{ "kind": "extraUsage", "when": { "path": "$.spend.enabled", "equals": true },
"used": { "amount": "$.spend.used.amount_minor", "decimals": "$.spend.used.exponent" },
"limit": { "amount": "$.spend.limit.amount_minor", "decimals": "$.spend.limit.exponent" } }
],
"whenEmpty": { "if": { "path": "$.plan", "equals": "free" }, "quotas": [ … ], "otherwise": "No data yet" },
"notAnObject": "Failed to parse usage response"
}Paths: $.a.b from the root, a.b from the current object, $header.x, $key
(the map key inside each), $credential.x (a non-secret credential value),
$signedInAs.field (who is signed in: a field of accounts.folder.signedInAs),
$context.file.field (a field of another context file the data source reads).
A list of values means the first that answers; a number is a constant.
Name each test should <outcome> [when <situation>], in the person's words, never a method, type or mechanism verb → Naming tests.
Find where the usage really comes from (CLI command, endpoint, local file) and
capture real responses, redacted, including the failure answers: logged
out, rate limited, a free plan, an empty account. Write what you learned in
docs/providers/<id>/design.md.
Modules/Providers/Tests/<Name>DefinitionTests.swift runs the real definition
through the real Provider over stubbed connections with StubbedProvider
(Tests/Support/Connections.swift). They fail first because <id>.json doesn't exist.
@MainActor
@Suite
struct AcmeDefinitionTests {
@Test
func `should read Acme from its API by default`() throws {
let acme = try ProviderFactory.builtIn("acme")
#expect(acme.dataSources.map(\.kind) == ["api"])
#expect(acme.defaultDataSource == "api")
}
@Test
func `should show the session window when the API answers`() async throws {
let stub = try StubbedProvider(providerId: "acme")
defer { stub.cleanUp() }
stub.environment = ["ACME_API_KEY": "test-key"]
stub.answerHTTP(#"{"session":{"used_percent":30,"reset_at":1735000000}}"#)
let usage = try await stub.make("acme").refreshPlain()
#expect(usage.quota(for: .session)?.percentRemaining == 70)
#expect(usage.quota(for: .session)?.resetsAt == Date(timeIntervalSince1970: 1735000000))
}
@Test
func `should say the key is missing when the API has no key`() async throws {
let stub = try StubbedProvider(providerId: "acme")
defer { stub.cleanUp() }
let acme = try stub.make("acme")
await #expect(throws: UsageError.authenticationRequired) { try await acme.refresh(acme.defaultAccount) }
#expect(acme.defaultAccount.lastFailedStep == .lookup)
}
}Assert on state: the usage, lastError, lastFailedStep, answeredBy, and
files written back. Don't verify() calls. Cover every fixture from step 1.
When a provider can have an added account, test that it reaches only its own
sign-in. definition.dataSources(forAccount:) is what an added account runs; a
test reading a built-in data source for the default account uses
definition.asDefaultAccount(kind) (Tests/Support/DefaultAccount.swift), since
{{account.x}} is only filled for an account:
@Test
func `should never read this Mac's key for an added account`() throws {
let acme = try ProviderFactory.builtIn("acme")
let added = try #require(try acme.dataSources(forAccount: [:]).first)
#expect(added.credential == .firstOf([.setting("apiKey")])) // ACME_API_KEY is this Mac's
}Add <id>.json until the golden tests pass. ProviderFactory.builtIn validates it:
kinds are unique, and the default and every fallback name an existing kind.
Don't write vendor code. Find the generic shape of the need, for example
"a list filtered by a field" or "money in minor units", and add it to
DataSources test-first in Modules/DataSources/Tests/. Then use it from the
JSON. ENGINE_DESIGN §1
lists the pieces each provider needed. Add your row there.
Only a format no rule can read, like a terminal UI screen, gets a mapping
script, <id>-<what>.js. Test it through Swift with real captured screens
(see ClaudeUsageScreenTests).
There is nothing to register: the file being in Resources/Providers/ makes it
a provider. "order" places it in the default lineup (the built-ins use 10, 20,
… 270); leave it out to come after the rest, by name. The person's own order
still wins. DetectionTests lists the built-ins in order — add your id there.
Every provider gets the same Engine: settings, the vault, the login shell (for
a lookup with "loginShell": true), the cloud ports (for a cloudWatch fetch),
the guest-passes runner (for "guestPasses": {}). Ask for one in the JSON;
never pass one in Swift.
Its name, symbol and colours are profile.look in the JSON — no switch id
table to edit. Add the icon image to the asset catalog under look.icon
(references/provider-icon-guide.md).
Settings need no new protocol: the data source choice is
dataSourceKind(forProvider:), and an on/off setting a definition names (for
example fallback.enabledBySetting) is isOn(_:forProvider:).
docs/providers/<id>/README.md (what users see, setup, errors) and design.md (sources, fields, gotchas).README.md — name, what it tracks, a link to its docs/providers/<id>/README.md — placed by its "order", so the table follows the default lineup.website/public/index.html, <div class="pv" data-id="<id>" …>), in lineup order, and every count on the page raised by one — npm test in website/ reads the definitions and fails until both match.## [Unreleased] in CHANGELOG.md.python3 scripts/gen-docs.py && python3 scripts/check-docs.py --strict.Every built-in provider is already a definition; when you change one, keep
every saved value working. The data source choice is <id>.probeMode, and a
setting foo is read from <id>.foo. A value kept somewhere else gets a row,
never a branch:
JSONSettingsRepository.legacySettingKeysJSONSettingsRepository.legacyDefaultsKeys (a saved number or list reads as text)ProviderVault.legacyKeyscodexHome → folder) → JSONSettingsRepository.renamedAccountValuesEach moves to its new place the first time it's saved, and each needs a test.
design.md<id>.json makes them pass; no vendor-named Swift anywhereDataSources, test-first, and listed in ENGINE_DESIGN §1{{account.x}}, and a test checks dataSources(forAccount:)"order" set if it has a place in the default lineup, and its id in DetectionTestsprofile.look filled in and the icon added to the asset catalogREADME.md Providers table, its landing-page tile and counts (npm test in website/), and the CHANGELOG line written; docs check passestuist test greentddworks/ClaudeBar
05ae5fb
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.