**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...
Scanned 9/3/2026
Install to Claude Code
npx -y skills add eugenelim/agent-ready-repo --skill how-to --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of How To?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/eugenelim-how-to-0bb8fc52)More formats (shields.io, HTML) on the badges page.
---
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.
### 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`](../../../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.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!