- Docs
- Guides
- Credential Brokers
- How-to
- How to add a credentialed skill
How to add a credentialed skill
Build a lint-clean authenticated skill that uses the correct broker and keeps credentials outside the model boundary.
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/ 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.
Before you start
Section titled “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-brokersuser-scope pack installed (agentbundle install --pack credential-brokers --scope user), if you’re using thecredsorsso-cookiebroker.
Step 1 — Pick a broker
Section titled “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 thecredbrokerlibrary (pip install credbroker), imported in-process — declare it in your skill’srequirements.txt(Step 9). Pick this for static API tokens / PATs.sso-cookie— session cookie acquired via a browser SSO flow. Your skill importscredbrokerand callsload_sso_cookies/refresh_sso_session; the library subprocess-invokes thesso-broker.pyengine 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, one per broker; copy the one matching your choice.
Step 2 — Pick a primitive class (orthogonal to broker)
Section titled “Step 2 — Pick a primitive class (orthogonal to broker)”credentialed-cli— your primitive is a Python CLI invoked from the skill body viasubprocess.run([sys.executable, "scripts/cli.py", ...]). The argv ban applies (no--token/--api-token/--bearer/--pat/--passwordflags) 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
Section titled “Step 3 — Scaffold the skill directory”your-skill-name/├── SKILL.md├── scripts/│ └── cli.py└── references/ └── creds-schema.toml # only for auth: creds / auth: envPlace this under your pack’s .apm/skills/ directory (e.g. packs/<your-pack>/.apm/skills/your-skill-name/).
Step 4 — Declare the frontmatter
Section titled “Step 4 — Declare the frontmatter”The frontmatter shape varies by broker. For auth: creds:
---name: your-skill-namedescription: <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)
Section titled “Step 5 — Declare the schema (auth: creds and auth: env only)”The schema lives at <skill-dir>/references/creds-schema.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 = falsesecret = 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
Section titled “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:
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:
import credbroker
credbroker.validate_sso_profile(profile) # grammar guardjar_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
Section titled “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:
### 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:
### 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:
### 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:
### 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
Section titled “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).
### 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 — itnames the cause more reliably than the exit code, whose meaning variesbetween 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 thecredential-setupskill (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 nextget-cookiesopens a browser; let the user complete it.
Step 9 — Declare the credbroker dependency (auth: creds only)
Section titled “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:
credbrokerpython -m pip install -r requirements.txtThe 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
Section titled “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:
- Vendored floor (zero-pip, always present at user scope). When a user installs the
credential-brokerspack 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/libtosys.pathat lowest precedence (the five API CLIs inside their__package__bootstrap;credential-setup’ssetup.pyahead of its top-level import). So a no-repo user-scope install resolvesimport 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). - Offline / local pip (corporate, no PyPI). A
pip installof the wheel (from an internal index or a local.whl) landscredbrokerin site-packages, which sits earlier onsys.paththan the floor — so it wins over the floor and unlocks the[crypto]vault. No PyPI dependency. Detailed just below. - PyPI (open adopters).
pip install credbroker[crypto]from public PyPI; same site-packages precedence as layer 2. Published ascredbroker 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)
Section titled “Installing without PyPI (corporate)”credbroker does not require PyPI. The release-credbroker 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):
Terminal window python -m pip install credbroker --index-url https://pypi.example.corp/simplepython -m pip install "credbroker[crypto]" --index-url https://pypi.example.corp/simple # + encrypted-at-rest vault -
From a local
.whl:Terminal window pip install ./credbroker-<version>-py3-none-any.whlpip 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
Section titled “Step 10 — Set up the credential”Once everything is in place, populate the credential for your namespace:
-
auth: creds— invoke thecredential-setupskill (ships with thecredential-brokerspack). It reads yourcreds-schema.toml, prompts for each required key (secret keys viagetpass; non-secret viainput), 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 callscredbroker.register_sso_session(the jira skill’s ispython scripts/jira.py check --register), and have the user run it. Direct engine invocation stays available for a scripted pre-bake:Terminal window 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.
Step 11 — Run the lint
Section titled “Step 11 — Run the lint”python3 tools/lint-agent-artifacts.py # frontmatter schemabash tools/lint-credentialed-skills.sh # credentialed-skill ruleslint-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 ifscripts/imports no credential resolver (from credbroker import …, or the legacyfrom .credentials_shim).auth: env— refuse if any declared<NAMESPACE>_<KEY>is never read inscripts/.auth: sso-cookie— refuse ifscripts/does not reach the broker throughcredbroker; 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
Section titled “Common pitfalls”- Printing
creds.API_TOKENinside a debugprint(...). The token reaches stdout where any caller can capture it. Uselen(creds.API_TOKEN)only if you must prove resolution, and ideally don’t even disclose the length. - Forgetting to declare
credbrokerinrequirements.txt. Thefrom credbroker import …resolver import fails withModuleNotFoundError: credbrokeruntil 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" / …plussubprocess.run(...). Callcredbroker, 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
--tokenflag “just for local testing”. The argv ban applies in every environment and every broker; thecredential-setupskill is the supported escape hatch for thecredsbroker.
Reference
Section titled “Reference”- Reference consumer (runnable, shipped):
packs/atlassian/.apm/skills/jira/— a liveauth: credscredentialed CLI - Explanation:
guides/credential-brokers/explanation/credentialed-skills.md - Related how-to: How to author a skill — the general skill-authoring standards (structure, cross-platform scripts, the three-tier dependency policy); the
auth: clibroker is where credential and tool-presence concerns meet.