Skip to content
DocsCredentials

Concepts

Credentials

Official Kody doc

A credential is anything Kody uses to reach a service for you: an API key, an OAuth connection, an MCP server, a webhook URL, a username and password, or a signing key. Credentials belong to an org (or to you, as personal credentials) and show up together on your org's Credentials page.

One rule holds for every kind: a credential's value never enters package code or your code. Code names a credential. Kody puts the value into the outgoing request at the network boundary, only for the hosts that credential allows, and only over HTTPS. Agents, packages, logs, and tool output never see it. The one thing code can get back is a signature from a signing credential: a token derived from the key, never the key. Treat it like any other bearer token.

The kinds

KindWhat it holdsHow code uses it
KeyAn API key, personal access token, or webhook signing secret{{secret:name}} placeholders, or credential(key).fetch
OAuthA connection you signed in to (GitHub, Google, Slack, …)credential(key).fetch, or {{integration-token:name}}
MCP serverA remote MCP server connectioncredential(key).callTool
URLA whole secret URL (a chat webhook, a /bot<token> base)credential(key).fetch with a relative path
LoginLogin fields Kody exchanges for a session tokencredential(key).fetch
SigningA signing secret or private keycredential(key).sign

Secrets (the Key kind) and integrations (the OAuth kind) are both credentials. The kind names in manifests and APIs are secret, oauth, mcp, url, login, and signing.

Custom secret providers (vaults such as 1Password) are not a credential kind. Granting Kody access to a vault is a separate trust decision, so they have their own page; see Custom secret providers. A saved package declares a provider it uses with a kind: "secret-provider" need (see Use a provider in a saved package). No saved credential ever fills that need.

Every credential has:

  • name: yours to choose, such as work or personal. Callers pick a credential by name.
  • scope: who owns it, seen from the run's org. org means the run's org owns it: every Key, login, OAuth connection, or MCP server stored in that org reads org, including a credential saved with secretSet({ scope: 'user' }) and a Key bound to the package. personal means it is the caller's own personal credential, stored in their personal org and granted to this org one org at a time. In your personal org, your own credentials read org. See Scope in code.
  • provider: an optional slug such as github. Package needs match on it.
  • hosts: the only hosts the value may be sent to, over HTTPS. Hosts live on the credential, never in a package manifest, so publishing or forking a package cannot widen them.
  • inject (Keys used through credential()): where the value goes, a header, Basic auth, or a query parameter. The default is Authorization: Bearer {value}.

Keys and placeholders

A Key is a saved secret. Write {{secret:name}} anywhere in a fetch request: a header, the URL path or query, or a text body. When the request leaves Kody, the gateway checks it and fills in the value.

Your code sends {{secret:github}}. The Kody gateway checks that the request is HTTPS and goes to a host the credential allows, swaps in the value, and redacts the value if the response echoes it back.

const response = await fetch('https://api.github.com/user/repos', {
	headers: { Authorization: 'Bearer {{secret:github}}' },
})

For a webhook URL (Slack, Discord, and similar), keep the path prefix literal and put only the one-segment token in the secret:

await fetch('https://hooks.example.com/services/{{secret:webhookToken}}', {
	method: 'POST',
	body,
})

A path placeholder is filled as one encoded segment, so a / inside the secret becomes %2F and cannot change the route. The MCP reference has the full rule: Secrets and host approval (raw).

  • The request must be HTTPS and its host must be one you approved for that secret. Otherwise it is refused with an error naming the secret and host; a placeholder is never sent unfilled.
  • You approve a Key's hosts on the approval link your agent sends (/connect/secrets). A Key with no approved hosts sends nowhere.
  • To change a Key's or an OAuth connection's hosts later, open it on the Credentials page and choose Edit hosts. Enter host names only (such as content.dropboxapi.com); Kody sends credentials only over HTTPS, with no wildcards, ports, or paths. An OAuth connection always keeps its API base and token hosts; a connection to a built-in app keeps the hosts Kody sets for it. Each change is recorded in the org audit log. Agents cannot edit hosts.
  • If the remote service echoes the value back, the gateway replaces it with [REDACTED SECRET] before your code reads the response.
  • {{integration-token:name}} works the same way for an OAuth connection, on the integration's own hosts.

