**Use this when:** your skill calls an external authenticated service (API token, vendor CLI, or corporate SSO) and the credential must never pass through the model. **Prerequisites:** target service's auth shape identified, namespace name chosen, and the `credential-brokers` pack installed if you're using the `creds` or `sso-cookie` broker. **Result:** a scaffolded, lint-passing credentialed skill directory with the correct broker wired, a `### Security rules (non-negotiable)` block in `SKIL...
Installs into .claude/skills of the current project.
Are you the author of How To?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/eugenelim-how-to-0bb8fc52)
---
title: "How to add a credentialed skill"
summary: "Build a lint-clean authenticated skill that uses the correct broker and keeps credentials outside the model boundary."
pack: credential-brokers
kind: how-to
---
# How to add a credentialed skill
**Use this when:** your skill calls an external authenticated service (API token, vendor CLI, or corporate SSO) and the credential must never pass through the model.
**Prerequisites:** target service's auth shape identified, namespace name chosen, and the `credential-brokers` pack installed if you're using the `creds` or `sso-cookie` broker.
**Result:** a scaffolded, lint-passing credentialed skill directory with the correct broker wired, a `### Security rules (non-negotiable)` block in `SKILL.md`, and banded exit-code handling in `scripts/cli.py`.
This is a one-page walk-through for authoring a credentialed primitive — a skill that calls an authenticated external API on behalf of the user. The architecture rule is *skills don't hold credentials*; a Python CLI under the skill's `scripts/` directory owns the secret on disk and constructs the API call inside its own process. The LLM never sees the token as a tool argument.
For a runnable, shipped reference, read a real consumer — [`packs/atlassian/.apm/skills/jira/`](../../../packs/atlassian/.apm/skills/jira) is a live `auth: creds` credentialed-CLI whose `scripts/_client.py` resolves a PAT via the `credbroker` library; this guide is the procedure that gets you to your own.
:::note
**When to use this** — your skill calls an external service that takes API tokens, Bearer auth, or session cookies via corporate SSO. If your skill only shells out to a binary the user has already authenticated on PATH (`gh`, `git`, `kubectl`) and the vendor binary owns the credential end-to-end, the `auth: cli` broker fits; everything else picks a different broker below.
:::
## Before you start
You need:
- The target service's authentication shape — static API token, vendor CLI, or corporate-SSO session cookie. The broker you pick depends on this.
- A namespace name — a short kebab/snake-case identifier (`jira`, `github`, `acme_corp`). For static tokens this becomes the env-var prefix and the keychain account label.
- The `credential-brokers` user-scope pack installed (`agentbundle install --pack credential-brokers --scope user`), if you're using the `creds` or `sso-cookie` broker.
## Step 1 — Pick a broker
`metadata.auth` names the broker that resolves the credential. Four ids, picked once per skill:
- **`env`** — the credential is a plain environment variable (`<NAMESPACE>_<KEY>`). Catalogue contributes naming convention and lint; no runtime resolver. Pick this for CI runners, ephemeral containers, and adopters whose threat model permits process env.
- **`cli`** — the primitive shells out to a vendor-authenticated binary (`gh`, `aws`, `kubectl`, `gcloud`). Vendor CLI owns the credential. Pick this when the user has already authenticated the vendor binary on their PATH.
- **`creds`** — static token resolved via the three-tier model (env → OS keychain → 0600 dotfile floor). Resolution comes from the `credbroker` library (`pip install credbroker`), imported in-process — declare it in your skill's `requirements.txt` (Step 9). Pick this for static API tokens / PATs.
- **`sso-cookie`** — session cookie acquired via a browser SSO flow. Your skill imports `credbroker` and calls `load_sso_cookies` / `refresh_sso_session`; the library subprocess-invokes the `sso-broker.py` engine on your behalf. Pick this for corporate-SSO endpoints (e.g. enterprise Jira / Confluence behind Okta or AzureAD).
The rest of this guide picks `creds` as the worked example because it's the most common case. The verbatim per-broker `### Security rules (non-negotiable)` block you embed in your `SKILL.md` is given inline in [Step 7](#step-7--embed-the-security-rules-block-in-skillmd), one per broker; copy the one matching your choice.
## Step 2 — Pick a primitive class (orthogonal to broker)
- **`credentialed-cli`** — your primitive is a Python CLI invoked from the skill body via `subprocess.run([sys.executable, "scripts/cli.py", ...])`. The argv ban applies (no `--token` / `--api-token` / `--bearer` / `--pat` / `--password` flags) regardless of broker.
- **`mcp-server`** — your primitive is a long-lived MCP server the user wires into their MCP host configuration. Header-naming flags (`--bearer-header`, `--auth-header`, `--header-prefix`) are allowed; the storage convention does not apply because the server holds no on-disk credential state.
The rest of this guide assumes **`credentialed-cli`** (the common case).
## Step 3 — Scaffold the skill directory
```
your-skill-name/
├── SKILL.md
├── scripts/
│ └── cli.py
└── references/
└── creds-schema.toml # only for auth: creds / auth: env
```
Place this under your pack's `.apm/skills/` directory (e.g. `packs/<your-pack>/.apm/skills/your-skill-name/`).
## Step 4 — Declare the frontmatter
The frontmatter shape varies by broker. For `auth: creds`:
```yaml
---
name: your-skill-name
description: <one-line description; what triggers the skill>
metadata:
credentialed: true
primitive-class: credentialed-cli
auth: creds
namespace: your-namespace
keys: ["API_TOKEN"]
---
```
For `auth: env`: same shape, `auth: env`. For `auth: sso-cookie`: `auth: sso-cookie` plus `sso_profile: <profile>` (no namespace/keys). For `auth: cli`: just `auth: cli` (no namespace/keys/profile).
`tools/lint-agent-artifacts.py` refuses unknown `auth:` values; `metadata.credentialed: true` requires `metadata.auth`.
## Step 5 — Declare the schema (`auth: creds` and `auth: env` only)
The schema lives at `<skill-dir>/references/creds-schema.toml`:
```toml
[namespace]
name = "your-namespace"
[[namespace.keys]]
name = "API_TOKEN"
label = "<service> API token"
secret = true
[[namespace.keys]]
name = "BASE_URL"
label = "<service> instance base URL"
secret = false
```
`secret = true` keys are prompted via `getpass.getpass` (no echo); `secret = false` keys are prompted via `input()`. `auth: cli` and `auth: sso-cookie` skip this step entirely.
## Step 6 — Import the broker in `scripts/cli.py`
For **`auth: creds`** — declare `credbroker` in your skill's `requirements.txt` (Step 9) and import it directly:
```python
from credbroker import (
CredentialsMissingError,
Tier2HardFailError,
load_credentials,
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="your-skill-name")
parser.add_argument("verb", choices=("call", "check"))
args = parser.parse_args(argv)
try:
creds = load_credentials(
"your-namespace",
required_keys=["API_TOKEN", "BASE_URL"],
)
except CredentialsMissingError as exc:
sys.stderr.write(f"{exc}\n")
sys.stderr.write(
"run the `credential-setup` skill to set the missing keys\n"
)
return 2 # EXIT_USER_ACTION — the user must act
except Tier2HardFailError as exc:
sys.stderr.write(f"keychain unavailable: {exc}\n")
return 1 # EXIT_ERROR — functional; the message carries the cause
# `creds.API_TOKEN` and `creds.BASE_URL` are attribute-accessible
# strings. Never print them, log them, or echo them.
...
```
`credbroker`'s stdlib core pulls no third-party dependency; the optional `credbroker[crypto]` extra adds an encrypted-at-rest vault (Argon2id → AES-256-GCM) as the Tier-3 floor where it's installed. The architectural rule above still applies: cleartext stays inside your interpreter's process boundary.
**Use the banded exit codes:** `0` ok, `1` functional/operational error (the catch-all bucket; the message carries the cause), `2` the user must act (credentials, 401/403, a missing dependency), with `3–9` reserved for future credential/auth codes. Then wrap your entry point in a top-level `except Exception` so no failure escapes as a traceback — `Tier2HardFailError` and anything unexpected map to `1` (print the exception *type*, never `str(exc)`, on the unexpected path). Do **not** use `except BaseException`: `SystemExit` (your own input-validation exits) and `KeyboardInterrupt` (`130`) must pass through.
For **`auth: env`** — just `os.environ["<NAMESPACE>_<KEY>"]`. The lint asserts at least one read per declared key.
For **`auth: cli`** — `subprocess.run(["<vendor-cli>", ...], env={**os.environ})`. The vendor CLI owns the credential.
For **`auth: sso-cookie`** — call `credbroker`. Never resolve the broker path, build its argv, or call `subprocess` from a skill script:
```python
import credbroker
credbroker.validate_sso_profile(profile) # grammar guard
jar_path = credbroker.load_sso_cookies(profile) # a path, never bytes
# Re-establish an expired session, without a human. Takes only a profile —
# the signature is structurally incapable of carrying a sign-in destination,
# which is what stops an automated path choosing where the browser goes. It
# runs headless: if the browser profile cannot complete the flow unaided it
# raises rather than putting a login page in front of whoever is at the
# machine.
credbroker.refresh_sso_session(profile)
# First capture. The only function that accepts a destination — reach it only
# from an operator-typed action, never automatically.
credbroker.register_sso_session(
profile,
login_url=...,
success_url_pattern=...,
cookie_domains=(...,),
validation_endpoint=...,
)
# Optional: ask the resource server where it sends users to sign in, and
# compare before opening a browser. Defence in depth, not a control — the
# derivation target lives in the same config file as the value it attests.
credbroker.derive_sso_destination(base_url, strategies=("atlassian-seraph",))
```
The resolver emits the *path* to a serialised cookie jar; load it inside your primitive and construct the authenticated request without surfacing cookie values to the LLM.
Keeping the spawn inside `credbroker` is not tidiness. The wall-clock bound, the whole-process-tree kill (POSIX process groups vs Windows `taskkill`), and the environment allowlist that stops a headed browser inheriting your `*_API_TOKEN` are written once there, type-checked and CI-exercised — in a skill script they would be neither, and they would be copy-pasted into the next consumer.
**Exit codes you must distinguish.** `refresh` returns `4` when the profile was never registered — route the operator to a first capture — and `5` when a person has to sign in. Both are exit-2 territory for your CLI, but they carry different remediations. Everything else the engine returns is an internal failure, not "your session expired": treat only the *typed* session-unavailable signal as recoverable, or a slow keychain will trigger a browser recapture while the stored session is perfectly valid.
## Step 7 — Embed the Security-rules block in `SKILL.md`
Every credentialed skill carries a `### Security rules (non-negotiable)` block in its `SKILL.md` body. Copy the block matching your broker *verbatim* — the lint (`tools/lint-credentialed-skills.sh`) pins the heading and the broker-specific phrases, so a skill missing either ships as a lint finding. Substitute the placeholders (`<namespace>`, `<KEY>`, `<NAMESPACE>_<KEY>`, `<vendor-cli>`, `<sso-profile>`) for your service; leave the rest byte-for-byte.
**`auth: creds`:**
```markdown
### Security rules (non-negotiable)
- Secrets live only in `~/.agentbundle/credentials.env`
(mode 0600 on POSIX; DACL-restricted on Windows), the OS keyring,
or process environment variables.
**Never** read that file, print it, or echo the token.
- **Never** put the token on the command line. The primitive
refuses flags like `--token` / `--api-token` / `--bearer` /
`--pat` / `--password` and exits — do not work around it.
- If `check` exits with the "missing credentials" code, tell the
user to run the `credential-setup` skill themselves. It's
interactive — do not run it for them.
```
**`auth: env`:**
```markdown
### Security rules (non-negotiable)
- Secrets live only in the process environment. **Never** print, log, or
echo the value of `<NAMESPACE>_<KEY>`.
- **Never** put the credential on the command line. The primitive
refuses flags like `--token` / `--api-token` / `--bearer` /
`--pat` / `--password` and exits — do not work around it.
- If the env var is missing, tell the user to export
`<NAMESPACE>_<KEY>` in their shell rc (or the equivalent for their
process manager) and re-launch the session. Do not write the value
anywhere yourself.
```
**`auth: cli`:**
```markdown
### Security rules (non-negotiable)
- Secrets live only in the vendor CLI's auth store. **Never** read
that store, print it, or echo the token.
- **Never** put the token on the command line. The primitive
refuses flags like `--token` / `--api-token` / `--bearer` /
`--pat` / `--password` and exits — do not work around it.
- If the vendor CLI exits with an authentication error, tell the
user to run the vendor's auth flow themselves (e.g.
`<vendor-cli> auth login`). It's interactive — do not run it for
them.
```
**`auth: sso-cookie`:**
```markdown
### Security rules (non-negotiable)
- Secrets live only in cookie jar in OS keychain (mode 0600 on POSIX;
DACL-restricted on Windows). **Never** read the jar file directly,
print its contents, or echo cookie values.
- **Never** put a session cookie on the command line. The broker
refuses flags like `--token` / `--api-token` / `--bearer` /
`--pat` / `--password` and emits only a *path* on stdout — do not
parse the jar yourself.
- If the broker exits with the "re-auth required" code (`2` from
`get-cookies`, or `4` from `refresh` when no profile was ever
registered), tell the user the SSO session has expired and that
capturing a new one opens a browser. It's interactive — do not run
any setup helper for them.
```
## Step 8 — Write the operational body: bootstrap and failure handling
Step 7 gives the agent the *prohibitions*. The agent also needs the *operations*: how to get the skill working on first run, and what to do when a call fails. Embed the two sections below in your `SKILL.md` body. Unlike the Security-rules block, these are **recommended, not lint-pinned** — keep the shape, adapt the wording to your service.
The dividing line is the charter rule: **the agent may install its own non-secret prerequisites, but never enters the credential itself.** Self-bootstrap covers `pip install`; credential entry stays user-invoked (Step 7, Step 10).
```markdown
### Verify the environment
Install dependencies (idempotent — safe to re-run), then check auth:
python -m pip install -r requirements.txt
python scripts/cli.py check
- Exit 0 → authenticated; proceed.
- Exit 2 → credential missing, unresolved, or rejected (401/403) → see
*When a request fails*.
- Any other non-zero → read the stderr message. `ModuleNotFoundError:
credbroker` means the resolver isn't installed — run `python -m pip
install -r requirements.txt` (or `pip install credbroker`). Surface it;
don't patch around it.
### When a request fails
The CLI exits non-zero and writes the cause to stderr. Read the message — it
names the cause more reliably than the exit code, whose meaning varies
between skills. Act on the cause:
- **Credential missing, unresolved, or expired** (often surfaced as a 401).
Have the user run the setup action for this broker (below); it's
interactive — do not run it for them. Re-run `check`; proceed only when it
exits 0.
- **403 — authenticated but forbidden.** A scope/permission gap, not a
missing token. `creds` / `env`: have the user regenerate the *same*
credential with the missing scope and re-run setup — don't create a second
credential. `cli`: the vendor's re-auth with scopes
(`gh auth login --scopes …`). `sso-cookie`: usually a missing entitlement
on the SSO account — surface it; re-running `get-cookies` won't fix it.
Don't retry blindly.
- **Environment problem** — a keychain/Tier-2 hard-fail, or `credbroker`
not installed (`ModuleNotFoundError: credbroker`). Run `python -m pip
install -r requirements.txt`; surface it.
- **Upstream 5xx or rate limit.** Surface the message; don't loop.
```
"The setup action" resolves per broker — the same one you document in the credential step (Step 10):
- `creds` → run the `credential-setup` skill (interactive; the user runs it).
- `env` → export `<NAMESPACE>_<KEY>` in the shell rc and re-launch.
- `cli` → run the vendor's auth flow (`gh auth login`, `aws configure`, …).
- `sso-cookie` → the next `get-cookies` opens a browser; let the user complete it.
:::note
The agent installs deps unattended (no secret) but never types the token. This is the credentialed-skill form of "self-bootstrapping": the non-secret setup is automatic; the secret stays a user gesture.
:::
## Step 9 — Declare the `credbroker` dependency (`auth: creds` only)
Add `credbroker` to your skill's `requirements.txt` (beside `httpx` if you use it), then install:
```
credbroker
```
```bash
python -m pip install -r requirements.txt
```
The `credbroker` library is pip-installable and imported in-process, so there is no `make build-self` projection step for the resolver and no `scripts/`-vendored shim to keep in sync. In a fresh repo checkout, before either the floor or a pip install is in place, `from credbroker import …` fails with `ModuleNotFoundError: credbroker`. For local development, install from the repo path: `python -m pip install -e ./packages/credbroker`. For locked-down sites, see *Installing without PyPI (corporate)* just below.
### How `credbroker` reaches `sys.path` — the layered model
`import credbroker` resolves through a **`sys.path` precedence stack** fed by three delivery layers, built in cost/value order. You don't choose between them — they stack, and the highest-precedence one present wins:
1. **Vendored floor (zero-pip, always present at user scope).** When a user installs the `credential-brokers` pack at user scope (`agentbundle install --pack credential-brokers --scope user`), install delivers a byte-faithful, stdlib-base copy of the package source to `~/.agentbundle/lib/credbroker/`, and every credentialed skill **appends** `~/.agentbundle/lib` to `sys.path` at **lowest** precedence (the five API CLIs inside their `__package__` bootstrap; `credential-setup`'s `setup.py` ahead of its top-level import). So a no-repo user-scope install resolves `import credbroker` — and full env→keyring→dotfile (Tier-1/2/3) resolution — with **no pip at all**. The floor is stdlib-only, so its Tier-3 dotfile is plaintext; the encrypted `[crypto]` vault is not available from the floor alone (layer 2/3 below adds it).
2. **Offline / local pip (corporate, no PyPI).** A `pip install` of the wheel (from an internal index or a local `.whl`) lands `credbroker` in site-packages, which sits **earlier** on `sys.path` than the floor — so it **wins** over the floor and unlocks the `[crypto]` vault. No PyPI dependency. Detailed just below.
3. **PyPI (open adopters).** `pip install credbroker[crypto]` from public PyPI; same site-packages precedence as layer 2. Published as **`credbroker 0.1.0`** (2026-06-10) via the gated OIDC job described under *Installing without PyPI* below.
Because the bootstrap **appends** (never prepends) the floor, a pip-installed `credbroker` of any vintage always shadows it: pip is the primary contract, the floor is the fallback that guarantees resolution where pip hasn't run. Declaring `credbroker` in your `requirements.txt` (above) stays the right thing to do — it covers the local dev loop and non-user-scope installs, and it's what makes `[crypto]` reachable.
### Package independence from AgentBundle
Starting with AgentBundle 0.51.0, `credbroker` is a declared wheel-level
dependency of `agentbundle` itself. When a user installs `agentbundle` from
a wheel, pip also installs `credbroker 0.7.x`. This has no effect on skill
scripts and makes no change to the layered model above.
A skill's `requirements.txt` still declares `credbroker` independently. The
reason is boundaries: `agentbundle` installs packs and resolves catalogues;
skill scripts run as standalone Python subprocesses — often in venvs or under
Python interpreters that have no relation to the one that runs `agentbundle`.
An `agentbundle` dependency in one Python environment does not make `credbroker`
importable in another.
If your skill script runs in the same environment as `agentbundle` (for example,
during a test), `credbroker` may already be resolvable through `agentbundle`'s
transitive dependency. Do not rely on this: declare the dependency in your own
`requirements.txt`. An explicit declaration is the only path that works across
all Python-environment shapes — venv, editable clone, vendor floor, and the
`pip install credbroker` corporate path.
### Installing without PyPI (corporate)
`credbroker` does **not** require PyPI. The [`release-credbroker`](../../../.github/workflows/release-credbroker.yml) workflow builds a platform-independent wheel (`credbroker-<version>-py3-none-any.whl`) and an sdist on every change to the package and validates them with `twine check`, so a locked-down or air-gapped site can install from a wheel it hosts or copies in:
- **From an internal package index** (Artifactory, Nexus, a private mirror):
```bash
python -m pip install credbroker --index-url https://pypi.example.corp/simple
python -m pip install "credbroker[crypto]" --index-url https://pypi.example.corp/simple # + encrypted-at-rest vault
```
- **From a local `.whl`**:
```bash
pip install ./credbroker-<version>-py3-none-any.whl
pip install "./credbroker-<version>-py3-none-any.whl[crypto]" # + encrypted-at-rest vault
```
The base wheel has no third-party dependency, so its local-`.whl` install is the only truly network-free path. The `[crypto]` extra additionally needs `cryptography` and `argon2-cffi` to be resolvable — from the same internal index, or pre-staged alongside the wheel — so on a fully air-gapped host stage those two first.
Either way pip lands the package in site-packages, so the resolver imports exactly as the worked example above — no code change. Public PyPI is wired into the same workflow behind a **gated, tag-triggered** OIDC job; **`credbroker 0.1.0` was published to PyPI on 2026-06-10**, so `pip install credbroker` now resolves from public PyPI as well. The internal-index and local-wheel paths above remain available and need no PyPI.
## Step 10 — Set up the credential
Once everything is in place, populate the credential for your namespace:
- **`auth: creds`** — invoke the `credential-setup` skill (ships with the `credential-brokers` pack). It reads your `creds-schema.toml`, prompts for each required key (secret keys via `getpass`; non-secret via `input`), and writes to the highest-available tier (keyring on Darwin/Windows; dotfile on Linux with `--allow-insecure-fallback`).
- **`auth: env`** — export `<NAMESPACE>_<KEY>` in your shell rc.
- **`auth: cli`** — run the vendor's auth flow (`gh auth login`, `aws configure`, …).
- **`auth: sso-cookie`** — register the SSO profile. Give your skill a
first-run command that calls `credbroker.register_sso_session` (the jira
skill's is `python scripts/jira.py check --register`), and have the *user*
run it. Direct engine invocation stays available for a scripted pre-bake:
```bash
python3 ~/.agentbundle/bin/sso-broker.py register your-profile \
--login-url <login-url> --success-url-pattern <pattern>
```
Registration opens a headed Chromium window and saves the cookie jar to the OS keychain (or a 0600 file on Linux). After that, `refresh` re-establishes an expired session headlessly, so a `check`-style verb can self-heal without a human — see the `sso-cookie` broker section of [`docs/architecture/credentials.md`](../../../docs/architecture/credentials.md#the-sso-cookie-broker).
## Step 11 — Run the lint
```bash
python3 tools/lint-agent-artifacts.py # frontmatter schema
bash tools/lint-credentialed-skills.sh # credentialed-skill rules
```
`lint-agent-artifacts.py` validates the nested `metadata.credentialed`, `metadata.primitive-class`, and `metadata.auth` keys. `lint-credentialed-skills.sh` walks every credentialed skill and reports broker-agnostic findings (Don't-block presence; argv-ban flags; plaintext dotfile reads without opt-out) plus broker-specific findings:
- `auth: creds` — refuse if `scripts/` imports no credential resolver (`from credbroker import …`, or the legacy `from .credentials_shim`).
- `auth: env` — refuse if any declared `<NAMESPACE>_<KEY>` is never read in `scripts/`.
- `auth: sso-cookie` — refuse if `scripts/` does not reach the broker through `credbroker`; refuse hard-coded absolute paths; refuse inline Playwright.
- `auth: cli` — no positive-grep enforcement; broker-agnostic checks only.
Both lints exit 0 against the worked example; aim for the same.
## Common pitfalls
- **Printing `creds.API_TOKEN` inside a debug `print(...)`.** The token reaches stdout where any caller can capture it. Use `len(creds.API_TOKEN)` only if you must prove resolution, and ideally don't even disclose the length.
- **Forgetting to declare `credbroker` in `requirements.txt`.** The `from credbroker import …` resolver import fails with `ModuleNotFoundError: credbroker` until the dependency is installed (`python -m pip install -r requirements.txt`).
- **Resolving the SSO broker path yourself at all.** Not just the hard-coded-absolute-path version — any skill-side `Path.home() / ".agentbundle" / …` plus `subprocess.run(...)`. Call `credbroker`, which owns the path, the wall-clock bound, the process-tree kill and the environment allowlist. A skill script that spawns the engine directly re-implements four cross-platform controls, none of them type-checked or CI-exercised.
- **Treating every broker failure as "the session expired".** A timeout, a missing engine, or an internal broker error is not an expired session; keying recovery on them opens a browser while the stored session is perfectly valid. Recover only on the typed session-unavailable signal.
- **Adding a `--token` flag "just for local testing".** The argv ban applies in every environment and every broker; the `credential-setup` skill is the supported escape hatch for the `creds` broker.
## Reference
- Reference consumer (runnable, shipped): [`packs/atlassian/.apm/skills/jira/`](../../../packs/atlassian/.apm/skills/jira) — a live `auth: creds` credentialed CLI
- Explanation: [`guides/credential-brokers/explanation/credentialed-skills.md`](../explanation/credentialed-skills.md)
- Related how-to: [How to author a skill](../../_shared/how-to/author-a-skill.md) — the general skill-authoring standards (structure, cross-platform scripts, the three-tier dependency policy); the `auth: cli` broker is where credential and tool-presence concerns meet.
## The credentialed-skill contract
Skills that call external authenticated APIs follow a tighter set of
rules than plain skills, because the moment a credential reaches the
LLM as a tool argument the architecture has already failed.
This section is the in-loop reminder of the shape every credentialed
skill must respect.
### Two-layer architecture
Skills do not hold credentials. A *credentialed primitive* — a Python
module, an MCP server, or a CLI wrapper packaged as a primitive —
owns the secret on disk and constructs the API call inside its own
process. The skill body invokes the primitive without ever touching
the token. A how-to on adding a credentialed skill walks authors
through broker selection and the verbatim security-rules blocks; the
shipped `jira` / `figma` skills are runnable references.
### Frontmatter declarations
A credentialed skill declares three project-specific flags under the
`metadata:` block of its `SKILL.md` frontmatter:
```yaml
---
name: your-skill-name
description: <what triggers it>
metadata:
credentialed: true
primitive-class: credentialed-cli # or mcp-server
auth: creds # env / cli / creds / sso-cookie
# auth-fallback: creds # optional: dual-auth — the broker to fall
# # back to when the active one can't resolve
# # (e.g. sso-cookie with a creds fallback)
# broker-specific extras follow:
# namespace: <ns> # required for auth: creds and auth: env
# keys: ["<KEY>"] # required for auth: creds and auth: env
# sso_profile: <profile> # required for auth: sso-cookie
---
```
The keys live under `metadata:` rather than at top level because the
[agentskills.io specification](https://agentskills.io/specification)
pins the top-level frontmatter set to `name`, `description`,
`license`, `compatibility`, `metadata`, `allowed-tools` and reserves
`metadata:` as the project-specific escape hatch. `agentbundle catalogue verify`
(step 11) refuses any top-level key outside that set; `agentbundle catalogue lint`
(`_PackRules._check_credentialed_skills`) scopes its checks to skills with `metadata.credentialed: true`.
`metadata.auth-fallback` is optional and names a second broker a **dual-auth**
skill falls back to when the active one can't resolve (e.g. an `auth: sso-cookie`
skill that drops to `creds` on a non-SSO instance). When present, the skill's
Security section must satisfy **both** brokers' don't-block phrase sets.
### Four brokers — pick one per skill
`metadata.auth` names the broker that resolves the credential. Choose
exactly one of these four ids:
- **`env`** — the credential is a plain environment variable
(`<NAMESPACE>_<KEY>`). Catalogue contributes naming convention and
lint; no runtime resolver.
- **`cli`** — the primitive shells out to a vendor-authenticated
binary (`gh`, `aws`, `kubectl`, `gcloud`). Vendor CLI owns the
credential.
- **`creds`** — static token via the three-tier model (env → OS
keychain → 0600 dotfile floor). Resolved via the `credbroker`
library (`pip install credbroker`), imported in-process; the
build-projected `credentials_shim` it replaced is retired for
`creds` consumers (the four-broker taxonomy above is unchanged).
- **`sso-cookie`** — session cookie acquired via a headed-browser SSO
flow. The skill resolves the session through the `credbroker` SSO
resolver (`from credbroker import load_sso_cookies`), which
subprocess-invokes `~/.agentbundle/bin/sso-broker.py` (projected by the
`credential-brokers` pack at user scope) — mirroring how `creds` moved
broker resolution into `credbroker`. A skill that still resolves the
broker in its own `scripts/` is also accepted.
The broker-agnostic invariants below apply to every credentialed
primitive regardless of broker. Broker-specific lint extensions layer
on top (`auth: creds` requires a credential-resolver import in
`scripts/` — `from credbroker import …`, or the legacy
`from .credentials_shim …`; `auth: env` requires each declared `<NAMESPACE>_<KEY>` to
be read at least once; `auth: sso-cookie` requires either a credbroker SSO import
(`from credbroker import load_sso_cookies`) or subprocess-invocation of the
canonical `Path.home() / ".agentbundle" / "bin" / "sso-broker.py"` path; `auth:
cli` falls through to broker-agnostic checks only).
### Three storage tiers
Credentials resolve in this order, first-hit-wins per key:
1. **Tier 1 — env var.** `<NAMESPACE>_<KEY>` from `os.environ`
(e.g. `JIRA_API_TOKEN`). Composes with Vault Agent / `op run --`
wrappers without further changes; the only path that does.
2. **Tier 2 — OS keyring.** macOS Keychain via `/usr/bin/security`
(token via child stdin, never argv); Windows Credential Manager
via in-process `ctypes` against `advapi32`. Linux falls through
to Tier 3 in v1 — a `libsecret` backend is deferred to a v2 RFC.
3. **Tier 3 — dotfile.** `~/.agentbundle/credentials.env`, mode
`0600` on POSIX, DACL-verified via `icacls` on Windows. The
fallback floor.
Changing the order, or adding a new tier, is an `Ask first` action
in the spec's Boundaries section — the corporate-network constraints
that justified the precedence are non-obvious.
### The argv ban
Credentialed-CLI-class primitives must refuse the value-shaped flags
`--token`, `--api-token`, `--api-key`, `--bearer`, `--pat`,
`--password`. The CLI verb's `setup` subparser registers these as
*tombstone arguments* whose action emits the verbatim sentinel
`tokens cannot be passed via argv` and exits non-zero; the
`agentbundle catalogue lint` (`_PackRules._check_credentialed_skills`) refuses any primitive's
script that declares one of the banned names in an
`argparse.ArgumentParser.add_argument` call. MCP-server-class
primitives may accept *header-naming* flags (`--bearer-header`,
`--auth-header`, `--header-prefix`) because those name *which* header
to consult per-request, not the value.
### Anti-pattern register
Five anti-patterns rejected by name:
- **Tokens in skill argv** — defeats the architecture rule.
- **A `get` verb that returns a cleartext token** — any verb that
prints the resolved token to stdout enables capture from a skill
body. The `credential-setup` skill writes; a consumer's `check`
verb reads (resolves and returns 0/non-0 only); no skill or shim
surface returns the cleartext token to a caller other than the
in-process credentialed primitive that owns the API call.
- **Per-skill dotfiles** — the contract mandates one well-known per-user
file; per-skill files multiply the wipe-on-rotation surface.
- **`SSL_VERIFY=false` defaults** — `--insecure` is opt-in only and
must emit a stderr warning.
- **Vendored copies of third-party API skills** — pin upstream and
audit; do not fork to silence a vendor's lint.
### Corporate-network requirements
Credentialed primitives ship from this catalogue running on corporate
laptops; the network they live on imposes constraints the primitive
must respect:
- **Honor `HTTPS_PROXY` / `NO_PROXY` from the environment.** No
hard-coded `requests.get(...)` without proxy resolution.
- **Honor the system trust store via `REQUESTS_CA_BUNDLE`,
`SSL_CERT_FILE`, `SSL_CERT_DIR`.** Corporate MITM CAs land here;
ignoring them turns into a "works on the engineer's laptop only"
bug.
- **Refuse `--insecure` / `verify=False` as a default.** Opt-in flag
only; primitive emits a stderr warning whenever it fires.