> ## Documentation Index
> Fetch the complete documentation index at: https://sdk.ekanos.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Credential levels

> Declare who connects each credential: the team, each person, or the Source. Your contract derives its connection levels from them.

Every credential your integration declares has a **level**: who connects it and who it acts for.

| Level | Connected by | Acts for |
| - | - | - |
| `'team'` | a team admin, once for the team | the team (the default) |
| `'user'` | each person, for themselves | that person |
| `'source'` | a Source admin, once for every team | the Source's provided agents |

A credential's level is served for SDK integrations:

* `'team'`: stored on the team account. A team admin (someone with `products.activate`) connects it once, and it serves the whole team. This is the default.
* `'user'`: stored on the person's own personal account. Each member connects for themselves, with no admin permission needed. An integration whose every credential is `'user'` connects only per person.

The admitted declaration is the review: your levels are checked in the admission PR, and there is no separate approval step per Source. A super-admin can switch a Source's per-person credentials off for one integration. Then nobody in that Source can connect their own, and `ctx.identity` is never set there.

> [!WARNING]
>
> Still not built: `'source'` credentials are served only to a Source's provided
> agents, a managed agent (or a workflow in agent mode) never acts with a
> person's credential until agent lending ships, and the `client_credentials`
> grant is not supported. Your integration's own `schedules` and `webhooks`
> never act as a person.

## The API

Declare a user-level OAuth credential:

```ts theme={null}
oauth: {
  level: 'user',
  provider: { authorizationUrl, tokenUrl, scopes: ['w_member_social'] },
  credentials: { clientIdSecretName: 'client_id', clientSecretSecretName: 'client_secret' },
  async onTokens(ctx, tokens) {
    await ctx.secrets.set('access_token', tokens.accessToken);
    if (tokens.refreshToken) await ctx.secrets.set('refresh_token', tokens.refreshToken);
  },
},
```

In `onTokens`, always write with `ctx.secrets.set`. The host builds that context on the account that owns the credential: `ctx.connection?.owner === 'user'` for a user-level one. The same code stores a team token on the team and a person's token on that person. `ctx.secrets` there also reads the app's admin-issued client credentials, which the code exchange needs.

In a tool, action or widget handler, read the caller's own token and the team's:

```ts theme={null}
const own = await ctx.identity?.secrets.get('access_token'); // the person's, or undefined
const team = await ctx.secrets.get('access_token'); // the team's
const token = own ?? team; // reads may fall back; for writes, decide and attribute
```

* `ctx.principal` says who the call runs for: `{ kind: 'user', userId, displayName, avatarUrl }` or `{ kind: 'machine' }`. The host sets it from the session, never from a request body. It carries no email.
* `ctx.identity` is `{ userId, secrets }`: the caller's own credential tier. It is set only when the principal is a person who has connected a user-level credential and the Source serves the level. `identity.secrets` holds only that person's values, never the team's, the Source's or the app's client id and secret. `set` writes back to it, so a refreshed token lands there.
* `ctx.secrets` stays the team tier, so existing code is unchanged.

When a person has no credential, a read may fall back to the team's. For a write, your integration decides: it sees `ctx.identity` is absent. If it acts as the team on behalf of a person, attribute the write to `ctx.principal` ("created by Ada") where the vendor allows it. See [Capability context](https://sdk.ekanos.dev/sdk/capability-context.md#principal-and-identity).

## Who acts as whom

| Surface | `ctx.principal` | `ctx.secrets` | `ctx.identity` |
| - | - | - | - |
| Chat (dock, `/chat`) | the sender | team | the sender's own, if connected |
| Widget handlers and actions | the signed-in person | team | the person's own, if connected |
| Integration MCP route | the caller | team | the caller's own, if connected |
| Experience slot `['invoker']` | the signed-in person | the person's (`owner: 'user'`) | the same person store |
| Experience slot `['invoker', 'team']` | the signed-in person | team fallback | the person's own first |
| Personal workflow step, any trigger | the owner | team | the owner's own |
| Team workflow step a person granted | the grantor | team | the grantor's own |
| Any other team workflow step | machine | team | never |
| Workflow agent mode | the creator | team | never, until agent lending |
| Managed agent | the invoker or nobody | team | never, until agent lending |
| Your `schedules`, `webhooks`, health | machine | team | never |

A machine principal never has `ctx.identity`.

### Workflows that act for a person

A workflow can use a person's own credential while they are not signed in,
in two ways:

* **A personal workflow** acts for its owner on every trigger: run by hand, on
  a schedule, or from its webhook (which needs the workflow's own secret). The
  owner creating and enabling it is the consent.
* **A team workflow** acts for a person who granted it specific operations of
  your integration, from the workflow's page ("Use my account"). The grant can
  expire and the person can turn it off any time in their preferences.

Either way, only the steps the workflow declares get `ctx.identity` — never a
tool an AI step or a code step picks — and only while the workflow is
enabled. A team workflow's grant covers the whole workflow exactly as the
person approved it, and is checked against the version each run executes:
any change to its steps, gates, conditions, order, trigger or settings stops
the steps that act for them with `needs_reapproval` until the person reviews
what changed and re-approves. Renaming the workflow does not.

While a grant is waiting for re-approval, every step that reaches one of its
operations is refused, even a step set to `['team']`: a workflow changed
after someone's approval does not keep running those operations by another
route until they have looked at it.

What a grant does **not** pin: your integration's published code (a step
names a tool, not a version, so a new admitted release of your integration
runs under existing grants — keep a tool's behaviour stable under its name),
which integration an experience slot is bound to, and the team's own
connection settings and secrets.

Each step says whose credential it runs on, with the same `modes` words as
experience slots. The default, `['invoker', 'team']`, uses the person's own
credential and otherwise the team's; on the team's, the call runs as the team
(`ctx.principal` is a machine on a team workflow, `ctx.identity` is absent)
and is never attributed to the person. `['invoker']` runs only on the
person's own and fails with `not_connected` otherwise; `['team']` always runs
on the team's. Your code sees `ctx.principal` and `ctx.identity` exactly as
on the person's own call; nothing in your integration changes.

## Declare a level per credential

A credential is the main `oauth`, each `oauthConnections` entry, or a group of secret `activation.fields`. Leave `level` out and the credential is `'team'`, as it always was.

A Linear-shaped integration has three: the team's own app for its agent, each person's sign-in, and an optional personal API key.

```ts theme={null}
import { defineIntegration } from '@ekanos/sdk/integration';

export const integration = defineIntegration({
  slug: 'linear',
  // …
  oauth: {
    level: 'user', // each person signs in for themselves
    provider: {/* … */},
    credentials: {
      clientIdSecretName: 'linear_client_id',
      clientSecretSecretName: 'linear_client_secret',
    },
    onTokens: async (ctx, tokens) => {
      /* … */
    },
  },
  oauthConnections: {
    agent: {
      level: 'team', // the team's own app, connected once
      label: 'Linear agent',
      description: 'The app the agent acts as.',
      provider: {/* … */},
      credentials: {
        clientIdSecretName: 'agent_client_id',
        clientSecretSecretName: 'agent_client_secret',
      },
      teamApp: { fields: [/* … */] },
      onTokens: async (ctx, tokens) => {
        /* … */
      },
    },
  },
  activation: {
    fields: [
      {
        name: 'linear_api_key',
        label: 'Personal API key',
        secret: true,
        required: false,
      },
    ],
    credentials: [
      {
        id: 'personal-key',
        label: 'Your Linear API key',
        level: 'user',
        fields: ['linear_api_key'],
      },
    ],
  },
});
```

### Activation credentials

`activation.credentials` groups secret activation fields into credentials, each with an `id`, a `label`, a `level` and the `fields` it is made of (a key, or a key and its secret). The rules `defineIntegration()` checks:

* Every name in `fields` is a declared `activation.fields` entry with `secret: true`.
* Each `id` is kebab-case and unique, and each field belongs to one credential at most.
* An activation credential is `'team'` or `'user'`, never `'source'`.

Secret fields outside every group are one team credential, as before. How your code sends the key (`Authorization: <key>` or `Bearer <key>`) stays in your own `ctx.fetch` call; the host only stores it.

### Where `source` and `user` are allowed

* `'source'` is allowed only on the main `oauth`, and only with [`sourceConnection`](https://sdk.ekanos.dev/sdk/surfaces/routing): a Source connects your integration through its main connection and nothing else.
* A named connection with a `teamApp` is the team's own app, so its level is `'team'`.

## Connection levels in your contract

Your contract's `connection.levels` is derived from the credentials:

1. An explicit `connection.levels` wins.
2. Otherwise, once any credential states a level, the levels are every credential's level, sorted and without repeats (a credential that states none counts as `'team'`). The example above derives `['team', 'user']`.
3. Otherwise, the old inference from `proposes` applies: `['user']` for `credentialModel: 'user'`, `['source']` for `credentialModel: 'source'`, else `['user']` for `scope: 'user'`, else `['team']`.

So a definition that declares no level derives exactly the levels it did before. `declaredCredentialLevels(definition)` from `@ekanos/sdk/integration` returns the answer for steps 2 and 3.

Dropping a level is a breaking change in the contract diff; adding one is compatible.

## What the toolchain does with them

* `ekanos validate` and the integration admission checks report each credential declared at `'user'` (`definition.credential-levels`, info) so you can see which credentials are per person.
* They warn when an explicit `connection.levels` disagrees with the credential levels; `ekanos contract` reports the same as `contract/levels-mismatch`.
* They warn when `proposes.scope` is not one of the levels (`definition.scope-levels`), whether the levels are explicit or derived.
* `ekanos check` and the experience admission checks hold an experience's `modes` for your integration to the levels in its pinned contract. An experience with `modes: ['invoker', 'team']` passes against `['team', 'user']`. See [Connection levels](https://sdk.ekanos.dev/sdk/surfaces/oauth.md#connection-levels) for the rules.

## Testing

`createMockContext()` takes the person's tier as `identity` and derives `principal` from the actor:

```ts theme={null}
const ctx = createMockContext({
  integration: { slug: 'acme' },
  userId: 'u1',
  identity: { secrets: { access_token: 'mine' } },
  secrets: { account: { access_token: 'team' } },
});

await ctx.identity?.secrets.get('access_token'); // 'mine'
ctx.dumpIdentitySecrets(); // what the person's tier holds now
```

Pass `principal` to set a display name. A machine actor with `identity` throws, as a machine never has one on the host.

## In the harness

The Connect tab labels each credential with its level: **For the team**, **For you** or **For the Source**. Secret activation fields appear beside the OAuth connections, so the personal API key above can be entered locally; it is saved to `.ekanos/local-secrets.json` and read with `ctx.secrets.get('linear_api_key')`. Each credential has a **Connect as: me / the team** switch that defaults to its declared level, and each integration has **Run as: me / machine**, so you can exercise `ctx.identity` and the machine path locally.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.