Credentials

Agents need customer secrets — an API key, an OAuth token for a spreadsheet, a database password. The platform holds them so that your code never does, and so a customer can rotate a credential without touching a deployment.

Three parties, three steps:

Platform admin          declares WHAT the agent needs   (credential requirement)
        │
Org member              connects it                     (credential / connection)
        │
Your task, at runtime   resolves it                     (GET /resolve)

1. How storage works

Two backends, chosen by the credential's type, and both invisible to your task:

TypeStored inCustomer sets it up via
api_keyThe platform's encrypted secret storeA form
basic_authThe platform's encrypted secret storeA form
oauthNango, which also refreshes tokensThe Nango Connect modal

Your task resolves all three through one endpoint and branches on the returned type. You never talk to Nango.

2. Declare the requirements (platform admin)

Go to /acme/revenue-chat/credential-requirements/new.

Label — required. What the customer sees on the form: Anthropic API Key.

Credential TypeAPI Key (default), OAuth, or Basic Auth. If OAuth is greyed out with (Nango environment required), the agent has no Nango environment selected — fix that in the agent's settings first, see Platform setup.

Integration ID — required, and this is the key your code uses to find the connection in the payload. For API keys it is free text, so use a short stable identifier: anthropic. For OAuth it becomes a dropdown of the Nango integrations configured in the selected Nango environment — pick google-sheets. The value stored is Nango's unique key for that integration; if the dropdown is empty or renders as a plain text box, the integration does not exist yet — see OAuth integrations with Nango.

Treat the integration id as part of your code's contract. Renaming it later breaks connectionIds['anthropic'] in every task that reads it — the lookup returns undefined and the run fails at resolve time.

Description — optional, up to 300 characters. Shown on the customer's form. Worth writing for anything non-obvious: where in the third-party dashboard they find this value.

Required — a switch, on by default. Agent cannot run without this credential connected. Turn it off only for genuinely optional integrations.

Metadata hints — optional, and easy to overlook even though it solves a real problem. Some credentials need a non-secret companion value: an organisation id, a region, an account number. Declaring a hint adds a labelled field to the customer's form, and the value comes back to your task inside extras. Each hint has a key (starting with a letter, letters/digits/underscores, up to 64 characters), a label, an optional description, and a required flag. Up to 20 per requirement, keys unique.

For this tutorial, declare two requirements:

LabelTypeIntegration IDRequired
Anthropic API Keyapi_keyanthropicyes
Google Sheetsoauthgoogle-sheetsyes

Add a metadata hint spreadsheetId on the Google Sheets requirement — the task needs to know which sheet, and that is configuration, not a secret.

Repeat for nightly-etl, which needs the same Google Sheets requirement. Requirements are per agent; connections can be reused across agents in the org, so the customer will not authorise twice.

3. Connect them (org member)

This part the customer does, and any org member can — it does not need a platform admin. That is deliberate: the person holding the API key is rarely the person who set up the agent.

/acme/revenue-chat/credentials lists every requirement with its connection status. Clicking one opens the form for that requirement:

  • API key — a single password field, with a reveal toggle. Once connected the label changes to Replace API Key; the existing value is never shown back.
  • Basic auth — username and password.
  • OAuth — a Connection dropdown listing connections already authorised in this org for that integration, plus an option to authorise a new one. Choosing that opens Nango's Connect modal, the customer completes the provider's consent screen, and the connection is saved as soon as it succeeds.

Any metadata hints you declared appear as extra fields on the same form.

The OAuth form also has an OAuth overrides section for the cases where the default app is not enough: additional user scopes, extra authorisation parameters, or a customer-supplied OAuth client id and secret when they insist on their own app. Ignore it unless a customer asks.

Once a requirement is satisfied, the run payload will carry its connection.

4. Resolve at runtime

Now the code. Connections arrive in the trigger payload, keyed by integration id:

ts
payload.connectionIds
// {
//   anthropic:      { integrationId: 'anthropic',      connectionId: '12' },
//   'google-sheets': { integrationId: 'google-sheets', connectionId: '13' },
// }

Where connectionIds comes from

There are two ways in, and both hand you the same pair: the map, and the credentialsToken that authorises spending it. Neither is useful without the other — the token grants no access on its own, and a connection id cannot be resolved without it.

When the platform triggers the run — Run Now, the input surface, a schedule, the trigger endpoint, a webhook endpoint, or a zone app going through the platform — it looks up every active connection on the agent, builds the map, mints the token, and puts both in the task payload:

ts
const { connectionIds, credentialsToken } = payload

When the run starts on the runtime and announces itself with registerRun, the same pair comes back in the response:

ts
const { runId, credentialsToken, connectionIds } = await registerRun({ … })

Three properties hold on both paths:

  • The map covers only connections whose status is active. A requirement nobody has connected is an absent key, not a present-but-null one.
  • connectionIds and credentialsToken arrive together or not at all. An agent with no active connections receives neither, because there would be nothing to resolve — so a token in hand always means a map in hand.
  • Both are per run. The token is valid for 60 minutes from the start of the run — long enough to resolve, not a budget for the whole run. Do not stash either across runs.

Because the shapes match, the same resolution code works either way — read the map from payload.connectionIds or from the register response and pass it on unchanged.

Resolving

cxpa.resolveCredential(connectionId) exchanges an id for the secret. The response is a discriminated union:

ts
const cred = await cxpa.resolveCredential(id)