The gateway fills those placeholders in the body too, not only in headers. Text this package did not write — a user message, an issue body, a comment — can quote {{secret:name}}. On a host that credential allows, the real value is sent. If that name is not saved, the fetch fails with Secret "name" was not found. When the body forwards someone else's text, set x-kody-secret-resolution: headers-only so only headers are filled:

await fetch('https://api.github.com/repos/acme/widgets/issues', {
	method: 'POST',
	headers: {
		Authorization: 'Bearer {{secret:github}}',
		'content-type': 'application/json',
		'x-kody-secret-resolution': 'headers-only',
	},
	body: JSON.stringify({ title: 'Widget broke', body: userReport }),
})

off sends every placeholder as written, including headers. Kody strips the header before the request leaves. kody dev check and a successful publish print a warning for a clear case: a fetch (or new Request) whose init object is a literal, whose body is not a literal (or JSON.stringify / new URLSearchParams of literals), and whose headers do not set x-kody-secret-resolution to headers-only or off. That includes helpers that build headers in a variable and header spreads — the scanner cannot see headers-only on those bags. Variable whole-init values (fetch(url, init)) are still skipped. The warning does not fail the check or block publish.

Publish fails when a fetch puts a credential placeholder in the request body, URL path, URL query, or whole URL. The error lists each file, line, and credential name (never a value) and says to send the credential in a request header instead. Community installs and platform codemods only warn, so existing published packages keep running and republishing; the next authoring publish has to move the credential. The switch is secretPlaceholderRequestPlacementPublishMode in the publish check.

Each credential also stores an allowed fill placement (headers, optionally named; query; path; and/or body). New credentials default to headers-only. The per-request header intersects with that placement (it never widens it). Distinct from inject, which only steers a scoped credential() client. Edit fill placement on the credential detail page, via secretSet / credentialUpdate, or the account secret editor.

The fetch gateway enforces placement: a fill into a part the credential does not allow is refused with a message naming the credential, the refused part, and the allowed parts. Query, URL path, and body also need the owner's opt-in, given by saving them on the credential page, which warns that the host may store a value sent there. A credential that already filled query, path, or body keeps those parts without a new save. Older credentials with no placement set fill headers plus those parts.

Agents cannot widen placement, but they can ask the owner to. A refused query, path, or body fill of a user Key carries a ready approval link, /connect/secrets?name=<key>&placement=body (add &hosts=<host> to approve the host in the same click). The owner sees the credential, the requested part, and the "host may store this" warning, then approves or declines. Agents can build the same link with credentialPlacementApprovalLink (MCP api, or kody api credentialPlacementApprovalLink --params '{"name":"plaidSecret","parts":["body"],"hosts":["production.plaid.com"]}'), which never changes placement itself. Other refusals link to the credential's fill placement setting (/@<org>/-/credentials/<credential>#fill-placement).

Full placeholder rules (encodings, multipart, the inert {{secret:<name>}} mention form): Secrets and host approval (raw).

Scoped clients: credential(key)

With a placeholder, the code decides where the value goes. With a scoped client, the credential decides: its hosts and inject rule say where the value is placed, and it is never placed in a request body. Code gets a client that holds an opaque handle, not a value.

import { credential } from 'kody:runtime'

const github = await credential('github')
const response = await github.fetch('https://api.github.com/user/repos')

The client has name, scope, kind, and hosts, plus one of:

  • fetch for Key, OAuth, URL, and login credentials,
  • callTool for MCP servers,
  • sign for signing credentials.

A relative URL resolves against the credential's first exact host. An absolute URL must be on one of its hosts. If code sets the header the credential injects, the credential's value replaces it. Redirects are followed by the client, and the credential only travels to its own hosts. A 401 or 403 response carries an x-kody-credential-hint header naming the inject shape (for example header Authorization Bearer), never the value.

credentials(key) returns an array of clients for a need declared with multiple: true.

The client's type is KodyCredentialClient, exported from kody:runtime (import type { KodyCredentialClient } from 'kody:runtime'). Use it to type a helper that takes a client, and call credential('key') with a literal key where the client is opened (the same goes for credential.visitor('key')): publish refuses code that passes credential, credentials, or credential.visitor itself around at run time, but type-only uses such as typeof credential are fine (stamp rule (raw)).

Scope in code

