Skip to main content
Every credential your integration declares has a level: who connects it and who it acts for. 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.
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:
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:
  • 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.

Who acts as whom

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.

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: 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 for the rules.

Testing

createMockContext() takes the person’s tier as identity and derives principal from the actor:
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.