# Credentials

> Credentials give your app keys and connections that belong to an external service: an API key for Acme, an OpenAI key, a Google sign-in. Each user supplies their own, and the platform passes them to y...

**URL:** https://inference.sh/docs/extend/credentials
**Last updated:** 2026-10-31

---

Credentials give your app keys and connections that belong to an external service: an API key for Acme, an OpenAI key, a Google sign-in. Each user supplies their own, and the platform passes them to your app at run time.

A value that belongs to no service, such as a webhook signing token, is a plain [secret](/docs/extend/secrets) instead.

→ [Concepts: providers, credentials, secrets and vaults](/docs/secrets/overview#concepts)

---

## Two words

- A **company** is the organization behind a service: one catalog row with its name, logo, docs and legal links. Google is a company; so is Aikido.
- An **auth provider** is one way to connect to a company's service, named by its **credential slug**, the key every credential is stored and looked up by. Google has several: `google` (an account sign-in), `google-sa` (a service account), `google-gemini` (a Gemini API key). Each is an [auth provider](/docs/api/rest/auth-providers) that belongs to its company; how it signs in (`oauth2`, `api_key`, `http`) is its **auth method**.

A token is minted for one resource, so a service and its MCP server are two slugs even when the same company runs both: `abc` for the API, `abc-mcp` for the MCP server. An `abc` login never satisfies `abc-mcp`.

`credentials:` entries are keyed by the credential slug. `credential.provider` in the API still carries that slug.

---

## Declaring credentials

List them under `credentials:` in `inf.yml`, keyed by the credential slug:

```yaml
credentials:
  openai:                              # a slug the platform lists: nothing else needed
    secrets: [OPENAI_API_KEY]

  google:
    capabilities: [google.sheets]      # the OAuth access the app needs
    description: Read and write your sales sheets

  acme:                                # a slug the platform doesn't list yet
    secrets: [ACME_API_KEY]
    description: Syncs contacts from your Acme workspace
    company:                           # seeds the company row if missing; never overwrites it
      slug: acme
      name: Acme CRM
      website: acme.com
      logo_uri: https://acme.com/logo.png
      docs_uri: https://docs.acme.com
      tos_uri: https://acme.com/terms
      policy_uri: https://acme.com/privacy

  acme-mcp:                            # the same company's MCP server: its own slug
    scopes: [contacts:read]
    auth:                              # seeds the auth provider if the slug is free
      kind: oauth2
      flow: authorization_code
      authorization_endpoint: https://acme.com/oauth/authorize
      token_endpoint: https://acme.com/oauth/token
```

The list form still works and is read into the same shape: `provider: acme` (a string) becomes the slug `acme`; `key: google.sheets` becomes the slug `google` with `capabilities: [google.sheets]`; `name` and `website` become the `company` block. A `provider:` object (the name of the `company` block before INF-1079) is read as `company`. Existing apps need no change.

### API keys from a provider

Name the slug and the keys your app reads:

```yaml
credentials:
  acme:
    secrets: [ACME_API_KEY]
    description: Syncs contacts from your Acme workspace
    company:
      name: Acme CRM
      website: acme.com
```

When a user runs the app without an Acme key, the run stops and asks for `ACME_API_KEY`. The key they paste is saved as their workspace's **Acme CRM** credential. Its logo comes from `acme.com`, and it shows in the vault like any other credential. Later runs, and any other app that asks for `acme`, find it there.

The slug doesn't have to be one the platform lists. The `company` block describes the company for users who haven't added it yet; `company.slug` defaults to the credential slug. For a slug the platform lists, such as `openai`, leave the block out; the platform's name and logo win.

#### Who can list a company for everyone

The platform's company catalog is shared by every workspace, so only platform apps can add to it:

- **A platform app** (owned by a platform admin) that declares a company the catalog doesn't have adds it on deploy, with the given `name` and the logo from `website`. Every workspace then sees it in the vault, ready to connect. This happens the same way platform apps are approved in the store automatically.
- **Any other app** doesn't change the catalog. The slug still works for each workspace that runs the app: the key a user saves becomes their workspace's credential for it, under the app's `name` and logo. It just isn't listed for workspaces that haven't used it.

This keeps slugs from being claimed by whoever deploys first. To get a company listed for everyone, ask a platform admin.

### Describing the auth provider

An entry can also describe how people connect: the name they see, who a saved key belongs to, how to get the key and how long it lasts.

```yaml
credentials:
  claude-code:
    secrets: [CLAUDE_CODE_OAUTH_TOKEN]
    company: {slug: anthropic}
    display_name: Claude Code
    connection_scope: user
    instructions: |
      1. Run `claude setup-token` in a terminal and sign in with your Claude account.
      2. Paste the token it prints. It starts with `sk-ant-oat01-`.
    docs_url: https://inference.sh/docs/integrations/claude-code-subscription
    expires_after_days: 365
```

| Field | Effect |
|-------|--------|
| `display_name` | The name shown on the credential page, the setup dialog and requirement errors. Without it, the slug. |
| `connection_scope` | `team` (default): a saved key serves the whole workspace. `user`: each person saves their own. Use `user` for personal tokens, so one member's token is not shared with everyone. |
| `instructions` | Markdown, links allowed, up to 4000 characters. Shown wherever someone connects: the credential page, the add-key dialog, the app's setup flow and the agent chat's setup card. |
| `docs_url` | A page with the full setup guide, linked beside the instructions. |
| `expires_after_days` | A saved key counts as expired this many days after it is saved; saving a new key restarts the count. The credential page shows the expiry date; after it, runs stop and ask for a new key. `0` or absent: never. |

What they do depends on who deploys the app:

- **A platform app**: the fields complete the platform's auth provider for the slug. On the first deploy they set it up; on later deploys they fill only fields that are still empty, so a value a platform admin set is never overwritten.
- **Any other app**: the fields are validated and otherwise ignored, like `company`. Ask a platform admin to set them for a listed slug.

A deploy fails when `connection_scope` is not `team` or `user`, `instructions` are over 4000 characters, `display_name` is over 100, or `expires_after_days` is outside 0–3650.

### Deploy wires your workspace credential

Each time you deploy an app, the platform registers every `api_key` entry in `credentials:` for your workspace.

If you already saved the declared keys as **plain team secrets** in the vault (not linked to any credential), deploy creates the provider's login if needed, links those secrets to it and marks it **connected** — you do not need to paste the keys again in the setup dialog. Only secrets in the **deploying workspace** are considered, and only keys that are still unlinked; a key attached to another credential stays where it is.

If the keys are missing, deploy creates no credential row. The next run (or the vault UI) asks for exactly the keys listed under `secrets`, and saving them creates the login.

Leave out `secrets` to receive every key the provider's credential holds.

### OAuth capabilities

For a service users sign in to, name the capabilities. They decide the access (the OAuth scopes) your app gets:

```yaml
credentials:
  google:
    capabilities: [google.sheets, google.drive]
    description: Read and write your sales sheets, attach exported files
```

A capability belongs to its own slug: `google.drive` goes under `google`, never under another entry. An entry whose only need is optional can say `optional: true`.

Users without a Google connection are asked to connect one. Users who have one without Sheets access are asked to grant it.

A connect goes through an OAuth client: the user's workspace's own, else the platform's. The platform's client for a provider may carry a list of **allowed scopes** (the scopes it is approved for — the platform's Google client is held to the scopes Google verified it for); a workspace's own client is unrestricted by default. A connect asking the platform's client for a scope outside its list is refused with `scope <scope> is not approved for the platform's <Provider> client; connect through your own OAuth app or ask an admin`. Deploy warns when your `inf.yml` asks for such a scope (see [Deploy checks](#deploy-checks)); a platform admin edits the list as data. Details in [Allowed scopes](/docs/api/rest/credentials#allowed-scopes).

### Properties

The entry's key is the credential slug: lowercase letters, digits and single hyphens (`acme`, `google-sa`). It is what `credential.provider` stores and what `@function(credentials=["acme"])` names.

| Property | Type | Description |
|----------|------|-------------|
| `capabilities` | string[] | Capabilities of this slug the platform defines (`google.sheets`, `x.tweet.write`). When set, they decide how the entry is checked. Each must start with the entry's slug. |
| `scopes` | string[] | OAuth scopes to require from the credential, for an entry without `capabilities`. At connect time the API also requests any `required_scopes` on the slug's auth method (identity and refresh); see [Required scopes on connect](/docs/api/rest/credentials#required-scopes-on-connect). |
| `secrets` | string[] | Keys to pass to the app from the credential. They are also the fields users fill in when the key is missing. |
| `description` | string | Shown to users when the credential is missing |
| `optional` | boolean | If `true`, the app runs without it |
| `company` | object | The company, for a slug the platform doesn't list (`CompanySeed`): `slug` (defaults to the entry's), `name`, `description`, `website`, `logo_uri`, `docs_uri`, `tos_uri`, `policy_uri`. Seeds the company row when none exists; an existing row is never overwritten. Called `provider` before INF-1079; the object form of `provider` is still read, and the API's `CredentialRequirement.provider` still carries it. |
| `display_name` | string | The auth provider's name. See [Describing the auth provider](#describing-the-auth-provider). |
| `connection_scope` | string | `team` (default) or `user`: who a saved key belongs to |
| `instructions` | string | Markdown shown wherever someone connects (≤ 4000 characters) |
| `docs_url` | string | Setup guide linked beside the instructions |
| `expires_after_days` | integer | Days a saved key lasts (0–3650; 0 = never) |
| `auth` | object | An [auth method](/docs/api/rest/auth-providers#auth-method) for a slug no auth provider defines yet: `kind` (`oauth2`, `api_key`, `http`), `flow`, `authorization_endpoint`, `token_endpoint`, `scopes`, `fields`, and the rest. Validated at deploy like `POST /auth-providers`; seeds the auth provider when the slug is free. |

Legacy list-form keys (`provider` as a string, `key`, `name`, `website`) are still read. A deploy with an invalid slug, a capability under the wrong slug, or an invalid `auth` block fails with a validation error.

The section was called `integrations:` before 2026-09-23. Current belt refuses that name and asks you to rename it.

---

## Per-function credentials

In a [multi-function app](/docs/extend/multi-function-apps), `credentials:` in `inf.yml` lists everything the app may use. Each function can also declare the subset it uses:

- A function that declares `credentials` requires exactly those when it runs.
- A function that declares none requires the whole `inf.yml` list.

A Google Calendar app whose event functions need only event access, and whose calendar-level functions need full calendar access:

```yaml
# inf.yml
credentials:
  google:
    capabilities: [google.calendar.events, google.calendar]
    description: Read and edit events; create, share and delete calendars
```

<div data-type="codegroup">

```python Python
from inferencesh import BaseApp, function

class App(BaseApp):
    @function(
        credentials=[{"slug": "google", "capabilities": ["google.calendar.events"]}],
        annotations={"readOnlyHint": False, "destructiveHint": False},
    )
    async def create_event(self, input_data: CreateEventInput) -> CreateEventOutput:
        ...

    @function(
        credentials=[{"slug": "google", "capabilities": ["google.calendar"]}],
        annotations={"readOnlyHint": False, "destructiveHint": True},
    )
    async def delete_calendar(self, input_data: DeleteCalendarInput) -> DeleteCalendarOutput:
        ...
```

```javascript Node.js
import { z } from "zod";
import { meta } from "@inferencesh/app";

export const CreateEventInput = z.object({ /* ... */ });
export const CreateEventOutput = z.object({ /* ... */ });
export const CreateEventMeta = meta({
  credentials: [{ slug: "google", capabilities: ["google.calendar.events"] }],
  annotations: { readOnlyHint: false, destructiveHint: false },
});

export const DeleteCalendarInput = z.object({ /* ... */ });
export const DeleteCalendarOutput = z.object({ /* ... */ });
export const DeleteCalendarMeta = meta({
  credentials: [{ slug: "google", capabilities: ["google.calendar"] }],
  annotations: { readOnlyHint: false, destructiveHint: true },
});

export class App {
  async createEvent(input) { /* ... */ }
  async deleteCalendar(input) { /* ... */ }
}
```

</div>

A function entry takes the same properties as an `inf.yml` entry ([Properties](#properties)), with the slug as `slug`. The legacy `{"key": "google.calendar.events"}` and `{"provider": "acme"}` forms are still read. `annotations` are covered in [Function metadata](/docs/extend/multi-function-apps#function-metadata).

### What users are asked for

When a user runs `create_event` without a Google connection, the connect prompt asks for event access only (`google.calendar.events`, Google's `https://www.googleapis.com/auth/calendar.events` scope). Full calendar access is requested only when they run a function that declares `google.calendar`. A connection that already holds a broader capability satisfies a narrower one: `google.calendar` or `google.calendar.events` covers `google.calendar.events.readonly`, and `google.calendar.readonly` covers `google.calendar.calendarlist.readonly` and `google.calendar.events.readonly`. See [Google OAuth capabilities](/docs/credentials/google-oauth#available-capabilities).

### Deploy checks

Each function entry is validated like an `inf.yml` entry and must be covered by `inf.yml`:

| Function entry | Covered when `inf.yml` has |
|----------------|----------------------------|
| with `capabilities` | An entry for the same slug whose `capabilities` include each of the function's |
| without | An entry for the same slug whose `secrets` include all of the function's `secrets` and whose `scopes` include all of the function's `scopes` |

Coverage is by exact capability: in the example above, `inf.yml` lists `google.calendar.events` even though `google.calendar` grants more. A function entry that isn't covered fails the deploy.

Deploy also warns, without failing, when a function marked `readOnlyHint: true` requires a capability that has a narrower read-only counterpart, such as `google.gmail.modify` where `google.gmail.readonly` would do, or `google.calendar.events` where `google.calendar.events.readonly` would do. Declare the read-only capability for that function instead.

It also warns (`unapproved_platform_scope`) when a `credentials:` entry asks for a scope — through its capabilities or its `scopes` list — that the platform's OAuth client for that provider is not approved for ([allowed scopes](/docs/api/rest/credentials#allowed-scopes)): `credentials: google asks for https://www.googleapis.com/auth/youtube.readonly, which the platform's google client is not approved for; users connect through their own OAuth app, or an admin approves the scope`. The deploy goes through; users without their own OAuth app are refused at connect. Pick an approved capability, have users set up their own client, or ask a platform admin to approve the scope.

---

## Available capabilities

List the capability keys the platform defines:

```bash
belt app credentials list
```

### Common capabilities

| Key | Description |
|-----|-------------|
| `google.sheets` | Read/write Google Sheets |
| `google.sheets.readonly` | Read-only Sheets access |
| `google.drive` | Google Drive files |
| `google-sa.files` | Service account access (Sheets, Docs, Drive) |
| `microsoft.mail.read` | Read Outlook email |
| `notion.read` | Read shared Notion pages |
| `salesforce.api` | Salesforce REST API access |
| `reddit.read` | Read Reddit posts and comments |

→ [Credentials overview](/docs/credentials/overview) for setup guides (Google, Slack, Microsoft, Notion, Salesforce, Reddit, and more)

---

## Accessing credentials

### API keys

Each key listed under `secrets` arrives as an environment variable with the same name:

<div data-type="codegroup">

```python Python
import os

class App(BaseApp):
    async def setup(self, config):
        self.acme_key = os.environ["ACME_API_KEY"]
```

```javascript Node.js
export class App {
  async setup(config) {
    this.acmeKey = process.env.ACME_API_KEY;
  }
}
```

</div>

### OAuth Credentials

<div data-type="codegroup">

```python Python
import os

class App(BaseApp):
    async def setup(self, config):
        # The platform refreshes the token before each run; the app never sees a refresh token.
        self.token = os.environ["GOOGLE_OAUTH_ACCESS_TOKEN"]
        self.expires_at = os.environ.get("GOOGLE_OAUTH_TOKEN_EXPIRES_AT")  # RFC 3339
```

```javascript Node.js
export class App {
  async setup(config) {
    // The platform refreshes the token before each run; the app never sees a refresh token.
    this.token = process.env.GOOGLE_OAUTH_ACCESS_TOKEN;
    this.expiresAt = process.env.GOOGLE_OAUTH_TOKEN_EXPIRES_AT; // RFC 3339
  }
}
```

</div>

### Service Account

<div data-type="codegroup">

```python Python
from google.oauth2 import service_account

sa_json = os.environ.get("GOOGLE_SA_CREDENTIALS")
if sa_json:
    self.credentials = service_account.Credentials.from_service_account_info(
        json.loads(sa_json)
    )
```

```javascript Node.js
const saJson = process.env.GOOGLE_SA_CREDENTIALS;
if (saJson) {
  this.serviceAccount = JSON.parse(saJson);
}
```

</div>

---

## Secrets vs credentials

| | `secrets:` | `credentials:` |
|--|-----------|----------------|
| The value belongs to | No service | A provider |
| Users see | A key name to fill in | The provider, with its name and logo |
| Stored as | A plain secret | That provider's credential, with a scope and a status |
| Reused by | Apps that ask for the same key name | Every app that asks for the provider |
| Best for | Webhook tokens, feature flags | API keys, OAuth sign-ins |

---

## Check before run

Validate that your workspace has the secrets and credentials an app requires before calling `POST /run`:

`POST /credentials/check`

Requires `credentials:read` scope. Send the same `secrets` and `credentials` arrays from your app's `inf.yml`:

```bash
curl -X POST https://api.inference.sh/credentials/check \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "secrets": [{ "key": "WEBHOOK_SECRET" }],
    "credentials": [
      { "slug": "acme", "secrets": ["ACME_API_KEY"] },
      { "slug": "google", "capabilities": ["google.sheets"] }
    ]
  }'
```

When requirements are missing, the response matches the **412** body from `POST /run` (`satisfied: false` and an `errors` array). See [Check requirements](/docs/api/rest/credentials#check-requirements-pre-flight).

### Connect from the CLI

Connect native OAuth, API-key, and service-account providers without the dashboard:

```bash
belt credentials connect google
belt credentials connect slack
belt credentials connect grafana          # api_key: prompts for declared keys
belt credentials disconnect google
```

For **API key** providers, the CLI saves each required key with `POST /secrets` (not `POST /credentials`, which is OAuth-only). That is the same as `belt secrets set <KEY> --provider <provider>` for each field. Providers with no declared fields print `belt secrets set <KEY> --provider …` instead. Non-interactive runs print `belt secrets set <KEY> --value-stdin --provider <provider> < value-file` lines for you to run in CI.

For MCP connector servers, use `belt mcp connect <slug>` instead. List connected services with `belt credentials list` (`belt creds` is an alias).

When a provider has several credential slugs (Google has an account sign-in at `google` and a separate [service account](/docs/credentials/google-service-account) at `google-sa`), use the slug that matches the connection you need. Connect a Google service account from **vaults → credentials** (`google-sa` slug) or with `belt credentials connect google-sa`.

When you run an app or agent and requirements are still missing, the CLI prints the same `errors[]` categories with actionable commands — for example `belt secrets set <KEY>` (prompts for the value) or `belt credentials connect google`.

→ [CLI setup: Credentials](/docs/extend/cli-setup#credentials-cli) · [Credentials API](/docs/api/rest/credentials)

---

## Best Practices

1. **Request minimal scopes** - use `readonly` if you only read, and declare [per-function credentials](#per-function-credentials) so each function asks only for what it uses
2. **Clear descriptions** - explain why access is needed
3. **Handle missing gracefully** - check if optional credentials exist
4. **Test without** - ensure app works without optional credentials

---

## REST API

Programmatic connect, disconnect, and requirements checks:

→ [Credentials API](/docs/api/rest/credentials) — `POST /credentials`, `POST /credentials/check`, MCP tool proxy

---

## Next

→ [Troubleshooting](/docs/extend/troubleshooting) - Common issues and solutions