client.scope uses the same two values as credentialList and call maps: org or personal, relative to the run's org. secretSet and secretList use a different word, the storage scope (user, package, or session), which says where a Key is stored, not whose it is. A user-scope Key is an owner-level Key of the org it was saved in; user alone does not make it a personal credential. It reads personal only when its owner grants it from their personal org to a team org and it runs there (see the table).

CredentialsecretList scopeclient.scope
Key saved with secretSet({ scope: 'user' }) in the run's orguserorg
Key saved with secretSet({ scope: 'package' }) for the running packagepackageorg
OAuth connection or MCP server in the run's orgnot listedorg
Your user-scope Key in your personal org, run in your personal orguserorg
Your user-scope Key in your personal org, granted to and run in a team orguser (in your personal org)personal
Key saved with secretSet({ scope: 'session' })sessionnever opened by credential()

Most package code should not branch on scope. Kody already picked the credential and checked that the run may use it, and both scopes fetch the same way. Use it for labels, logs, or results that say whose account was used:

import { credential } from 'kody:runtime'

export default async function checkPortal() {
	const login = await credential('portal')
	// Do not refuse a login because scope is 'org': a credential saved with
	// secretSet({ scope: 'user' }) in the run's org reads 'org'.
	const response = await login.fetch('/api/me')
	return {
		account: login.scope === 'personal' ? `${login.name} (yours)` : login.name,
		ok: response.ok,
	}
}

To require one specific credential, declare the need with a provider and kind, and let the caller map it by { name, scope } (see Picking a credential) instead of checking scope in code.

Declare needs in kody.credentials

A package lists the credentials it needs in package.json. Each need has a provider (which defaults to the key) and a required description that tells the person choosing a credential why the package wants it.

{
	"name": "@you/pr-digest",
	"exports": { "./summarize": "./src/summarize.ts" },
	"kody": {
		"description": "Summarize open pull requests every morning.",
		"credentials": {
			"github": { "description": "Read pull requests to summarize them" },
			"slack": { "description": "Post the summary to a channel" }
		}
	}
}