cred.type === 'api_key'    // { value, extras }
cred.type === 'basic_auth' // { username, password, extras }
cred.type === 'oauth'      // { access_token, extras }

Rather than scatter that across a task, wrap it once. This helper is worth copying nearly verbatim:

ts
import { createAgentClient, type AgentClient } from '@cxpa/sdk/agent'

export type ConnectionIds = Record<
  string,
  { integrationId: string; connectionId: string }
>

export type ResolveCtx = {
  client: AgentClient
  connectionIds: ConnectionIds
}

export function buildResolveCtx(opts: {
  runId: string
  agentApiKey: string | undefined
  credentialsToken: string | undefined
  connectionIds?: ConnectionIds
}): ResolveCtx {
  const baseUrl = process.env.CXPA_API_URL
  if (!baseUrl) throw new Error('CXPA_API_URL is not set')
  if (!opts.agentApiKey) throw new Error('Agent API key is not set')
  if (!opts.credentialsToken) {
    throw new Error('No credentialsToken in the payload — cannot resolve credentials')
  }

  return {
    client: createAgentClient({
      baseUrl,
      apiKey: opts.agentApiKey,
      runId: opts.runId,
      credentialsToken: opts.credentialsToken,
    }),
    connectionIds: opts.connectionIds ?? {},
  }
}

/** Resolve a secret by integration id. */
export async function resolveSecret(
  ctx: ResolveCtx,
  integrationId: string,
): Promise<string> {
  const connectionId = ctx.connectionIds[integrationId]?.connectionId
  if (!connectionId) {
    throw new Error(
      `No connection for "${integrationId}" — is the credential requirement connected?`,
    )
  }

  const cred = await ctx.client.resolveCredential(connectionId)
  if (cred.type === 'api_key') return cred.value
  if (cred.type === 'oauth') return cred.access_token
  throw new Error(`Unsupported credential type for ${integrationId}: ${cred.type}`)
}

Used from the task:

ts
const resolveCtx = buildResolveCtx({
  runId: ctx.run.id,
  agentApiKey: process.env.CXPA_REVENUE_CHAT_AGENT_API_KEY,
  credentialsToken: payload.credentialsToken,
  connectionIds: payload.connectionIds,
})

const anthropicKey = await resolveSecret(resolveCtx, 'anthropic')

Fail loudly, and never keep a copy

Notice that every path out of this helper is either a secret or a thrown error. That is deliberate: the platform is the only source of credentials. Do not add an environment-variable fallback for when resolution fails.

It is tempting — a task that "still works" when the platform is unreachable sounds robust. What it actually does is hide the two failures you most need to see: a customer who never connected the credential, and an integration id that no longer matches the requirement. Both produce a run that succeeds against the wrong account or with stale access, and neither shows up until someone questions the output. A run that fails with No connection for "anthropic" is diagnosed in seconds.

It also keeps the security story simple. With no fallback there is exactly one copy of each customer secret, held by the platform, and rotating it takes effect on the next run with nothing to redeploy.

For the same reason, resolve credentials when you need them and let them go — do not cache a resolved secret in a module-level variable or write it to your own database.

Developing locally without a fallback

Since the task cannot invent credentials, local development means giving it a real payload. Point a platform runtime environment at your Trigger.dev Development environment, run trigger dev, and start runs from the platform — Run Now, or the input surface.

The run then arrives with genuine connection ids and a genuine credentials token, and the task executes on your machine with a debugger attached. This is strictly better than a fallback: you are exercising the same code path that production uses, so credential problems surface while you are still writing the task rather than after deploying it.

The one thing you cannot do is invoke the task by hand with a payload you typed and expect credentials to resolve. That is fine — trigger it from the platform instead.

Reading metadata out of extras

Every resolved credential carries extras. For OAuth it merges the provider's own fields (token_type, expires_at, scope) with the metadata the customer entered — and your metadata wins on a collision:

ts
const cred = await ctx.client.resolveCredential(connectionId)
if (cred.type !== 'oauth') throw new Error('expected an OAuth connection')

const spreadsheetId = String(cred.extras?.spreadsheetId ?? '').trim()
if (!spreadsheetId) throw new Error('google-sheets connection is missing spreadsheetId')

await fetchSheet(spreadsheetId, cred.access_token)

This is how per-customer configuration reaches your task without a deployment or a config file.

5. Things that will bite

Resolve everything up front — the token expires after 60 minutes. That TTL bounds when you may call /resolve; it does not bound the run. Resolve each credential you need at the start, keep the returned value in memory, and the task can then run for hours — a twelve-hour ETL is fine as long as it resolved in the first minute.

What fails is lazy resolution: a task that first calls resolveCredential at minute 90 gets a 401, and because that is usually deep inside a long job it surfaces as a mysterious late-stage failure. If a task branches such that a credential is only sometimes needed, resolve it anyway before the branch.

Do not carry resolved values across runs, though — cache them for the life of the run and no longer. Rotations and OAuth refreshes take effect on the next run, and a cached secret defeats that.

Keys are scoped per agent. revenue-chat's API key cannot resolve nightly-etl's connections. This is why each agent gets its own environment variable.

A 403 on resolve means the token belongs to a different agent or run — usually a copied-and-pasted env var. A 401 means the key or token is wrong or expired. A 404 means the connection id is not visible to this agent, which usually means the requirement was never connected.

Next

The agent can reach its dependencies. Now decide how runs start: Triggering runs.