Authentication
Configure OAuth 2.0 and API key authentication for your app, and access tokens at runtime.
Authentication
Most apps need credentials to talk to external services. The Agent Apps SDK provides two main authentication strategies — OAuth 2.0 for delegated user flows, and API key for static secrets — plus a set of runtime helpers for reading tokens, checking auth status, and refreshing credentials.
Auth types at a glance
| Type | auth.type | Use case |
|---|---|---|
| None | "none" or false | App requires no authentication |
| API key | "api-key" | Static secrets (personal tokens, service keys) |
| OAuth 2.0 | "oauth2" | Delegated user flows (Google, Microsoft, GitHub, etc.) |
| GitHub App | "github-app" | GitHub App installations with optional user OAuth |
When auth is omitted or set to false, the app has no authentication configuration and ctx.requestAuth() will reject.
Declaring auth in the manifest
The auth field in agentapp.json declares which strategy your app uses. The host uses this to build the right configuration UI at install time and to wire up the correct callbacks at runtime.
API key
Best for services that issue static credentials — personal access tokens, service account keys, or simple API keys.
{
"auth": {
"type": "api-key",
"systemSecrets": ["OPENAI_API_KEY"],
"userSecrets": ["GITHUB_TOKEN"],
"headerName": "Authorization",
"headerPrefix": "Bearer "
}
}| Field | Default | Description |
|---|---|---|
systemSecrets | [] | App-wide secrets shared across all installations (service keys) |
userSecrets | [] | Per-user secrets the user configures after install (personal tokens) |
headerName | "Authorization" | HTTP header name the host uses when injecting the key |
headerPrefix | "Bearer " | Prefix prepended to the secret value in the header |
Fail-fast behavior: when userSecrets are declared but not yet configured by the user, the SDK rejects every tool call with a clear UNAUTHORIZED error — your handler never runs without credentials.
OAuth 2.0
For services that support delegated authorization (Google, Microsoft, Slack, etc.).
{
"auth": {
"type": "oauth2",
"provider": "google",
"authorizeUrl": "https://accounts.google.com/o/oauth2/v2/auth",
"tokenUrl": "https://oauth2.googleapis.com/token",
"scopes": ["https://www.googleapis.com/auth/gmail.readonly"],
"optionalScopes": ["https://www.googleapis.com/auth/gmail.send"],
"pkce": true,
"systemSecrets": ["CLIENT_ID", "CLIENT_SECRET"],
"userSecrets": ["ACCESS_TOKEN", "REFRESH_TOKEN"],
"extraFields": [
{
"name": "TEAM_ID",
"label": "Slack Team ID",
"type": "text",
"required": true,
"placeholder": "T01234ABC",
"hint": "Found in your Slack workspace settings"
}
]
}
}| Field | Required | Description |
|---|---|---|
provider | yes | OAuth provider identifier (e.g. "google", "microsoft") |
authorizeUrl | yes | OAuth2 authorization endpoint URL |
tokenUrl | yes | OAuth2 token exchange endpoint URL |
scopes | yes | Required scopes (at least one) |
optionalScopes | no | Scopes the user can opt into |
scopeSeparator | no | Separator used to join scopes (defaults to space) |
pkce | no | Enable PKCE for public clients without client_secret |
systemSecrets | no | System-level secrets (e.g. CLIENT_ID, CLIENT_SECRET) |
userSecrets | no | User-level secrets (e.g. ACCESS_TOKEN, REFRESH_TOKEN) |
extraFields | no | Extra user-configurable fields shown at install time |
GitHub App
A specialized flow for GitHub Apps that supports installation tokens and optional user OAuth.
{
"auth": {
"type": "github-app",
"provider": "github",
"appSlug": "my-github-app",
"setupUrl": "https://github.com/apps/my-github-app/installations/new",
"userOAuth": true,
"tokenUrl": "https://github.com/login/oauth/access_token",
"permissions": {
"issues": "write",
"pull_requests": "read",
"contents": "read"
},
"events": ["issues", "pull_request"],
"systemSecrets": ["GITHUB_APP_ID", "PRIVATE_KEY", "WEBHOOK_SECRET"],
"userSecrets": ["INSTALLATION_ID"]
}
}When userOAuth is true, both tokenUrl and at least one events entry are required.
OAuth flow from your app
ctx.requestAuth()
Initiates an OAuth flow by asking the host to open a browser, complete the authorization, and store the resulting tokens.
// Request OAuth authentication — the host handles the browser flow
const result = await ctx.requestAuth({
provider: 'google',
scopes: ['https://www.googleapis.com/auth/gmail.readonly'],
timeoutMs: 300_000, // optional, defaults to 5 minutes
});
// result: { accessToken, refreshToken?, expiresAt? }
console.log(result.accessToken);The provider must match the one declared in the manifest's auth.provider field. If the app's auth is not oauth2 (or is false/none/api-key), requestAuth() throws.
ctx.auth.getStatus()
Check whether the user is currently authenticated without triggering a flow.
const status = await ctx.auth.getStatus();
// status: {
// authenticated: boolean;
// provider?: string;
// scopes?: string[];
// expiresAt?: string;
// authRequired?: boolean;
// authUrl?: string;
// }
if (!status.authenticated) {
// Prompt the user to authenticate
await ctx.requestAuth({ provider: 'google' });
}ctx.auth.getUrl()
Get just the authorization URL without completing the flow. Useful when you need to display the link in a custom UI.
const { url, state } = await ctx.auth.getUrl('google', [
'https://www.googleapis.com/auth/gmail.readonly',
]);
// Open the URL in the user's browser
await ctx.openUrl({ url, title: 'Connect your Google account' });ctx.auth.refresh()
Force a token refresh using the host-stored refresh token. The host handles the exchange and persists the new credentials — no client secrets in your app code.
try {
const refreshed = await ctx.auth.refresh();
console.log('New access token:', refreshed.accessToken);
} catch (err) {
// Refresh failed — the user may need to re-authenticate
console.error('Token refresh failed:', err.message);
}API keys and manual secrets
ctx.secrets.getUser()
Retrieve all user-level secrets as a key-value map.
const userSecrets = await ctx.secrets.getUser();
const apiKey = userSecrets?.['GITHUB_TOKEN'];
if (!apiKey) {
throw new Error('GitHub token not configured. Please set it in your app settings.');
}ctx.userSecret(name)
Convenience method to read a single user secret by name.
const apiKey = await ctx.userSecret('GITHUB_TOKEN');
if (!apiKey) {
throw new Error('GitHub token not configured');
}
// Use the token in an API call
const response = await fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${apiKey}` },
});ctx.secrets.getSystem()
Retrieve system-level secrets (shared across all installations of this app).
const systemSecrets = await ctx.secrets.getSystem();
const clientId = systemSecrets?.['CLIENT_ID'];
const clientSecret = systemSecrets?.['CLIENT_SECRET'];ctx.systemSecret(name)
Convenience method to read a single system secret by name.
const clientId = await ctx.systemSecret('CLIENT_ID');ctx.secrets.updateUser(secrets)
Update user-level secrets programmatically — for example, after refreshing a token inside your app.
// After obtaining a new token through your own refresh logic
await ctx.secrets.updateUser({
ACCESS_TOKEN: newAccessToken,
REFRESH_TOKEN: newRefreshToken,
});ctx.requestSecret()
Prompt the user to provide a secret value interactively. The host shows a UI for the user to enter the value.
const result = await ctx.requestSecret({
name: 'STRIPE_API_KEY',
description: 'Enter your Stripe secret API key (starts with sk_live_)',
timeoutMs: 300_000,
});
console.log('Secret received:', result.value);Complete examples
OAuth 2.0 app (Google Calendar)
import { createApp } from '@agentapps/sdk';
export default createApp({
id: 'app.google.calendar',
version: '1.0.0',
name: 'Google Calendar',
description: 'View and manage Google Calendar events',
// ... other required fields
auth: {
type: 'oauth2',
provider: 'google',
authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenUrl: 'https://oauth2.googleapis.com/token',
scopes: ['https://www.googleapis.com/auth/calendar'],
pkce: true,
systemSecrets: ['CLIENT_ID', 'CLIENT_SECRET'],
userSecrets: ['ACCESS_TOKEN', 'REFRESH_TOKEN'],
},
runtime: {
transport: 'http',
teros: { containerMode: 'process' },
},
}, (app) => {
app.tool({
name: 'list_events',
description: 'List upcoming calendar events',
parameters: {
type: 'object',
properties: {
maxResults: { type: 'number', description: 'Maximum number of events to return' },
},
},
handler: async (args, ctx) => {
// Check auth status first
const status = await ctx.auth.getStatus();
if (!status.authenticated) {
// Trigger the OAuth flow — opens browser, stores tokens
await ctx.requestAuth({ provider: 'google' });
}
// Get the stored access token
const userSecrets = await ctx.secrets.getUser();
const accessToken = userSecrets?.['ACCESS_TOKEN'];
if (!accessToken) {
throw new Error('No access token found. Please re-authenticate.');
}
const maxResults = (args.maxResults as number) ?? 10;
const response = await fetch(
`https://www.googleapis.com/calendar/v3/calendars/primary/events?maxResults=${maxResults}`,
{ headers: { Authorization: `Bearer ${accessToken}` } },
);
if (response.status === 401) {
// Token expired — try refreshing
const refreshed = await ctx.auth.refresh();
const retryResponse = await fetch(
`https://www.googleapis.com/calendar/v3/calendars/primary/events?maxResults=${maxResults}`,
{ headers: { Authorization: `Bearer ${refreshed.accessToken}` } },
);
return await retryResponse.json();
}
return await response.json();
},
});
});API key app (GitHub)
import { createApp } from '@agentapps/sdk';
export default createApp({
id: 'app.github.issues',
version: '1.0.0',
name: 'GitHub Issues',
description: 'View and manage GitHub issues',
// ... other required fields
auth: {
type: 'api-key',
userSecrets: ['GITHUB_TOKEN'],
headerName: 'Authorization',
headerPrefix: 'Bearer ',
},
runtime: {
transport: 'http',
teros: { containerMode: 'process' },
},
}, (app) => {
app.tool({
name: 'list_issues',
description: 'List open issues in a repository',
parameters: {
type: 'object',
properties: {
owner: { type: 'string', description: 'Repository owner' },
repo: { type: 'string', description: 'Repository name' },
},
required: ['owner', 'repo'],
},
handler: async (args, ctx) => {
// ctx.userSecret() is the simplest way to read a single secret
const token = await ctx.userSecret('GITHUB_TOKEN');
if (!token) {
throw new Error('GitHub token not configured. Set GITHUB_TOKEN in your app settings.');
}
const owner = args.owner as string;
const repo = args.repo as string;
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/issues?state=open`,
{
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json',
},
},
);
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
const issues = await response.json();
return issues.map((issue: any) => ({
number: issue.number,
title: issue.title,
state: issue.state,
url: issue.html_url,
}));
},
});
});Health checks with auth
Use the onHealthCheck handler to verify credentials are present and report auth issues to the host.
import { createApp } from '@agentapps/sdk';
export default createApp({
// ...
auth: {
type: 'oauth2',
provider: 'google',
// ...
},
}, (app) => {
app.healthCheck(async (ctx) => {
const status = await ctx.auth.getStatus();
if (!status.authenticated) {
return {
status: 'not_ready',
message: 'Google authentication required',
issues: [{
type: 'auth_required',
message: 'Connect your Google account to start using this app',
action: 'authenticate',
}],
};
}
if (status.expiresAt) {
const expiresAt = new Date(status.expiresAt);
const now = new Date();
const fiveMinutes = 5 * 60 * 1000;
if (expiresAt.getTime() - now.getTime() < fiveMinutes) {
// Proactively refresh
try {
await ctx.auth.refresh();
} catch {
return {
status: 'degraded',
message: 'Token expiring soon and refresh failed',
};
}
}
}
return { status: 'ready' };
});
});Best practices
Never hardcode tokens
// ❌ Never do this
const TOKEN = 'ghp_xxxxxxxxxxxx';
// ✅ Always read from the host-managed secret store
const token = await ctx.userSecret('GITHUB_TOKEN');Secrets are injected by the host at runtime — your app never sees the raw credential at build time, and they are never logged or serialized into tool results.
Always check auth status before API calls
// ✅ Check first, then request auth if needed
const status = await ctx.auth.getStatus();
if (!status.authenticated) {
await ctx.requestAuth({ provider: 'google' });
}Use ctx.auth.refresh() instead of manual refresh logic
The host stores refresh tokens and handles the exchange centrally. Calling ctx.auth.refresh() avoids race conditions between concurrent instances and keeps client secrets out of your app code.
Handle expired tokens gracefully
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (response.status === 401) {
// Try refreshing once
const refreshed = await ctx.auth.refresh();
// Retry with the new token
return await fetch(url, {
headers: { Authorization: `Bearer ${refreshed.accessToken}` },
});
}Declare all secrets in the manifest
Secrets not declared in auth.systemSecrets or auth.userSecrets won't be available at runtime. The host only injects declared secrets.
{
"auth": {
"type": "api-key",
"userSecrets": ["GITHUB_TOKEN", "GITHUB_ORG"]
}
}Use systemSecrets for shared credentials, userSecrets for per-user
systemSecrets— shared across all installations (e.g.CLIENT_ID,CLIENT_SECRET). Configured once by the app author or admin.userSecrets— unique per installation (e.g.ACCESS_TOKEN, personal API keys). Configured by each user after install.
Next steps
- Implement tools — add tools that use authenticated APIs
- Storage — persist data scoped to your installation
- Events — emit and receive events