kind is optional. Without it, any credential for that provider that can make requests matches: a Key such as a personal access token, an OAuth connection, or a login. credential('github') picks the right path for whichever one is used (placing the Key's header, refreshing an OAuth token once on a 401, or running the login exchange), so the package code is identical for a PAT and for OAuth.

Declare kind only when the package needs a specific shape:

"credentials": {
	"github": { "description": "Read pull requests to summarize them" },
	"apple-music": { "kind": "signing", "description": "Sign developer tokens" },
	"linear": { "kind": "mcp", "description": "Create issues for failing checks" },
	"alerts-bot": { "kind": "url", "provider": "telegram", "description": "Post alerts" },
	"google": { "kind": "oauth", "multiple": true, "description": "Calendars to include" }
}
  • signing for sign(), mcp for callTool(), url for a whole-URL secret, or oauth when the provider only works through OAuth (Google user data, for example).
  • A second account from the same provider gets its own key with an explicit provider.
  • Needs have no hosts or inject fields (those belong to the credential). An optional placement on a need is the reviewable default for package-scope secrets that need inserts as that name (Connect / secretSet when no row exists yet), instead of headers-only. Disconnect clears the value but keeps the row and placement; reconnect fills the same record so placement wins over the manifest default. Delete removes the credential entirely; a later Connect is a new insert. Package runtimes cannot set placement themselves; the owner can edit it on the credential detail page.

Choose who supplies a package credential

When the package-credential-suppliers flag is on, a need can declare supplier: "publisher" or supplier: "executor". A publisher need is set by the package publisher with an org-level mapping. It cannot be set by a per-person or per-trigger mapping, and a visitor cannot resolve it from their own credentials. An app uses it with credential("key").

An executor need is chosen by each person who uses the app. Its consent scope is need:<key>. The app resolves only the credential IDs selected for that need in that person's live Sign in with Kody grant. It does not fall back to the publisher's credentials, the visitor's personal defaults, or automatic matching. A mapped credential is rechecked for live Use access on every call.

kody.credentials is where a credential need and its supplier are declared. An executor need gets its own need:<key> consent scope from that declaration and needs no kody.app.visitorNeeds entry. A kody.app.visitorNeeds entry whose credential names an executor need is accepted as an alias for it; it cannot name a publisher need. Use kody.app.visitorNeeds for capability needs.

In app code, open an executor need with credential.visitor("key"). Plain credential("key") never reads the visitor's pick. Inside an app it is refused with credential_denied, naming credential.visitor. In a direct run (execute, a job, or another package calling it) the person running it is the executor, so credential("key") resolves their own credentials.

To check whether the flag is on for an org, call packageGet on one of its packages: credentials.suppliersEnabled is true when it is. While it is false, supplier and required are ignored and needs resolve without a supplier.

packageGet also lists credentials.invalidSlots: published kody.credentials slots that fail the current slot schema, each with its validation message. Nothing fills those slots; fix them and republish.

Executor needs default to required. Set required: false to make a need optional. Required needs cannot be skipped in consent, and the app does not serve until the visitor has connected a usable credential. A direct run that cannot resolve a required executor need fails before execution with credential_required, naming the need. Inside an app, an executor need without its grant mapping fails with credential_unbound; it never falls back to the publisher's credential. required is valid only for executor-supplied needs. The older ownerCredentialForVisitors: true setting keeps its meaning as a publisher-supplied alias. A need with no supplier keeps the existing package behavior.

Publisher-supplied credentials in apps

Package apps use the publisher's credentials for every visitor. ownerCredentialForVisitors: true still parses on a need as a leftover alias for supplier: "publisher". Omit it on new packages. See Packages (raw).

Publisher-supplied needs and needs shared with visitors are unmappable: they always act as your org. While a package has one, only org Owners can push, publish, or change it, and sharing Contribute or Manage warns by credential name. An Owner can allow a specific collaborator per grant. See Unmappable credentials.

Each kind through credential()

Key

Any saved Key can be used through a client. Set its provider so needs match it, and its inject if the service does not take Authorization: Bearer. This is one-time setup on the credential's page on the website, or with the MCP api tool (credentialUpdate). Changing inject or widening placement is refused from execute (hosted or --local), package, and app code, with a link to the credential's page; narrowing placement and setting the provider still work there. credentialList returns each credential's id:

{
	"operationId": "credentialUpdate",
	"params": {
		"credential_id": "cred_…",
		"provider": "anthropic",
		"inject": { "kind": "header", "name": "x-api-key", "format": "{value}" }
	}
}
import { credential } from 'kody:runtime'

const anthropic = await credential('anthropic')
const response = await anthropic.fetch(
	'https://api.anthropic.com/v1/messages',
	{
		method: 'POST',
		headers: {
			'content-type': 'application/json',
			'anthropic-version': '2023-06-01',
		},
		body: JSON.stringify({ model, max_tokens: 512, messages }),
	},
)

The inject options are { kind: 'header', name, format } (the format holds {value} once), { kind: 'basic', valueAs: 'password', other: 'api' } (the value is the password and other is a literal username, or the reverse), and { kind: 'query', param }. The same Key keeps filling {{secret:name}} placeholders; inject and provider only affect clients.

OAuth

Connect the service once (your agent sends a /connect/oauth link, with org=<slug> for a team organization). Kody keeps the token fresh. Code looks exactly like the Key example:

const github = await credential('github')
const response = await github.fetch('https://api.github.com/user')

On a 401 the client refreshes the token and retries once. OAuth connections always use Authorization: Bearer. createAuthenticatedFetch and {{integration-token:name}} also resolve the same OAuth connection.

MCP server

An MCP server you added to the org is a credential with callTool:

const linear = await credential('linear')
const result = await linear.callTool('create_issue', {
	title: 'Nightly build failed',
	teamId: 'ENG',
})

The need must declare kind: "mcp". Set the server's provider with credentialUpdate so it auto-resolves.

URL

For services whose secret is the URL itself, such as a Telegram bot (https://api.telegram.org/bot<token>) or a chat webhook. Code passes only a path and query; Kody appends them to the stored URL.

const alerts = await credential('alerts-bot')
await alerts.fetch('/sendMessage', {
	method: 'POST',
	headers: { 'content-type': 'application/json' },
	body: JSON.stringify({ chat_id: chatId, text: 'Deploy finished' }),
})

The stored URL must be HTTPS and on one of the credential's hosts. A path cannot name another origin or climb out of the stored path. A URL client returns redirects as they are instead of following them.

Login

For services that trade a username and password for a session token. Kody stores the login fields, runs the exchange itself, and places only the session token (in a header by default, or a query parameter). The login response never reaches code.

const reports = await credential('acme-reports')
const response = await reports.fetch('https://api.acme.example/v1/reports')

The credential's login_exchange says how to log in: the exchange url (on one of the credential's hosts), bodyFormat (json or form), a body template where {field} is replaced by a login field, and tokenPath, the dot path to the token in the JSON response.

A login also keeps a refresh token. The value holds the token as a field, and the exchange is the token request:

await kody.secretSet({
	name: 'token-demo',
	scope: 'user',
	kind: 'login',
	hosts: ['auth.example.com', 'api.example.com'],
	value: JSON.stringify({ refresh_token: '<refresh token>' }),
	login_exchange: {
		url: 'https://auth.example.com/oauth/token',
		bodyFormat: 'form',
		body: {
			grant_type: 'refresh_token',
			client_id: '<client id>',
			refresh_token: '{refresh_token}',
		},
		tokenPath: 'access_token',
		expiresInPath: 'expires_in',
		refreshToken: { path: 'refresh_token', field: 'refresh_token' },
	},
})

Body values without a {field} token (grant_type, client_id) are sent as written. Without expiresInPath, a session lasts ttlSeconds (default 3600, at most 24 hours). The session goes in an Authorization: Bearer header unless the credential's inject says otherwise. The package declares the need with "kind": "login" in kody.credentials and calls (await credential('token-demo')).fetch('https://api.example.com/v1/items'); it never sees the refresh token or the access token. The owner creates or rotates the credential on the website or with secretSet outside a package: package code cannot set kind, hosts, or login_exchange.

refreshToken is for providers that rotate refresh tokens: path is where the response carries the new one, and field is the login field it replaces (a field the body sends). When the response has a new one, Kody saves it in place of the old one together with the new session, only if the credential did not change meanwhile, and records a value replacement in the audit log without the value.

What Kody keeps: the session, encrypted, for this credential and its current value, exchange, hosts, and inject, until it expires (the 30 second early margin included). Every isolate reuses it, and only one exchange runs at a time per credential, so a rotating provider never sees the same refresh token twice. Editing the value, exchange, hosts, or inject, or deleting the credential, drops it. When one of the credential's hosts answers 401 to a request without a body, Kody drops the session, runs the exchange once more, and retries the request once (with the same host and placement checks). A request with a body is returned as is.

When the exchange fails, Kody records the failure for the credential's current revision and does not run the exchange again until a cooldown passes, so apps and jobs sharing one credential never hammer the provider's token endpoint. The caller that ran the exchange gets login_failed, and so does any caller in the same isolate that was sharing that pending exchange. Callers that arrive during the recorded cooldown, in any isolate (including ones that were waiting on the exchange's lease), fail fast with login_retry_later without contacting the provider. Both carry retryAfterSeconds: wait that long, then retry. The cooldown is 30 seconds after the first failure and doubles for each failure in a row, up to 15 minutes (or longer when the provider's Retry-After asks for it, up to an hour). Network errors, timeouts, 5xx, and 429 recover on their own: after the cooldown the next request runs the exchange again, and a success clears the failure. While a cooldown is running, the 401 retry is skipped and the host's 401 is returned; a freshly exchanged session that the host refuses again also backs off the next 401 retry the same way.

When the token endpoint answers invalid_grant (the refresh token was revoked or is invalid), retrying cannot help: requests fail with login_needs_reconnect, the credential shows Needs attention in the credentials list, and Kody stops running the exchange. The owner reconnects it by saving a new value (for example a new refresh token, on the website or with secretSet). Saving a new value, or editing the exchange, hosts, or inject, starts a new revision with no failure state.

A refresh token that a token request in an execute run returned sealed (kody-sealed.…) can become a login credential without code reading it: pass it as sealed_token together with login_exchange, as in secretSet({ name: 'token-demo', scope: 'user', sealed_token: tokens.refresh_token, login_exchange }). Kody opens it on the host and stores it as the login field the body sends (refreshToken.field, or refresh_token). The credential's hosts are the sealed token's save hosts, and the exchange URL must be one of them. This works from execute in the run that minted the token, not from package code.

Moving a package off a package-scoped refresh secret and a stored access token:

  1. Declare the need in kody.credentials with "kind": "login", and replace the package's token handling with credential('<key>').fetch(...).
  2. The owner creates the login credential on the website, or with secretSet outside a package (value holding the refresh token field, or a sealed_token from a token request in the same execute run, so no code reads the value), and maps it to the need.
  3. Publish, check a request works, then delete the old package secrets (the refresh token and the stored access token) with secretDelete.

Limits: the exchange sends only content-type and accept headers. Package code cannot save the sealed access_token from its own token request as a Key (sealed_token saving works from execute only); without a login credential, use the sealed token in the run that minted it and mint it again next run.

Signing

For APIs that want a signed JWT, an HMAC, or an RSA-signed HTTP request. Code gets the signature, never the key. Declare kind: "signing" for JWT key material. HMAC also works through sign({ type: "hmac", ... }) on an ordinary Key (secret) — the same Key a webhook verification.secretName already uses — so you do not need to re-kind a webhook consumer secret as signing.

const appleMusic = await credential('apple-music')
const token = await appleMusic.sign({
	type: 'jwt',
	algorithm: 'ES256',
	header: { kid: keyId },
	claims: { iss: teamId, iat: now, exp: now + 3600 },
})
await fetch('https://api.music.apple.com/v1/catalog/us/songs/203709340', {
	headers: { Authorization: `Bearer ${token}` },
})
const signature = await partner.sign({
	type: 'hmac',
	algorithm: 'hmac-sha256',
	message: body,
	encoding: 'hex',
	prefix: 'sha256=',
})

JWTs support RS*, PS*, ES*, EdDSA (PKCS#8 PEM private keys), and HS256 / HS384 / HS512. Kody signs only for one of the credential's hosts; pass host to pick one, or it uses the first exact host.

For APIs that sign whole HTTP requests with an RSA key, like Oracle Cloud, use http-signature. Code describes the request; Kody builds the draft-cavage signing string, checks the url host is one of the credential's hosts, and returns only the headers to add. The signature always covers (request-target) and host, so it is not valid for any other request.

const oci = await credential('oracle-api-key')
const url = `https://iaas.us-ashburn-1.oraclecloud.com/20160918/instances?compartmentId=${compartmentId}`
const headers = await oci.sign({
	type: 'http-signature',
	keyId: `${tenancyOcid}/${userOcid}/${fingerprint}`,
	method: 'GET',
	url,
})
await fetch(url, { headers })

profile: 'oracle' (the default) signs date (request-target) host, and for POST, PUT, and PATCH also content-length content-type x-content-sha256 over body, and puts the signature in Authorization. profile: 'generic' signs (request-target) host date plus digest for a body, in a Signature header. signedHeaders picks other headers, and placement picks the header. Kody adds date, the body digest, and the length when missing, and refuses a digest or length that does not match the body. Send the body exactly as signed. The key is an unencrypted RSA PEM (BEGIN PRIVATE KEY or BEGIN RSA PRIVATE KEY). A redirect to another URL needs a new signature, and Kody signs it only if that host is one of the credential's hosts.

Raw signing, sign({ type: 'rsa-sha256', message }), signs any string with the key and returns the RSA-SHA256 signature (encoding is base64, base64url, or hex). It is off for every credential. Only an owner of the account or org, signed in on the website, can turn it on, under Raw signing on the credential's page, because a key that signs anything can impersonate its identity for requests outside the credential's hosts. Agents, API tokens, MCP, and packages cannot turn it on. Every raw signature and refusal is listed on that page for 90 days, with who, how, the package, and the message size; never the key, the message, or the signature. Turning it off stops new signatures at once. See ADR 0099.

Creating credentials

Your agent sends a creation link, and you paste the value into the page, never into chat. Links carry your org in the path:

https://kody.codes/@acme/-/credentials/new?kind=url&name=alerts-bot&provider=telegram&allowedHosts=api.telegram.org

kind preselects a kind; add kindMode=required to lock it. name, provider, description, and allowedHosts prefill the form. The page saves Key, URL, login, and signing credentials in place, and hands OAuth and MCP servers to their own connect flows.

The Add credential button on the Credentials page opens the page without a link. It leads with Copy prompt, a short prompt you paste into your agent so it works out the kind, name, and hosts and hands back a prefilled link. Create one manually opens the same form in place. A link with any prefill skips straight to the form.

On that form, Which packages can read it is Any package in this org (the default) or Only these packages, which locks the credential to the packages you pick (allowedPackages). A key that belongs to one package is made from that package's own Credentials tab (Add a key only this package can read), or from a link that names the package (scope=package&packageId=...).

Code that already holds a value (a key the package just minted) can store it with secretSet, which also takes kind, hosts, login_exchange, provider, and inject. Whoever supplies a value decides where it may go, so kind and hosts are set together with the value.

Picking a credential

You can have several credentials for the same provider, such as work and personal. When code calls credential(key), Kody uses the first of these that applies:

  1. The caller's credentials map for this run.
  2. A stored mapping for the trigger that started the run.
  3. Your default mapping for the package, then the org's default.
  4. The one credential you can Use that matches the need.

Kody never guesses between two matches. Zero matches is credential_unbound and two or more is credential_ambiguous.

Pass a map on execute next to its code:

{
	"code": "import action from 'kody:@you/pr-digest/summarize'\nexport default (params) => action(params)",
	"credentials": {
		"github": "work",
		"slack": { "name": "work", "scope": "org" }
	}
}

A string picks by name, and an object adds scope when an org and a personal credential share a name. Package-bound Keys also read org scope, so when a user-scope Key and a package Key share a name, a { name, scope } pick always gets the user-scope Key. Give them distinct names, or map a need that must use the package Key in the package's credential settings. For a multiple need, an array picks several for credentials(key) ("google": ["work", "personal"]), and [] picks none on purpose. On execute, a key that no package in the run declares gets a warning, not a failure. The CLI takes the same picks (--invoke writes the passthrough for you):

npx @kodycodes/cli execute --invoke kody:@you/pr-digest/summarize \
  --credential github=work --credential slack=org:work

A pick grants nothing. It chooses only among credentials you can already Use. Running code through a client needs your Use on the credential; a package's identity never grants access on its own.

Defaults and trigger mappings are stored on the package you run (the entry package), including needs it inherits from its dependencies. When a dependency calls credential(key) in that run, it uses the entry package's mappings (the first entry that has the need when one run has several, and a caller that is itself an entry uses its own), and a creation link names the entry package. A mapping stored on the dependency applies only when you run the dependency itself. Agents propose changes with credentialMappingPropose; you approve them on the package's approval page or its Credentials tab.

To build one shared package that many callers use with their own accounts, see Shared packages for many accounts.

A Key bound to one package fills only that package's own credential() calls, so map an org or personal credential for a need the entry package inherits.

Errors

Every credential error is structured: it has a code, a message, and retryWith, a list of ready-to-retry picks.

CodeMeaning
credential_unboundNo credential you can use matches the need
credential_requiredA required executor need has no connected credential before a direct run
credential_ambiguousMore than one matches; options lists them by name/scope
credential_not_foundThe named credential is missing or not usable by you
credential_need_unknownThe map names a key no package in the invoked run declares
credential_need_undeclaredA package someone else runs uses a credential it does not declare in kody.credentials
credential_deniedAuthorization failed
credential_rate_limitedAn app's visitors used an owner's credential too often; try again shortly
login_failedA login's exchange failed; retryAfterSeconds says when Kody runs it again
login_retry_laterA login's exchange failed recently and is cooling down; retry after retryAfterSeconds
login_needs_reconnectThe provider rejected the login's refresh token; the owner saves a new value
host_not_allowedThe request's host is not one of the credential's hosts
insecure_transportThe request is not HTTPS

When a need has no usable credential, or more than one, the error carries createUrl: a link to the creation page prefilled for that need, so an agent can hand you a link instead of a bare error. A name that matches nothing (credential_not_found) carries one too.

try {
	const github = await credential('github')
} catch (error) {
	if (error.code === 'credential_ambiguous') {
		// error.options: [{ name: 'work', scope: 'org', … }, { name: 'personal', … }]
		// error.retryWith: [{ credentials: { github: { name: 'work', scope: 'org' } } }, …]
	}
	if (error.code === 'credential_unbound') {
		// error.createUrl: https://kody.codes/@acme/-/credentials/new?provider=github&…
	}
	throw error
}

credential.named(name) picks by exact name in ad hoc code and in unshared packages in your personal org. Shared packages declare needs instead.

Webhook verification

Inbound webhooks verify the sender before any package code runs. Declare it on the webhook in kody.webhooks.

A provider that signs deliveries with a signing secret it issued (Sentry, Stripe) uses a Key named by secretName:

"verification": {
	"type": "hmac-sha256",
	"header": "sentry-hook-signature",
	"secretName": "sentryWebhookSecret",
	"encoding": "hex"
}

Omit secretName for GitHub-style hooks where Kody mints the signing secret onto the webhook URL itself. A sender that cannot sign uses the secret path segment of the minted URL as its credential:

"verification": { "type": "url-secret" }

Treat a minted webhook URL like any other credential. Details: Inbound webhooks.

Personal credentials

A Key, OAuth connection, or MCP server in your personal org is a personal credential. Grant it to a team org with credentialOrgGrant. It then works in that org only in your own runs: through credential(key), through {{secret:name}} and {{integration-token:name}} placeholders, and through createAuthenticatedFetch(name). An org credential with the same name wins first. Nobody else there can use it, Owners and Automation included. credentialOrgRevoke removes the grant.

There is no "assign to org" control that moves a credential from one workspace to another. If a connection or secret was created under the wrong workspace, reconnect or create it under the right org (use that org's Credentials / Connect pages, or pass --org / ?org=). Granting a personal credential into a team org shares it for your own runs there; it does not transfer ownership.

Grant APIs (credentialOrgGrant, credentialOrgRevoke, credentialOrgGrantList) are in Personal credentials in a team org (raw).

When a team org has registered an OAuth app, each Member can connect their own account through it (Connect your own account on the app's page, or /connect/oauth?org=<org>&app=<app>&mode=personal). The connection is yours and lives in your personal org, but it is pinned to that org: it is granted there automatically, can never be granted anywhere else, and works only in your own runs in that org. The org's client secret stays with the org. Org Owners can see who connected (member, account label, date) and revoke a connection, but never use it or read its tokens. Revoking, leaving the org, or deleting the app wipes it and revokes it with the provider where the provider supports that. See OAuth.

Limits

  • Using a personal MCP server through credential(). Add the MCP server to the org instead.
  • A verify operation on signing credentials for inbound webhooks. Keys verify webhooks.
  • Declaring OAuth scopes on a need. A missing scope shows up as a 401 or 403 with x-kody-credential-hint.

Secret mounts for bundled dependents

Packages that declare kody.secretMounts and read them with packageSecrets.get get an opaque {{secret:…}} ref. A dependent that statically imported the package bundled that call. The bundled copy still resolves the alias against the dependency's current published manifest, not the commit it was built from.

Publish may keep those mount declarations when the package's own source uses kody.credentials and does not call packageSecrets.get. A kept mount grants nothing new:

  • Resolution still reads only the mounts on the current manifest. There is no alias fallback, no history of old mounts, and no commit-pinned lookup.
  • The mounted secret name must also be a kody.credentials slot of kind secret or signing (or a slot with no kind). The alias is not a stamp key. A kept mount whose name is not declared fails closed (credential_need_undeclared) once the package has a credential stamp. A package or session scope on the mount does not skip that check.
  • Actor Use, package approval, and a fresh secret lookup still run. Deleting the secret cuts off old code on its next call. Old code cannot keep using a deleted secret.
  • New source that calls packageSecrets.get or packageSecretGet still fails the publish gate. A newly published dependent cannot borrow the dependency's mount: its own packageSecrets.get runs as the dependent, which does not declare that mount.

The credentials check says which aliases were kept. Publish results, static_dependents, locked-publish approval, and the approve-publish page name the stale static dependents that still read each mount. They also warn when a publish removes an alias those dependents bundled: removing the mount or deleting the secret will break those named packages.

Retire a mount only after static_dependents shows no stale bundle still calling packageSecrets.get for it (dependents republish onto the credential slot). Then delete the mount. Deleting the underlying secret is the other revocation lever and works even while the mount declaration remains.

Where to go next

  • Secrets: the Key kind in depth, host and package approval, and expiry.
  • Secrets and host approval: MCP-level reference for placeholders, secretSet fields, and personal grants.
  • Packages: the full kody.credentials and credential() reference.
  • OAuth and MCP servers: connect each kind.
  • ADR 0068: the design record for the credentials model.

Working with an agent? This page is also plain markdown at /docs/credentials.md, or load it over MCP with search({ entity: 'guide:credentials' }).